Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,4 @@ dist/
build/

**/.claude
.gstack/
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""add ontology and insight map tables"""

revision = '838ba1ab5f45'
down_revision = 'cb758bd5da5f'
branch_labels = None
depends_on = None

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('ontology_concepts',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('concept_key', sa.String(length=100), nullable=False),
sa.Column('label', sa.String(length=255), nullable=False),
sa.Column('synonyms', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('language_area', sa.String(length=100), nullable=True),
sa.Column('concept_type', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_ontology_concepts')),
sa.UniqueConstraint('concept_key', name=op.f('uq_ontology_concepts_concept_key'))
)
op.create_table('ontology_edges',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('source_concept_id', sa.UUID(), nullable=False),
sa.Column('target_concept_id', sa.UUID(), nullable=False),
sa.Column('relation_type', sa.String(length=50), nullable=False),
sa.Column('evidence_source_ids', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('confidence', sa.Numeric(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['source_concept_id'], ['ontology_concepts.id'], name=op.f('fk_ontology_edges_source_concept_id_ontology_concepts'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['target_concept_id'], ['ontology_concepts.id'], name=op.f('fk_ontology_edges_target_concept_id_ontology_concepts'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_ontology_edges'))
)
op.create_table('patient_metric_trends',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('patient_ref_id', sa.UUID(), nullable=False),
sa.Column('therapist_id', sa.UUID(), nullable=False),
sa.Column('metric', sa.String(length=100), nullable=False),
sa.Column('trend_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['patient_ref_id'], ['patient_refs.id'], name=op.f('fk_patient_metric_trends_patient_ref_id_patient_refs'), ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['therapist_id'], ['users.id'], name=op.f('fk_patient_metric_trends_therapist_id_users'), ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_patient_metric_trends'))
)
op.create_table('soap_case_indexes',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('patient_ref_id', sa.UUID(), nullable=False),
sa.Column('session_id', sa.UUID(), nullable=False),
sa.Column('report_id', sa.UUID(), nullable=False),
sa.Column('therapist_id', sa.UUID(), nullable=False),
sa.Column('language_area', sa.String(length=100), nullable=True),
sa.Column('metric', sa.String(length=100), nullable=True),
sa.Column('observed_issue', sa.String(length=255), nullable=True),
sa.Column('intervention', sa.String(length=255), nullable=True),
sa.Column('age_band', sa.String(length=50), nullable=True),
sa.Column('summary', sa.Text(), nullable=False),
sa.Column('concept_ids', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['patient_ref_id'], ['patient_refs.id'], name=op.f('fk_soap_case_indexes_patient_ref_id_patient_refs'), ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['report_id'], ['reports.id'], name=op.f('fk_soap_case_indexes_report_id_reports'), ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], name=op.f('fk_soap_case_indexes_session_id_sessions'), ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['therapist_id'], ['users.id'], name=op.f('fk_soap_case_indexes_therapist_id_users'), ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_soap_case_indexes'))
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('soap_case_indexes')
op.drop_table('patient_metric_trends')
op.drop_table('ontology_edges')
op.drop_table('ontology_concepts')
# ### end Alembic commands ###
6 changes: 6 additions & 0 deletions app/api/v1/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from app.api.v1.endpoints.analysis import router as analysis_router
from app.api.v1.endpoints.audio import router as audio_router
from app.api.v1.endpoints.auth import router as auth_router
from app.api.v1.endpoints.insight_map import case_index_router as soap_case_index_router
from app.api.v1.endpoints.insight_map import router as insight_map_router
from app.api.v1.endpoints.patients import router as patients_router
from app.api.v1.endpoints.report import router as report_router
from app.api.v1.endpoints.report_chat import router as report_chat_router
Expand All @@ -27,6 +29,10 @@
api_v1_router.include_router(report_chat_router, prefix="/reports", tags=["report-chat"])
api_v1_router.include_router(templates_router, prefix="/templates", tags=["templates"])
api_v1_router.include_router(transcript_router, prefix="/transcripts", tags=["transcripts"])
api_v1_router.include_router(insight_map_router, prefix="/insight-map", tags=["insight-map"])
api_v1_router.include_router(
soap_case_index_router, prefix="/soap-case-indexes", tags=["insight-map"]
)
api_v1_router.include_router(
analysis_internal_router,
prefix="/internal/analysis-jobs",
Expand Down
65 changes: 65 additions & 0 deletions app/api/v1/endpoints/insight_map.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Insight map Graph API: concept/patient-history/SOAP-case-index visualization + search."""

from __future__ import annotations

import uuid

from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session

from app.api.dependencies import get_current_user
from app.core.db import get_db_session
from app.schemas.auth import UserRead
from app.schemas.insight_map import (
EvidenceResponse,
InsightMapResponse,
InsightMapSearchRequest,
SourceLinkResponse,
)
from app.services.insight_map import InsightMapService

router = APIRouter()
case_index_router = APIRouter()

_DEFAULT_MODE = ["research", "current_patient", "my_soap"]


@router.get("", response_model=InsightMapResponse)
def get_insight_map(
report_draft_id: uuid.UUID | None = Query(default=None),
mode: list[str] = Query(default=_DEFAULT_MODE),
current_user: UserRead = Depends(get_current_user),
db: Session = Depends(get_db_session),
) -> InsightMapResponse:
service = InsightMapService(db)
return service.get_insight_map(report_draft_id, mode, current_user)


@router.post("/search", response_model=InsightMapResponse)
def search_insight_map(
request: InsightMapSearchRequest,
current_user: UserRead = Depends(get_current_user),
db: Session = Depends(get_db_session),
) -> InsightMapResponse:
service = InsightMapService(db)
return service.search(request.query, request.report_draft_id, request.mode, current_user)


@router.get("/concepts/{concept_key}/evidence", response_model=EvidenceResponse)
def get_concept_evidence(
concept_key: str,
current_user: UserRead = Depends(get_current_user),
db: Session = Depends(get_db_session),
) -> EvidenceResponse:
service = InsightMapService(db)
return service.get_concept_evidence(concept_key, current_user)


@case_index_router.get("/{case_index_id}/source-link", response_model=SourceLinkResponse)
def get_case_index_source_link(
case_index_id: uuid.UUID,
current_user: UserRead = Depends(get_current_user),
db: Session = Depends(get_db_session),
) -> SourceLinkResponse:
service = InsightMapService(db)
return service.get_source_link(case_index_id, current_user)
62 changes: 62 additions & 0 deletions app/core/authorization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Object-level authorization helpers shared across services.

Report/session/patient ownership checks were previously duplicated ad-hoc in
each service (e.g. ``ReportService._get_accessible_session``,
``PatientService._get_accessible``). This module centralizes the same
ADMIN-bypass / SLP-owns-resource rule so new endpoints — starting with the
insight map's Graph API — don't reimplement it, and IDOR checks stay
consistent even when a resource is reached through a different path
(report_draft_id, patient_ref_id, case_index_id).
"""

from __future__ import annotations

import uuid

from fastapi import HTTPException, status
from sqlalchemy.orm import Session as DbSession

from app.core.enums import SessionStatus, UserRole
from app.models.entities import PatientRef, Report, Session as SessionEntity, SoapCaseIndex
from app.schemas.auth import UserRead


def ensure_session_access(db: DbSession, session_id: uuid.UUID, current_user: UserRead) -> SessionEntity:
session = db.get(SessionEntity, session_id)
if session is None or session.status == SessionStatus.DELETED:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found.")
if current_user.role == UserRole.ADMIN:
return session
if current_user.role == UserRole.SLP and session.slp_id == current_user.id:
return session
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied.")


def ensure_report_access(db: DbSession, report_id: uuid.UUID, current_user: UserRead) -> Report:
report = db.get(Report, report_id)
if report is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report not found.")
ensure_session_access(db, report.session_id, current_user)
return report


def ensure_patient_ref_access(db: DbSession, patient_ref_id: uuid.UUID, current_user: UserRead) -> PatientRef:
ref = db.get(PatientRef, patient_ref_id)
if ref is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Patient not found.")
if current_user.role == UserRole.ADMIN:
return ref
if ref.current_slp_id == current_user.id:
return ref
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied.")


def ensure_case_index_access(db: DbSession, case_index_id: uuid.UUID, current_user: UserRead) -> SoapCaseIndex:
case_index = db.get(SoapCaseIndex, case_index_id)
if case_index is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Case index not found.")
if current_user.role == UserRole.ADMIN:
return case_index
if case_index.therapist_id == current_user.id:
return case_index
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied.")
87 changes: 86 additions & 1 deletion app/models/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from datetime import date, datetime
from typing import Any

from sqlalchemy import BigInteger, Boolean, Date, DateTime, Enum, Float, ForeignKey, Integer, String, Text
from sqlalchemy import BigInteger, Boolean, Date, DateTime, Enum, Float, ForeignKey, Integer, Numeric, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column

Expand Down Expand Up @@ -414,3 +414,88 @@ class ReportDraftRevision(Base):
UUID(as_uuid=True), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)


class OntologyConcept(Base):
"""근거 기반 임상 인사이트맵의 기준 지식 레이어(노드).

논문/가이드에서 추출한 개념(MLU, TTR, expressive_language 등)을 저장한다.
concept_type 예: metric, language_area, observed_issue, intervention.
"""

__tablename__ = "ontology_concepts"

id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
concept_key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
label: Mapped[str] = mapped_column(String(255), nullable=False)
synonyms: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
language_area: Mapped[str | None] = mapped_column(String(100), nullable=True)
concept_type: Mapped[str] = mapped_column(String(50), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)


class OntologyEdge(Base):
"""개념 간 관계(엣지). relation_type 예: measures, belongs_to, related_to, related_intervention."""

__tablename__ = "ontology_edges"

id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
source_concept_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("ontology_concepts.id", ondelete="CASCADE"), nullable=False
)
target_concept_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("ontology_concepts.id", ondelete="CASCADE"), nullable=False
)
relation_type: Mapped[str] = mapped_column(String(50), nullable=False)
evidence_source_ids: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
confidence: Mapped[float | None] = mapped_column(Numeric, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)


class SoapCaseIndex(Base):
"""SOAP note(=Report) 원문에서 추출한 구조화 색인.

원문은 저장하지 않는다 — report_id만 참조하고, 상세 조회는 별도 권한 검증 API를 거친다.
"""

__tablename__ = "soap_case_indexes"

id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
patient_ref_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("patient_refs.id", ondelete="RESTRICT"), nullable=False
)
session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="RESTRICT"), nullable=False
)
report_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("reports.id", ondelete="RESTRICT"), nullable=False
)
therapist_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
language_area: Mapped[str | None] = mapped_column(String(100), nullable=True)
metric: Mapped[str | None] = mapped_column(String(100), nullable=True)
observed_issue: Mapped[str | None] = mapped_column(String(255), nullable=True)
intervention: Mapped[str | None] = mapped_column(String(255), nullable=True)
age_band: Mapped[str | None] = mapped_column(String(50), nullable=True)
summary: Mapped[str] = mapped_column(Text, nullable=False)
concept_ids: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)


class PatientMetricTrend(Base):
"""환자별 지표 추이 스냅샷. trend_json에 세션별 시계열 값을 저장한다."""

__tablename__ = "patient_metric_trends"

id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
patient_ref_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("patient_refs.id", ondelete="RESTRICT"), nullable=False
)
therapist_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
metric: Mapped[str] = mapped_column(String(100), nullable=False)
trend_json: Mapped[dict] = mapped_column(JSONB, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
8 changes: 8 additions & 0 deletions app/models/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
AnalysisJob,
AudioFile,
LanguageMetrics,
OntologyConcept,
OntologyEdge,
PatientMetricTrend,
PatientRef,
RefreshToken,
Report,
ReportSegment,
Session,
SoapCaseIndex,
Template,
Transcript,
TranscriptSegment,
Expand All @@ -21,11 +25,15 @@
"AudioFile",
"Base",
"LanguageMetrics",
"OntologyConcept",
"OntologyEdge",
"PatientMetricTrend",
"PatientRef",
"RefreshToken",
"Report",
"ReportSegment",
"Session",
"SoapCaseIndex",
"Template",
"Transcript",
"TranscriptSegment",
Expand Down
Loading
Loading