From d031919201a8d3dbaba19f370c3d499a4e90b98a Mon Sep 17 00:00:00 2001 From: "Eunice(Eunyoung) Shin" Date: Thu, 2 Jul 2026 07:32:05 +0900 Subject: [PATCH 1/2] feat: add insight map graph data model, API, and object-level authorization Add ontology_concepts/ontology_edges/soap_case_indexes/patient_metric_trends tables, seed data derived from the existing RAG ontology, Graph API (GET /insight-map, POST /insight-map/search, GET /soap-case-indexes/{id}/source-link), a shared object-level authorization helper, and automatic SOAP case-index tagging when a report is finalized. --- .gitignore | 1 + ...f45_add_ontology_and_insight_map_tables.py | 79 ++++ app/api/v1/api.py | 6 + app/api/v1/endpoints/insight_map.py | 54 +++ app/core/authorization.py | 62 +++ app/models/entities.py | 87 +++- app/models/registry.py | 8 + app/repositories/insight_map.py | 86 ++++ app/schemas/insight_map.py | 37 ++ app/services/case_index_builder.py | 95 ++++ app/services/insight_map.py | 219 +++++++++ app/services/report.py | 12 + scripts/ontology_seed.yaml | 427 ++++++++++++++++++ scripts/seed_ontology.py | 86 ++++ tests/unit/test_authorization.py | 144 ++++++ tests/unit/test_case_index_builder.py | 93 ++++ tests/unit/test_service_insight_map.py | 166 +++++++ 17 files changed, 1661 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/838ba1ab5f45_add_ontology_and_insight_map_tables.py create mode 100644 app/api/v1/endpoints/insight_map.py create mode 100644 app/core/authorization.py create mode 100644 app/repositories/insight_map.py create mode 100644 app/schemas/insight_map.py create mode 100644 app/services/case_index_builder.py create mode 100644 app/services/insight_map.py create mode 100644 scripts/ontology_seed.yaml create mode 100644 scripts/seed_ontology.py create mode 100644 tests/unit/test_authorization.py create mode 100644 tests/unit/test_case_index_builder.py create mode 100644 tests/unit/test_service_insight_map.py diff --git a/.gitignore b/.gitignore index af86dfe..c0dd6cd 100644 --- a/.gitignore +++ b/.gitignore @@ -157,3 +157,4 @@ dist/ build/ **/.claude +.gstack/ diff --git a/alembic/versions/838ba1ab5f45_add_ontology_and_insight_map_tables.py b/alembic/versions/838ba1ab5f45_add_ontology_and_insight_map_tables.py new file mode 100644 index 0000000..b8ed68c --- /dev/null +++ b/alembic/versions/838ba1ab5f45_add_ontology_and_insight_map_tables.py @@ -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 ### diff --git a/app/api/v1/api.py b/app/api/v1/api.py index 38dd684..2ce16c9 100644 --- a/app/api/v1/api.py +++ b/app/api/v1/api.py @@ -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 @@ -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", diff --git a/app/api/v1/endpoints/insight_map.py b/app/api/v1/endpoints/insight_map.py new file mode 100644 index 0000000..a2640e6 --- /dev/null +++ b/app/api/v1/endpoints/insight_map.py @@ -0,0 +1,54 @@ +"""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 ( + 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) + + +@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) diff --git a/app/core/authorization.py b/app/core/authorization.py new file mode 100644 index 0000000..8d06116 --- /dev/null +++ b/app/core/authorization.py @@ -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.") \ No newline at end of file diff --git a/app/models/entities.py b/app/models/entities.py index b8a40b5..9f419de 100644 --- a/app/models/entities.py +++ b/app/models/entities.py @@ -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 @@ -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) diff --git a/app/models/registry.py b/app/models/registry.py index 3567f5e..c795f7f 100644 --- a/app/models/registry.py +++ b/app/models/registry.py @@ -5,11 +5,15 @@ AnalysisJob, AudioFile, LanguageMetrics, + OntologyConcept, + OntologyEdge, + PatientMetricTrend, PatientRef, RefreshToken, Report, ReportSegment, Session, + SoapCaseIndex, Template, Transcript, TranscriptSegment, @@ -21,11 +25,15 @@ "AudioFile", "Base", "LanguageMetrics", + "OntologyConcept", + "OntologyEdge", + "PatientMetricTrend", "PatientRef", "RefreshToken", "Report", "ReportSegment", "Session", + "SoapCaseIndex", "Template", "Transcript", "TranscriptSegment", diff --git a/app/repositories/insight_map.py b/app/repositories/insight_map.py new file mode 100644 index 0000000..7992678 --- /dev/null +++ b/app/repositories/insight_map.py @@ -0,0 +1,86 @@ +"""Database access helpers for the insight map (ontology + patient history + case index).""" + +from __future__ import annotations + +import uuid + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.entities import OntologyConcept, OntologyEdge, PatientMetricTrend, SoapCaseIndex + + +class InsightMapRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def list_concepts(self, concept_ids: list[uuid.UUID] | None = None) -> list[OntologyConcept]: + statement = select(OntologyConcept) + if concept_ids is not None: + statement = statement.where(OntologyConcept.id.in_(concept_ids)) + return list(self.db.execute(statement).scalars().all()) + + def find_concepts_by_key(self, concept_keys: list[str]) -> list[OntologyConcept]: + """concept_key 대소문자를 구분하지 않고 매칭한다. + + AI agent가 반환하는 concept_id(예: 'concept_mlu')는 소문자인 반면 + ontology_concepts.concept_key는 ontology.yaml 원본 표기(예: 'MLU')를 + 그대로 쓰기 때문에, 대소문자를 무시하지 않으면 매칭이 항상 실패한다. + """ + if not concept_keys: + return [] + lowered = [k.lower() for k in concept_keys] + statement = select(OntologyConcept).where(func.lower(OntologyConcept.concept_key).in_(lowered)) + return list(self.db.execute(statement).scalars().all()) + + def search_concepts_by_term(self, term: str) -> list[OntologyConcept]: + """concept_key/label/synonyms 중 term을 포함하는 concept을 찾는다 (대소문자 무시).""" + term_lower = term.lower() + matches = [] + for concept in self.list_concepts(): + haystack = [concept.concept_key.lower(), concept.label.lower()] + haystack += [s.lower() for s in (concept.synonyms or [])] + if any(term_lower in h or h in term_lower for h in haystack): + matches.append(concept) + return matches + + def list_edges(self, concept_ids: list[uuid.UUID] | None = None) -> list[OntologyEdge]: + statement = select(OntologyEdge) + if concept_ids is not None: + statement = statement.where( + OntologyEdge.source_concept_id.in_(concept_ids) | OntologyEdge.target_concept_id.in_(concept_ids) + ) + return list(self.db.execute(statement).scalars().all()) + + def list_patient_metric_trends(self, patient_ref_id: uuid.UUID, therapist_id: uuid.UUID) -> list[PatientMetricTrend]: + statement = select(PatientMetricTrend).where( + PatientMetricTrend.patient_ref_id == patient_ref_id, + PatientMetricTrend.therapist_id == therapist_id, + ) + return list(self.db.execute(statement).scalars().all()) + + def get_case_index_by_report_id(self, report_id: uuid.UUID) -> SoapCaseIndex | None: + statement = select(SoapCaseIndex).where(SoapCaseIndex.report_id == report_id) + return self.db.execute(statement).scalar_one_or_none() + + def upsert_case_index(self, case_index: SoapCaseIndex) -> SoapCaseIndex: + self.db.add(case_index) + self.db.flush() + return case_index + + def list_case_indexes( + self, + patient_ref_id: uuid.UUID, + therapist_id: uuid.UUID, + concept_ids: list[uuid.UUID] | None = None, + ) -> list[SoapCaseIndex]: + """내 SOAP 기록만 반환한다 — therapist_id는 항상 현재 사용자로 강제한다 (IDOR 방지).""" + statement = select(SoapCaseIndex).where( + SoapCaseIndex.patient_ref_id == patient_ref_id, + SoapCaseIndex.therapist_id == therapist_id, + ) + case_indexes = list(self.db.execute(statement).scalars().all()) + if concept_ids is None: + return case_indexes + concept_id_strs = {str(cid) for cid in concept_ids} + return [ci for ci in case_indexes if concept_id_strs & {str(c) for c in (ci.concept_ids or [])}] \ No newline at end of file diff --git a/app/schemas/insight_map.py b/app/schemas/insight_map.py new file mode 100644 index 0000000..451b07d --- /dev/null +++ b/app/schemas/insight_map.py @@ -0,0 +1,37 @@ +"""Schemas for the insight map Graph API.""" + +from __future__ import annotations + +import uuid + +from pydantic import BaseModel, Field + + +class InsightMapNode(BaseModel): + id: str + type: str + label: str + source_layer: str + summary: str | None = None + data: dict = Field(default_factory=dict) + + +class InsightMapEdge(BaseModel): + source: str + target: str + relation: str + + +class InsightMapResponse(BaseModel): + nodes: list[InsightMapNode] + edges: list[InsightMapEdge] + + +class InsightMapSearchRequest(BaseModel): + query: str + report_draft_id: uuid.UUID | None = None + mode: list[str] = Field(default_factory=lambda: ["research", "current_patient", "my_soap"]) + + +class SourceLinkResponse(BaseModel): + url: str \ No newline at end of file diff --git a/app/services/case_index_builder.py b/app/services/case_index_builder.py new file mode 100644 index 0000000..804c5ea --- /dev/null +++ b/app/services/case_index_builder.py @@ -0,0 +1,95 @@ +"""Report(=SOAP note) 확정 시 soap_case_indexes를 생성/갱신한다. + +원문은 저장하지 않는다 — report_segments 텍스트는 concept 태깅에만 임시로 +쓰이고, DB에는 매칭된 concept_id와 라벨 기반의 짧은 요약 문장만 남는다. +관찰 소견/중재를 임의로 창작하지 않도록, 요약과 observed_issue/intervention은 +ontology_concepts에 이미 정의된 라벨만 조합한다 (LLM 호출 없음). +""" + +from __future__ import annotations + +import uuid +from datetime import date, datetime, timezone + +from sqlalchemy.orm import Session as DbSession + +from app.models.entities import OntologyConcept, Patient, Report, ReportSegment, Session as SessionEntity, SoapCaseIndex +from app.repositories.insight_map import InsightMapRepository +from app.repositories.patient import PatientRepository + + +def _age_band(birth_date: date | None, on_date: date) -> str | None: + if birth_date is None: + return None + age_months = (on_date.year - birth_date.year) * 12 + (on_date.month - birth_date.month) + if age_months < 36: + return "toddler" + if age_months < 84: + return "preschool" + if age_months < 156: + return "school_age" + if age_months < 216: + return "adolescent" + return "adult" + + +def _match_concepts(text: str, concepts: list[OntologyConcept]) -> list[OntologyConcept]: + text_lower = text.lower() + matched = [] + for concept in concepts: + candidates = [concept.concept_key, concept.label, *(concept.synonyms or [])] + if any(c.lower() in text_lower for c in candidates if c): + matched.append(concept) + return matched + + +def build_case_index( + db: DbSession, + report: Report, + session: SessionEntity, + segments: list[ReportSegment], +) -> SoapCaseIndex | None: + text = "\n".join(seg.content or seg.ai_content or "" for seg in segments) + if not text.strip(): + return None + + insight_repo = InsightMapRepository(db) + concepts = insight_repo.list_concepts() + matched = _match_concepts(text, concepts) + if not matched: + return None + + metric_concept = next((c for c in matched if c.concept_type == "metric"), None) + issue_concept = next((c for c in matched if c.concept_type == "disorder"), None) + intervention_concept = next((c for c in matched if c.concept_type == "intervention"), None) + language_area = next((c.language_area for c in matched if c.language_area), None) + + summary_parts = [] + if issue_concept is not None: + summary_parts.append(f"{issue_concept.label}가 관찰됨") + if intervention_concept is not None: + summary_parts.append(f"{intervention_concept.label} 적용") + if not summary_parts and metric_concept is not None: + summary_parts.append(f"{metric_concept.label} 관련 소견 기록") + summary = " / ".join(summary_parts) if summary_parts else "관련 개념이 태깅된 세션 기록" + + patient = PatientRepository(db).get_by_ref_id(session.patient_ref_id) + age_band = _age_band(patient.birth_date, session.session_date) if patient else None + + now = datetime.now(timezone.utc) + existing = insight_repo.get_case_index_by_report_id(report.id) + case_index = existing or SoapCaseIndex(id=uuid.uuid4(), created_at=now) + + case_index.patient_ref_id = session.patient_ref_id + case_index.session_id = session.id + case_index.report_id = report.id + case_index.therapist_id = session.slp_id + case_index.language_area = language_area + case_index.metric = metric_concept.concept_key if metric_concept else None + case_index.observed_issue = issue_concept.label if issue_concept else None + case_index.intervention = intervention_concept.label if intervention_concept else None + case_index.age_band = age_band + case_index.summary = summary + case_index.concept_ids = [str(c.id) for c in matched] + + return insight_repo.upsert_case_index(case_index) \ No newline at end of file diff --git a/app/services/insight_map.py b/app/services/insight_map.py new file mode 100644 index 0000000..adbd2f5 --- /dev/null +++ b/app/services/insight_map.py @@ -0,0 +1,219 @@ +"""Service for the insight map: graph assembly, natural-language search, source links.""" + +from __future__ import annotations + +import logging +import uuid + +import httpx +from fastapi import HTTPException, status +from sqlalchemy.orm import Session as DbSession + +from app.core.authorization import ensure_case_index_access, ensure_report_access +from app.core.config import get_settings +from app.models.entities import OntologyConcept +from app.repositories.insight_map import InsightMapRepository +from app.repositories.session import SessionRepository +from app.schemas.auth import UserRead +from app.schemas.insight_map import ( + InsightMapEdge, + InsightMapNode, + InsightMapResponse, + SourceLinkResponse, +) + +settings = get_settings() +logger = logging.getLogger(__name__) + +_DEFAULT_MODE = ["research", "current_patient", "my_soap"] + + +def _concept_node_id(concept: OntologyConcept) -> str: + return f"concept_{concept.concept_key.lower()}" + + +class InsightMapService: + def __init__(self, db: DbSession) -> None: + self._db = db + self._repo = InsightMapRepository(db) + self._session_repo = SessionRepository(db) + + def get_insight_map( + self, + report_draft_id: uuid.UUID | None, + mode: list[str], + current_user: UserRead, + ) -> InsightMapResponse: + patient_ref_id: uuid.UUID | None = None + if report_draft_id is not None: + report = ensure_report_access(self._db, report_draft_id, current_user) + session = self._session_repo.get_by_id(report.session_id) + if session is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found.") + patient_ref_id = session.patient_ref_id + else: + # report_draft_id 없이 접근한 경우(사이드바에서 직접 진입) — 환자 히스토리와 + # 내 SOAP 기록은 특정 리포트/세션에 묶여 있으므로 노출하지 않는다. + mode = [m for m in mode if m == "research"] + + concepts = self._repo.list_concepts() if "research" in mode else [] + return self._build_graph(concepts, patient_ref_id, current_user, mode) + + def search( + self, + query: str, + report_draft_id: uuid.UUID | None, + mode: list[str], + current_user: UserRead, + ) -> InsightMapResponse: + patient_ref_id: uuid.UUID | None = None + if report_draft_id is not None: + report = ensure_report_access(self._db, report_draft_id, current_user) + session = self._session_repo.get_by_id(report.session_id) + patient_ref_id = session.patient_ref_id if session else None + + resolved_keys = self._resolve_query(query) + concepts = self._repo.find_concepts_by_key(resolved_keys) if resolved_keys else [] + if not concepts: + # agent 응답이 비었거나 AI 서비스가 매칭되는 개념을 찾지 못한 경우, + # 텍스트 포함 매칭으로 최소한의 결과를 보장한다. + concepts = self._repo.search_concepts_by_term(query) + + # 1-hop 관련 개념도 함께 보여준다 (예: MLU 검색 시 short_utterance도 노출). + if concepts: + edges = self._repo.list_edges([c.id for c in concepts]) + related_ids = {e.source_concept_id for e in edges} | {e.target_concept_id for e in edges} + related_ids -= {c.id for c in concepts} + if related_ids: + concepts = concepts + self._repo.list_concepts(list(related_ids)) + + if patient_ref_id is None: + # report_draft_id 없이 검색한 경우 환자 히스토리/SOAP 기록은 노출하지 않는다. + mode = [m for m in mode if m == "research"] + + return self._build_graph(concepts, patient_ref_id, current_user, mode) + + def get_source_link(self, case_index_id: uuid.UUID, current_user: UserRead) -> SourceLinkResponse: + case_index = ensure_case_index_access(self._db, case_index_id, current_user) + url = ( + f"/patients/{case_index.patient_ref_id}" + f"/sessions/{case_index.session_id}" + f"/soap-notes/{case_index.report_id}" + ) + return SourceLinkResponse(url=url) + + # ── helpers ────────────────────────────────────────────────────────────── + + def _build_graph( + self, + concepts: list[OntologyConcept], + patient_ref_id: uuid.UUID | None, + current_user: UserRead, + mode: list[str], + ) -> InsightMapResponse: + nodes: list[InsightMapNode] = [] + edges: list[InsightMapEdge] = [] + concept_by_id: dict[uuid.UUID, OntologyConcept] = {c.id: c for c in concepts} + + if "research" in mode and concepts: + for concept in concepts: + nodes.append( + InsightMapNode( + id=_concept_node_id(concept), + type=concept.concept_type, + label=concept.label, + source_layer="ontology", + summary=concept.description, + ) + ) + for edge in self._repo.list_edges([c.id for c in concepts]): + source = concept_by_id.get(edge.source_concept_id) + target = concept_by_id.get(edge.target_concept_id) + if source is None or target is None: + continue + edges.append( + InsightMapEdge( + source=_concept_node_id(source), + target=_concept_node_id(target), + relation=edge.relation_type, + ) + ) + + if patient_ref_id is None: + return InsightMapResponse(nodes=nodes, edges=edges) + + if "current_patient" in mode: + for trend in self._repo.list_patient_metric_trends(patient_ref_id, current_user.id): + node_id = f"trend_{trend.id}" + nodes.append( + InsightMapNode( + id=node_id, + type="patient_metric_trend", + label=f"{trend.metric} 추이", + source_layer="current_patient", + data={"metric": trend.metric, "trend": trend.trend_json}, + ) + ) + matching = next( + (c for c in concepts if c.concept_key.lower() == trend.metric.lower()), None + ) + if matching is not None: + edges.append( + InsightMapEdge(source=_concept_node_id(matching), target=node_id, relation="observed_in") + ) + + if "my_soap" in mode: + concept_ids = [c.id for c in concepts] if concepts else None + for case_index in self._repo.list_case_indexes(patient_ref_id, current_user.id, concept_ids): + node_id = f"caseidx_{case_index.id}" + nodes.append( + InsightMapNode( + id=node_id, + type="soap_case_index", + label=case_index.observed_issue or case_index.metric or "SOAP 기록", + source_layer="my_soap", + summary=case_index.summary, + data={ + "session_id": str(case_index.session_id), + "report_id": str(case_index.report_id), + }, + ) + ) + for concept_id in case_index.concept_ids or []: + matching = concept_by_id.get(uuid.UUID(str(concept_id))) + if matching is not None: + edges.append( + InsightMapEdge( + source=_concept_node_id(matching), target=node_id, relation="observed_in" + ) + ) + + return InsightMapResponse(nodes=nodes, edges=edges) + + _AI_SERVICE_MAX_ATTEMPTS = 2 + + def _resolve_query(self, query: str) -> list[str]: + """AI 서비스의 insight_query_resolver_agent를 호출해 concept_key 후보를 받는다. + + AI 서비스가 응답하지 않아도 검색 자체가 실패하면 안 되므로, 실패 시 빈 목록을 + 반환하고 search()가 텍스트 포함 매칭으로 폴백한다. + """ + url = f"{settings.ai_service_base_url}/ai/insight-map/resolve-query" + for attempt in range(1, self._AI_SERVICE_MAX_ATTEMPTS + 1): + try: + with httpx.Client(timeout=30.0) as client: + resp = client.post(url, json={"query": query}) + resp.raise_for_status() + data = resp.json() + return [ + c["concept_id"].removeprefix("concept_") + for c in data.get("resolved_concepts", []) + ] + except httpx.HTTPStatusError as exc: + logger.warning(f"[insight-map] AI 서비스 오류 응답: {exc.response.status_code}") + return [] + except httpx.RequestError as exc: + logger.warning( + f"[insight-map] AI 서비스 연결 실패 (attempt {attempt}/{self._AI_SERVICE_MAX_ATTEMPTS}): {exc}" + ) + return [] \ No newline at end of file diff --git a/app/services/report.py b/app/services/report.py index 34ae76b..15be6a3 100644 --- a/app/services/report.py +++ b/app/services/report.py @@ -13,6 +13,7 @@ from app.repositories.report import ReportRepository from app.repositories.session import SessionRepository from app.schemas.auth import UserRead +from app.services.case_index_builder import build_case_index from app.schemas.report import ( ReportRead, ReportSegmentRead, @@ -95,9 +96,20 @@ def update_status( status_code=status.HTTP_400_BAD_REQUEST, detail="Deleted reports cannot be updated.", ) + was_finalized = report.status == ReportStatus.FINALIZED report.status = request.status report.updated_at = datetime.now(UTC) updated = self.report_repository.update(report) + + # FINALIZED로 전환되는 시점에만 SOAP case index를 만든다 — 초안 단계에서 + # 매번 태깅하면 계속 수정되는 리포트마다 불필요한 재계산이 일어난다. + if not was_finalized and updated.status == ReportStatus.FINALIZED: + session = self.session_repository.get_by_id(updated.session_id) + segments = self.report_repository.list_segments(updated.id) + if session is not None: + build_case_index(self.report_repository.db, updated, session, segments) + self.report_repository.db.commit() + return ReportRead.model_validate(updated) def _get_accessible_session(self, session_id: uuid.UUID, current_user: UserRead) -> SessionEntity: diff --git a/scripts/ontology_seed.yaml b/scripts/ontology_seed.yaml new file mode 100644 index 0000000..b368ea5 --- /dev/null +++ b/scripts/ontology_seed.yaml @@ -0,0 +1,427 @@ +# 인사이트맵 기준 지식 레이어 시드 데이터. +# +# concepts는 UtterAI_AI/app/rag/ontology.yaml(RAG 쿼리 확장용 개념 사전)의 concept_key/ +# related_terms/language_area를 그대로 가져오되, ontology_concepts 테이블에 필요한 +# concept_type(metric/disorder/intervention/assessment_tool/linguistic_construct/ +# clinical_documentation/process)을 추가로 분류했다. +# +# edges는 두 온톨로지 문서 어디에도 없던 신규 정보다 — "03. 온톨로지맵 설계서" 3.1절의 +# MLU 예시 체인을 시작으로, 38개 concept 전부가 최소 1개 이상의 관계를 갖도록 SLP +# 도메인 지식 기준으로 확장했다. confidence는 관계의 확실성을 나타내는 주관적 가중치이며, +# 실제 임상 문헌 인용 근거(evidence_source_ids)가 아직 없는 상태다 — 문헌 연결은 후속 작업. + +concepts: + MLU: + label: "MLU" + synonyms: ["평균 발화 길이", "형태소 수", "문장 길이", "발화 복잡도", "표현언어", "MLU-w", "MLU-m", "MLU-e", "LLU", "K-ALAS", "어절", "발화 수"] + language_area: "expressive_language" + concept_type: "metric" + TTR: + label: "TTR" + synonyms: ["Type Token Ratio", "어휘 다양성", "어휘 다양도", "NDW", "NTW", "어휘 다양성 지수", "K-ALAS"] + language_area: "vocabulary" + concept_type: "metric" + NDW: + label: "NDW" + synonyms: ["Number of Different Words", "어휘 다양성", "서로 다른 낱말"] + language_area: "vocabulary" + concept_type: "metric" + NTW: + label: "NTW" + synonyms: ["Number of Total Words", "총 발화량", "발화량"] + language_area: "expressive_language" + concept_type: "metric" + response_latency: + label: "반응 지연 시간" + synonyms: ["반응 지연", "발화 반응 시간", "차례 맞추기", "대화 차례"] + language_area: "pragmatics" + concept_type: "metric" + SOAP_note: + label: "SOAP 노트" + synonyms: ["SOAP Note", "세션 기록", "치료 기록", "임상 기록", "리포트"] + language_area: "clinical_documentation" + concept_type: "clinical_documentation" + diarization: + label: "화자 분리" + synonyms: ["화자 구분", "아동 발화", "치료사 발화"] + language_area: "speech_processing" + concept_type: "process" + LLU: + label: "최장 발화 길이" + synonyms: ["최장발화길이", "발화 복잡도", "문장 복잡성", "Longest Length of Utterance", "LLU-m", "LLU-w", "LLU-e"] + language_area: "expressive_language" + concept_type: "metric" + PCC: + label: "자음정확도" + synonyms: ["자음 정확도", "조음 정확도", "말소리 오류", "음소 오류", "U-TAP2", "APAC", "Percent Consonants Correct", "조음장애", "음운변동"] + language_area: "phonology" + concept_type: "metric" + grammatical_morpheme: + label: "문법형태소" + synonyms: ["조사 오류", "어미 오류", "격조사", "주격조사", "목적격조사", "연결어미", "종결어미", "문법형태소 누락", "형태통사", "조사 누락", "어미 누락"] + language_area: "morphosyntax" + concept_type: "linguistic_construct" + turn_taking: + label: "대화 차례" + synonyms: ["차례 주고받기", "발화 교대", "반응 시작", "대화 개시", "화용 상호작용", "자발 발화", "Turn-taking"] + language_area: "pragmatics" + concept_type: "linguistic_construct" + CIU: + label: "맥락적 정보 단위" + synonyms: ["Correct Information Unit", "담화 정보량", "정보 전달 효율성", "%CIU", "CIU/min", "분당 CIU", "Main Concept Analysis", "담화 분석"] + language_area: "narrative_discourse" + concept_type: "metric" + aphasia: + label: "실어증" + synonyms: ["Broca 실어증", "Wernicke 실어증", "전도성 실어증", "전반성 실어증", "명칭 실어증", "이름대기 장애", "PK-WAB", "K-BNT", "단어 찾기 어려움", "착어", "의미착어", "음운착어", "신조어", "우회표현"] + language_area: "expressive_language" + concept_type: "disorder" + cognitive_communication: + label: "인지-의사소통" + synonyms: ["TBI", "외상성 뇌손상", "치매", "우반구 손상", "주의 장애", "기억 장애", "집행기능", "담화 조직화", "추론", "인지-의사소통 장애"] + language_area: "cognitive_communication" + concept_type: "disorder" + motor_speech: + label: "말운동장애" + synonyms: ["마비말장애", "말실행증", "말 명료도", "조음 오류", "말속도", "운율", "dysarthria", "apraxia of speech", "발성", "공명", "호흡 지지"] + language_area: "motor_speech" + concept_type: "disorder" + narrative_discourse: + label: "이야기 담화" + synonyms: ["이야기 문법", "에피소드 구조", "이야기 구성", "담화 능력", "설명 담화", "이야기 산출", "이야기 이해"] + language_area: "narrative_discourse" + concept_type: "linguistic_construct" + AAC: + label: "보완대체의사소통" + synonyms: ["Augmentative Alternative Communication", "그림 교환", "PECS", "음성출력기기", "보완대체 의사소통", "비구어", "AAC 도구"] + language_area: "aac" + concept_type: "intervention" + assessment_tools: + label: "언어평가 도구" + synonyms: ["PRES", "SELSI", "REVT", "MCDI-K", "U-TAP2", "APAC", "P-FA", "K-ALAS", "KLA", "LSSC", "KOPLAC", "PK-WAB", "K-BNT"] + language_area: "expressive_language" + concept_type: "assessment_tool" + language_delay: + label: "언어발달지연" + synonyms: ["단순언어발달지연", "발달성 언어장애", "DLD", "언어지연", "말 늦은 아동", "표현언어 지연", "수용언어 지연"] + language_area: "expressive_language" + concept_type: "disorder" + ASD: + label: "자폐스펙트럼장애" + synonyms: ["자폐 스펙트럼", "사회적 의사소통", "공동주의", "상호작용", "반향어", "echolalia", "화용 어려움"] + language_area: "pragmatics" + concept_type: "disorder" + eojeol: + label: "어절" + synonyms: ["띄어쓰기 단위", "낱말 단위", "word unit", "Korean word", "어절 수", "NTJ", "NDJ"] + language_area: "morphosyntax" + concept_type: "linguistic_construct" + case_particle: + label: "격조사" + synonyms: ["주격조사", "목적격조사", "부사격조사", "관형격조사", "처소격조사", "공동격조사", "조사 생략", "조사 대치", "조사 누락", "-이/-가", "-을/-를", "-에", "-에서", "-의"] + language_area: "morphosyntax" + concept_type: "linguistic_construct" + connective_ending: + label: "연결어미" + synonyms: ["어미 오류", "종결어미", "전성어미", "문장 연결", "복문", "-고", "-서", "-는데", "-면", "-기", "어미 누락", "어미 대치"] + language_area: "morphosyntax" + concept_type: "linguistic_construct" + phonological_process: + label: "음운변동" + synonyms: ["음운 과정", "대치", "생략", "첨가", "동화", "경음화", "축약", "종성 오류", "어말 자음 생략", "파열음화", "전설음화", "음소 오류 패턴"] + language_area: "phonology" + concept_type: "disorder" + disfluency: + label: "비유창성" + synonyms: ["반복", "연장", "막힘", "간투사", "수정", "머뭇거림", "삽입", "미완성 발화", "말더듬", "비유창성 빈도", "P-FA"] + language_area: "fluency" + concept_type: "disorder" + main_concept: + label: "핵심개념분석" + synonyms: ["Main Concept Analysis", "MCA", "담화 핵심 정보", "정보 단위", "핵심 개념 전달", "AphasiaBank"] + language_area: "narrative_discourse" + concept_type: "metric" + functional_goal: + label: "기능적 목표" + synonyms: ["일상 의사소통", "활동 참여", "ICF", "기능적 의사소통", "보호자 교육", "가정 연계", "FCM", "ASHA-FACS", "WHO ICF"] + language_area: "functional_communication" + concept_type: "linguistic_construct" + DLD: + label: "발달성 언어장애" + synonyms: ["Developmental Language Disorder", "언어발달지연", "후기 말문", "말 늦은 아동", "언어 학습 어려움", "문법 오류", "가족력", "지속적 언어 어려움"] + language_area: "expressive_language" + concept_type: "disorder" + milieu_teaching: + label: "환경중심언어중재" + synonyms: ["Milieu Teaching", "우발교수", "시간지연", "자연적 강화", "아동 주도", "모델링", "확장", "보호자 코칭"] + language_area: "expressive_language" + concept_type: "intervention" + sentence_expansion_modeling: + label: "문장 확장 모델링" + synonyms: ["Sentence Expansion", "확장 모델링", "발화 확장", "expansion"] + language_area: "expressive_language" + concept_type: "intervention" + short_utterance: + label: "짧은 발화" + synonyms: ["단문 위주 발화", "짧은 문장", "간단한 발화"] + language_area: "expressive_language" + concept_type: "disorder" + utterance_length: + label: "발화 길이" + synonyms: ["문장 길이", "발화 단위 길이"] + language_area: "expressive_language" + concept_type: "linguistic_construct" + TBI: + label: "외상성 뇌손상" + synonyms: ["Traumatic Brain Injury", "인지-의사소통", "집행기능", "자기인식", "메타인지", "미래 기억", "직장 복귀", "보상 전략"] + language_area: "cognitive_communication" + concept_type: "disorder" + dementia: + label: "치매" + synonyms: ["인지 저하", "경도인지장애", "MCI", "알츠하이머병", "반복 질문", "기억 보조", "의사소통 파트너", "진행성 언어 변화"] + language_area: "cognitive_communication" + concept_type: "disorder" + dysarthria: + label: "마비말장애" + synonyms: ["말 명료도", "이해가능성", "호흡", "발성", "조음", "공명", "운율", "이완형", "경직형", "실조형", "저운동형", "과운동형"] + language_area: "motor_speech" + concept_type: "disorder" + FCM: + label: "기능적 의사소통 척도" + synonyms: ["Functional Communication Measure", "ASHA NOMS", "기능적 결과", "활동 참여", "지원 수준", "의사소통 독립성"] + language_area: "functional_communication" + concept_type: "metric" + voice_disorder: + label: "음성장애" + synonyms: ["쉰 목소리", "애성", "음질", "음도", "음성 강도", "발성 노력", "후두 검사", "음성위생", "공명음성", "VHI"] + language_area: "voice" + concept_type: "disorder" + expressive_language: + label: "표현언어" + synonyms: ["표현 언어", "Expressive Language"] + language_area: "expressive_language" + concept_type: "language_area" + +edges: + - source: "MLU" + target: "utterance_length" + relation_type: "measures" + confidence: 1.0 + - source: "MLU" + target: "expressive_language" + relation_type: "belongs_to" + confidence: 1.0 + - source: "MLU" + target: "short_utterance" + relation_type: "related_to" + confidence: 0.8 + - source: "short_utterance" + target: "sentence_expansion_modeling" + relation_type: "related_intervention" + confidence: 0.8 + - source: "short_utterance" + target: "milieu_teaching" + relation_type: "related_intervention" + confidence: 0.6 + - source: "TTR" + target: "NDW" + relation_type: "measures" + confidence: 0.9 + - source: "TTR" + target: "NTW" + relation_type: "measures" + confidence: 0.9 + - source: "language_delay" + target: "DLD" + relation_type: "related_to" + confidence: 0.7 + - source: "language_delay" + target: "milieu_teaching" + relation_type: "related_intervention" + confidence: 0.6 + - source: "ASD" + target: "turn_taking" + relation_type: "related_to" + confidence: 0.7 + - source: "aphasia" + target: "main_concept" + relation_type: "related_to" + confidence: 0.7 + - source: "aphasia" + target: "CIU" + relation_type: "related_to" + confidence: 0.7 + - source: "dysarthria" + target: "motor_speech" + relation_type: "belongs_to" + confidence: 1.0 + - source: "phonological_process" + target: "PCC" + relation_type: "measures" + confidence: 0.6 + + # ── 확장분: 도메인 관계 채움 (아래부터 신규) ── + - source: "LLU" + target: "utterance_length" + relation_type: "measures" + confidence: 0.9 + - source: "LLU" + target: "expressive_language" + relation_type: "belongs_to" + confidence: 1.0 + - source: "LLU" + target: "MLU" + relation_type: "related_to" + confidence: 0.6 + - source: "NTW" + target: "expressive_language" + relation_type: "belongs_to" + confidence: 1.0 + - source: "response_latency" + target: "turn_taking" + relation_type: "related_to" + confidence: 0.8 + - source: "response_latency" + target: "TBI" + relation_type: "related_to" + confidence: 0.5 + - source: "SOAP_note" + target: "functional_goal" + relation_type: "related_to" + confidence: 0.7 + - source: "SOAP_note" + target: "assessment_tools" + relation_type: "related_to" + confidence: 0.6 + - source: "diarization" + target: "SOAP_note" + relation_type: "related_to" + confidence: 0.7 + - source: "grammatical_morpheme" + target: "case_particle" + relation_type: "related_to" + confidence: 0.9 + - source: "grammatical_morpheme" + target: "connective_ending" + relation_type: "related_to" + confidence: 0.9 + - source: "grammatical_morpheme" + target: "DLD" + relation_type: "related_to" + confidence: 0.7 + - source: "grammatical_morpheme" + target: "eojeol" + relation_type: "related_to" + confidence: 0.6 + - source: "case_particle" + target: "eojeol" + relation_type: "belongs_to" + confidence: 0.8 + - source: "connective_ending" + target: "eojeol" + relation_type: "belongs_to" + confidence: 0.8 + - source: "turn_taking" + target: "functional_goal" + relation_type: "related_to" + confidence: 0.6 + - source: "turn_taking" + target: "milieu_teaching" + relation_type: "related_intervention" + confidence: 0.7 + - source: "CIU" + target: "narrative_discourse" + relation_type: "belongs_to" + confidence: 0.8 + - source: "CIU" + target: "main_concept" + relation_type: "related_to" + confidence: 0.7 + - source: "main_concept" + target: "narrative_discourse" + relation_type: "belongs_to" + confidence: 0.8 + - source: "aphasia" + target: "assessment_tools" + relation_type: "related_to" + confidence: 0.7 + - source: "aphasia" + target: "functional_goal" + relation_type: "related_to" + confidence: 0.6 + - source: "TBI" + target: "cognitive_communication" + relation_type: "related_to" + confidence: 0.8 + - source: "dementia" + target: "cognitive_communication" + relation_type: "related_to" + confidence: 0.8 + - source: "cognitive_communication" + target: "narrative_discourse" + relation_type: "related_to" + confidence: 0.6 + - source: "motor_speech" + target: "voice_disorder" + relation_type: "related_to" + confidence: 0.5 + - source: "AAC" + target: "functional_goal" + relation_type: "related_intervention" + confidence: 0.7 + - source: "AAC" + target: "ASD" + relation_type: "related_intervention" + confidence: 0.7 + - source: "AAC" + target: "TBI" + relation_type: "related_intervention" + confidence: 0.5 + - source: "assessment_tools" + target: "language_delay" + relation_type: "related_to" + confidence: 0.7 + - source: "assessment_tools" + target: "DLD" + relation_type: "related_to" + confidence: 0.7 + - source: "assessment_tools" + target: "phonological_process" + relation_type: "related_to" + confidence: 0.7 + - source: "language_delay" + target: "expressive_language" + relation_type: "belongs_to" + confidence: 0.8 + - source: "DLD" + target: "expressive_language" + relation_type: "belongs_to" + confidence: 0.8 + - source: "ASD" + target: "functional_goal" + relation_type: "related_to" + confidence: 0.6 + - source: "disfluency" + target: "functional_goal" + relation_type: "related_to" + confidence: 0.5 + - source: "disfluency" + target: "assessment_tools" + relation_type: "related_to" + confidence: 0.6 + - source: "functional_goal" + target: "FCM" + relation_type: "measures" + confidence: 0.8 + - source: "dementia" + target: "functional_goal" + relation_type: "related_to" + confidence: 0.5 + - source: "TBI" + target: "functional_goal" + relation_type: "related_to" + confidence: 0.5 + - source: "voice_disorder" + target: "functional_goal" + relation_type: "related_to" + confidence: 0.5 \ No newline at end of file diff --git a/scripts/seed_ontology.py b/scripts/seed_ontology.py new file mode 100644 index 0000000..eef1fd0 --- /dev/null +++ b/scripts/seed_ontology.py @@ -0,0 +1,86 @@ +"""ontology_seed.yaml을 ontology_concepts/ontology_edges 테이블에 upsert한다. + +concept_key 기준으로 존재하면 갱신, 없으면 생성한다 (idempotent). +edges는 concept_key 쌍 + relation_type 기준으로 존재 여부를 확인한다. + +사용법: + python scripts/seed_ontology.py +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from pathlib import Path + +import yaml + +from app.core.db import SessionLocal +from app.models.entities import OntologyConcept, OntologyEdge + +_SEED_PATH = Path(__file__).parent / "ontology_seed.yaml" + + +def _load_seed() -> dict: + with open(_SEED_PATH, encoding="utf-8") as f: + return yaml.safe_load(f) + + +def seed_ontology() -> None: + data = _load_seed() + now = datetime.now(timezone.utc) + + with SessionLocal() as db: + concept_id_by_key: dict[str, uuid.UUID] = {} + + for concept_key, fields in data["concepts"].items(): + existing = db.query(OntologyConcept).filter_by(concept_key=concept_key).one_or_none() + if existing is None: + existing = OntologyConcept( + id=uuid.uuid4(), + concept_key=concept_key, + created_at=now, + ) + db.add(existing) + existing.label = fields["label"] + existing.synonyms = fields.get("synonyms", []) + existing.language_area = fields.get("language_area") + existing.concept_type = fields["concept_type"] + existing.description = fields.get("description") + concept_id_by_key[concept_key] = existing.id + + db.flush() + + for edge in data.get("edges", []): + source_id = concept_id_by_key[edge["source"]] + target_id = concept_id_by_key[edge["target"]] + relation_type = edge["relation_type"] + + existing_edge = ( + db.query(OntologyEdge) + .filter_by(source_concept_id=source_id, target_concept_id=target_id, relation_type=relation_type) + .one_or_none() + ) + if existing_edge is None: + db.add( + OntologyEdge( + id=uuid.uuid4(), + source_concept_id=source_id, + target_concept_id=target_id, + relation_type=relation_type, + evidence_source_ids=edge.get("evidence_source_ids", []), + confidence=edge.get("confidence"), + created_at=now, + ) + ) + else: + existing_edge.confidence = edge.get("confidence") + existing_edge.evidence_source_ids = edge.get("evidence_source_ids", existing_edge.evidence_source_ids) + + db.commit() + + print(f"Seeded {len(data['concepts'])} concepts and {len(data.get('edges', []))} edges.") + + +if __name__ == "__main__": + seed_ontology() \ No newline at end of file diff --git a/tests/unit/test_authorization.py b/tests/unit/test_authorization.py new file mode 100644 index 0000000..a8f48ec --- /dev/null +++ b/tests/unit/test_authorization.py @@ -0,0 +1,144 @@ +"""Unit tests for shared object-level authorization helpers.""" + +import uuid +from datetime import datetime +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException + +from app.core.authorization import ( + ensure_case_index_access, + ensure_patient_ref_access, + ensure_report_access, + ensure_session_access, +) +from app.core.enums import SessionStatus, UserRole, UserStatus +from app.models.entities import PatientRef, Report, Session as SessionEntity, SoapCaseIndex +from app.schemas.auth import UserRead + + +def _make_user(role: UserRole = UserRole.SLP) -> UserRead: + return UserRead( + id=uuid.uuid4(), + email="slp@test.com", + name="Test SLP", + role=role, + status=UserStatus.ACTIVE, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + +def _make_session(slp_id: uuid.UUID) -> MagicMock: + s = MagicMock(spec=SessionEntity) + s.id = uuid.uuid4() + s.slp_id = slp_id + s.status = SessionStatus.CREATED + return s + + +class TestEnsureSessionAccess: + def test_404_when_session_missing(self): + db = MagicMock() + db.get.return_value = None + with pytest.raises(HTTPException) as exc: + ensure_session_access(db, uuid.uuid4(), _make_user()) + assert exc.value.status_code == 404 + + def test_403_when_other_slp_owns_session(self): + db = MagicMock() + session = _make_session(uuid.uuid4()) + db.get.return_value = session + with pytest.raises(HTTPException) as exc: + ensure_session_access(db, session.id, _make_user()) + assert exc.value.status_code == 403 + + def test_admin_bypasses_ownership_check(self): + db = MagicMock() + session = _make_session(uuid.uuid4()) + db.get.return_value = session + result = ensure_session_access(db, session.id, _make_user(UserRole.ADMIN)) + assert result is session + + def test_owner_slp_is_allowed(self): + db = MagicMock() + user = _make_user() + session = _make_session(user.id) + db.get.return_value = session + result = ensure_session_access(db, session.id, user) + assert result is session + + +class TestEnsureReportAccess: + def test_404_when_report_missing(self): + db = MagicMock() + db.get.return_value = None + with pytest.raises(HTTPException) as exc: + ensure_report_access(db, uuid.uuid4(), _make_user()) + assert exc.value.status_code == 404 + + def test_403_when_session_not_owned(self): + db = MagicMock() + user = _make_user() + session = _make_session(uuid.uuid4()) + report = MagicMock(spec=Report) + report.session_id = session.id + db.get.side_effect = lambda model, _id: report if model is Report else session + with pytest.raises(HTTPException) as exc: + ensure_report_access(db, uuid.uuid4(), user) + assert exc.value.status_code == 403 + + +class TestEnsurePatientRefAccess: + def test_404_when_patient_ref_missing(self): + db = MagicMock() + db.get.return_value = None + with pytest.raises(HTTPException) as exc: + ensure_patient_ref_access(db, uuid.uuid4(), _make_user()) + assert exc.value.status_code == 404 + + def test_403_when_not_current_slp(self): + db = MagicMock() + ref = MagicMock(spec=PatientRef) + ref.current_slp_id = uuid.uuid4() + db.get.return_value = ref + with pytest.raises(HTTPException) as exc: + ensure_patient_ref_access(db, uuid.uuid4(), _make_user()) + assert exc.value.status_code == 403 + + def test_current_slp_is_allowed(self): + db = MagicMock() + user = _make_user() + ref = MagicMock(spec=PatientRef) + ref.current_slp_id = user.id + db.get.return_value = ref + result = ensure_patient_ref_access(db, uuid.uuid4(), user) + assert result is ref + + +class TestEnsureCaseIndexAccess: + def test_404_when_case_index_missing(self): + db = MagicMock() + db.get.return_value = None + with pytest.raises(HTTPException) as exc: + ensure_case_index_access(db, uuid.uuid4(), _make_user()) + assert exc.value.status_code == 404 + + def test_403_when_different_therapist(self): + db = MagicMock() + case_index = MagicMock(spec=SoapCaseIndex) + case_index.therapist_id = uuid.uuid4() + db.get.return_value = case_index + with pytest.raises(HTTPException) as exc: + ensure_case_index_access(db, uuid.uuid4(), _make_user()) + assert exc.value.status_code == 403 + + def test_owning_therapist_is_allowed(self): + db = MagicMock() + user = _make_user() + case_index = MagicMock(spec=SoapCaseIndex) + case_index.therapist_id = user.id + db.get.return_value = case_index + result = ensure_case_index_access(db, uuid.uuid4(), user) + assert result is case_index diff --git a/tests/unit/test_case_index_builder.py b/tests/unit/test_case_index_builder.py new file mode 100644 index 0000000..88d8b44 --- /dev/null +++ b/tests/unit/test_case_index_builder.py @@ -0,0 +1,93 @@ +"""Unit tests for SOAP case index concept tagging.""" + +import uuid +from datetime import date, datetime, timezone +from unittest.mock import MagicMock, patch + +from app.models.entities import OntologyConcept, Patient, Report, ReportSegment +from app.models.entities import Session as SessionEntity +from app.services.case_index_builder import _age_band, build_case_index + + +def _concept(concept_key: str, label: str, concept_type: str, language_area: str = "expressive_language") -> OntologyConcept: + c = MagicMock(spec=OntologyConcept) + c.id = uuid.uuid4() + c.concept_key = concept_key + c.label = label + c.concept_type = concept_type + c.language_area = language_area + c.synonyms = [] + return c + + +def _segment(content: str) -> MagicMock: + seg = MagicMock(spec=ReportSegment) + seg.content = content + seg.ai_content = None + return seg + + +class TestAgeBand: + def test_none_birth_date_returns_none(self): + assert _age_band(None, date(2026, 1, 1)) is None + + def test_preschool_band(self): + assert _age_band(date(2022, 1, 1), date(2026, 1, 1)) == "preschool" + + def test_adult_band(self): + assert _age_band(date(1990, 1, 1), date(2026, 1, 1)) == "adult" + + +class TestBuildCaseIndex: + def test_returns_none_when_no_text(self): + db = MagicMock() + report = MagicMock(spec=Report) + session = MagicMock(spec=SessionEntity) + result = build_case_index(db, report, session, [_segment(None)]) + assert result is None + + def test_returns_none_when_no_concepts_match(self): + db = MagicMock() + report = MagicMock(spec=Report) + session = MagicMock(spec=SessionEntity) + with patch("app.services.case_index_builder.InsightMapRepository") as MockRepo: + MockRepo.return_value.list_concepts.return_value = [ + _concept("PCC", "자음정확도", "metric", "phonology") + ] + result = build_case_index(db, report, session, [_segment("전혀 관련 없는 문장입니다.")]) + assert result is None + + def test_tags_matched_concepts_and_builds_summary(self): + db = MagicMock() + report = MagicMock(spec=Report) + report.id = uuid.uuid4() + session = MagicMock(spec=SessionEntity) + session.id = uuid.uuid4() + session.patient_ref_id = uuid.uuid4() + session.slp_id = uuid.uuid4() + session.session_date = date(2026, 1, 1) + + short_utterance = _concept("short_utterance", "짧은 발화", "disorder") + modeling = _concept("sentence_expansion_modeling", "문장 확장 모델링", "intervention") + + patient = MagicMock(spec=Patient) + patient.birth_date = date(2022, 1, 1) + + with patch("app.services.case_index_builder.InsightMapRepository") as MockRepo, \ + patch("app.services.case_index_builder.PatientRepository") as MockPatientRepo: + MockRepo.return_value.list_concepts.return_value = [short_utterance, modeling] + MockRepo.return_value.get_case_index_by_report_id.return_value = None + MockRepo.return_value.upsert_case_index.side_effect = lambda ci: ci + MockPatientRepo.return_value.get_by_ref_id.return_value = patient + + result = build_case_index( + db, report, session, [_segment("짧은 발화가 관찰되어 문장 확장 모델링을 적용함")] + ) + + assert result is not None + assert "짧은 발화가 관찰됨" in result.summary + assert "문장 확장 모델링 적용" in result.summary + assert result.observed_issue == "짧은 발화" + assert result.intervention == "문장 확장 모델링" + assert result.age_band == "preschool" + assert set(result.concept_ids) == {str(short_utterance.id), str(modeling.id)} diff --git a/tests/unit/test_service_insight_map.py b/tests/unit/test_service_insight_map.py new file mode 100644 index 0000000..e567543 --- /dev/null +++ b/tests/unit/test_service_insight_map.py @@ -0,0 +1,166 @@ +"""Unit tests for InsightMapService.""" + +import uuid +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest + +from app.core.enums import UserRole, UserStatus +from app.models.entities import OntologyConcept, OntologyEdge, Report, Session as SessionEntity, SoapCaseIndex +from app.schemas.auth import UserRead +from app.services.insight_map import InsightMapService + + +def _make_user() -> UserRead: + return UserRead( + id=uuid.uuid4(), + email="slp@test.com", + name="Test SLP", + role=UserRole.SLP, + status=UserStatus.ACTIVE, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + +def _concept(concept_key: str, concept_type: str = "metric") -> MagicMock: + c = MagicMock(spec=OntologyConcept) + c.id = uuid.uuid4() + c.concept_key = concept_key + c.label = concept_key + c.concept_type = concept_type + c.language_area = "expressive_language" + c.description = None + c.synonyms = ["동의어"] + return c + + +@pytest.fixture +def db(): + return MagicMock() + + +@pytest.fixture +def service(db): + with patch("app.services.insight_map.InsightMapRepository") as MockRepo, \ + patch("app.services.insight_map.SessionRepository") as MockSession: + svc = InsightMapService(db) + svc._repo = MockRepo.return_value + svc._session_repo = MockSession.return_value + yield svc + + +class TestGetInsightMap: + def test_research_mode_returns_concept_nodes_and_edges(self, service): + user = _make_user() + report = MagicMock(spec=Report) + session = MagicMock(spec=SessionEntity) + session.patient_ref_id = uuid.uuid4() + + mlu = _concept("MLU") + short_utterance = _concept("short_utterance", "disorder") + edge = MagicMock(spec=OntologyEdge) + edge.source_concept_id = mlu.id + edge.target_concept_id = short_utterance.id + edge.relation_type = "related_to" + + service._repo.list_concepts.return_value = [mlu, short_utterance] + service._repo.list_edges.return_value = [edge] + service._repo.list_patient_metric_trends.return_value = [] + service._repo.list_case_indexes.return_value = [] + service._session_repo.get_by_id.return_value = session + + with patch("app.services.insight_map.ensure_report_access", return_value=report): + result = service.get_insight_map(uuid.uuid4(), ["research"], user) + + node_ids = {n.id for n in result.nodes} + assert node_ids == {"concept_mlu", "concept_short_utterance"} + assert result.edges[0].source == "concept_mlu" + assert result.edges[0].target == "concept_short_utterance" + assert result.edges[0].relation == "related_to" + + def test_my_soap_mode_only_queries_current_user_case_indexes(self, service): + user = _make_user() + report = MagicMock(spec=Report) + session = MagicMock(spec=SessionEntity) + session.patient_ref_id = uuid.uuid4() + + service._repo.list_concepts.return_value = [] + service._repo.list_patient_metric_trends.return_value = [] + service._repo.list_case_indexes.return_value = [] + service._session_repo.get_by_id.return_value = session + + with patch("app.services.insight_map.ensure_report_access", return_value=report): + service.get_insight_map(uuid.uuid4(), ["my_soap"], user) + + service._repo.list_case_indexes.assert_called_once_with(session.patient_ref_id, user.id, None) + + def test_without_report_draft_id_only_returns_research_layer(self, service): + user = _make_user() + mlu = _concept("MLU") + service._repo.list_concepts.return_value = [mlu] + service._repo.list_edges.return_value = [] + + result = service.get_insight_map(None, ["research", "current_patient", "my_soap"], user) + + service._session_repo.get_by_id.assert_not_called() + service._repo.list_case_indexes.assert_not_called() + service._repo.list_patient_metric_trends.assert_not_called() + assert {n.id for n in result.nodes} == {"concept_mlu"} + + +class TestSearch: + def test_falls_back_to_term_match_when_resolver_returns_nothing(self, service): + user = _make_user() + mlu = _concept("MLU") + service._repo.list_edges.return_value = [] + service._repo.search_concepts_by_term.return_value = [mlu] + service._repo.find_concepts_by_key.return_value = [] + + with patch.object(service, "_resolve_query", return_value=[]): + result = service.search("낮은 MLU", None, ["research"], user) + + service._repo.search_concepts_by_term.assert_called_once_with("낮은 MLU") + assert {n.id for n in result.nodes} == {"concept_mlu"} + + def test_uses_resolved_concepts_when_available(self, service): + user = _make_user() + mlu = _concept("MLU") + service._repo.find_concepts_by_key.return_value = [mlu] + service._repo.list_edges.return_value = [] + + with patch.object(service, "_resolve_query", return_value=["mlu"]): + result = service.search("낮은 MLU", None, ["research"], user) + + service._repo.find_concepts_by_key.assert_called_once_with(["mlu"]) + assert {n.id for n in result.nodes} == {"concept_mlu"} + + def test_without_report_draft_id_drops_patient_and_soap_modes(self, service): + user = _make_user() + service._repo.find_concepts_by_key.return_value = [] + service._repo.search_concepts_by_term.return_value = [] + + with patch.object(service, "_resolve_query", return_value=[]): + service.search("아무개념", None, ["research", "current_patient", "my_soap"], user) + + service._repo.list_case_indexes.assert_not_called() + service._repo.list_patient_metric_trends.assert_not_called() + + +class TestGetSourceLink: + def test_builds_url_from_case_index(self, service): + user = _make_user() + case_index = MagicMock(spec=SoapCaseIndex) + case_index.patient_ref_id = uuid.uuid4() + case_index.session_id = uuid.uuid4() + case_index.report_id = uuid.uuid4() + + with patch("app.services.insight_map.ensure_case_index_access", return_value=case_index): + result = service.get_source_link(uuid.uuid4(), user) + + assert result.url == ( + f"/patients/{case_index.patient_ref_id}" + f"/sessions/{case_index.session_id}" + f"/soap-notes/{case_index.report_id}" + ) From c8ba95e6de586f2872cc2346278f053b02a47ac0 Mon Sep 17 00:00:00 2001 From: "Eunice(Eunyoung) Shin" Date: Thu, 2 Jul 2026 10:26:25 +0900 Subject: [PATCH 2/2] feat: add literature evidence lookup and therapist-wide SOAP case index browsing Add GET /insight-map/concepts/{concept_key}/evidence proxying the AI service's RAG search so concept nodes can show related paper/guide citations. Also let "my_soap" mode work without a report_draft_id by listing every SOAP case index owned by the current therapist across all patients (list_case_indexes_by_therapist), with session dates attached to node labels for disambiguation. --- app/api/v1/endpoints/insight_map.py | 11 +++++ app/repositories/insight_map.py | 29 ++++++++++-- app/schemas/insight_map.py | 13 +++++- app/services/insight_map.py | 61 ++++++++++++++++++++----- tests/unit/test_service_insight_map.py | 62 +++++++++++++++++++++++++- 5 files changed, 158 insertions(+), 18 deletions(-) diff --git a/app/api/v1/endpoints/insight_map.py b/app/api/v1/endpoints/insight_map.py index a2640e6..8dc3ed2 100644 --- a/app/api/v1/endpoints/insight_map.py +++ b/app/api/v1/endpoints/insight_map.py @@ -11,6 +11,7 @@ from app.core.db import get_db_session from app.schemas.auth import UserRead from app.schemas.insight_map import ( + EvidenceResponse, InsightMapResponse, InsightMapSearchRequest, SourceLinkResponse, @@ -44,6 +45,16 @@ def search_insight_map( 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, diff --git a/app/repositories/insight_map.py b/app/repositories/insight_map.py index 7992678..93df558 100644 --- a/app/repositories/insight_map.py +++ b/app/repositories/insight_map.py @@ -7,7 +7,7 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session -from app.models.entities import OntologyConcept, OntologyEdge, PatientMetricTrend, SoapCaseIndex +from app.models.entities import OntologyConcept, OntologyEdge, PatientMetricTrend, Session as SessionEntity, SoapCaseIndex class InsightMapRepository: @@ -74,13 +74,34 @@ def list_case_indexes( therapist_id: uuid.UUID, concept_ids: list[uuid.UUID] | None = None, ) -> list[SoapCaseIndex]: - """내 SOAP 기록만 반환한다 — therapist_id는 항상 현재 사용자로 강제한다 (IDOR 방지).""" + """특정 환자에 대해 내 SOAP 기록만 반환한다 — therapist_id는 항상 현재 사용자로 강제한다 (IDOR 방지).""" statement = select(SoapCaseIndex).where( SoapCaseIndex.patient_ref_id == patient_ref_id, SoapCaseIndex.therapist_id == therapist_id, ) - case_indexes = list(self.db.execute(statement).scalars().all()) + return self._filter_by_concepts(list(self.db.execute(statement).scalars().all()), concept_ids) + + def list_case_indexes_by_therapist( + self, + therapist_id: uuid.UUID, + concept_ids: list[uuid.UUID] | None = None, + ) -> list[SoapCaseIndex]: + """환자 지정 없이 — 현재 치료사가 담당한 모든 환자의 내 SOAP 기록을 반환한다.""" + statement = select(SoapCaseIndex).where(SoapCaseIndex.therapist_id == therapist_id) + return self._filter_by_concepts(list(self.db.execute(statement).scalars().all()), concept_ids) + + @staticmethod + def _filter_by_concepts( + case_indexes: list[SoapCaseIndex], concept_ids: list[uuid.UUID] | None + ) -> list[SoapCaseIndex]: if concept_ids is None: return case_indexes concept_id_strs = {str(cid) for cid in concept_ids} - return [ci for ci in case_indexes if concept_id_strs & {str(c) for c in (ci.concept_ids or [])}] \ No newline at end of file + return [ci for ci in case_indexes if concept_id_strs & {str(c) for c in (ci.concept_ids or [])}] + + def get_session_dates(self, session_ids: list[uuid.UUID]) -> dict[uuid.UUID, object]: + """session_id -> session_date 매핑. 인사이트맵 노드에 세션 날짜를 표시하기 위한 용도.""" + if not session_ids: + return {} + statement = select(SessionEntity.id, SessionEntity.session_date).where(SessionEntity.id.in_(session_ids)) + return dict(self.db.execute(statement).all()) \ No newline at end of file diff --git a/app/schemas/insight_map.py b/app/schemas/insight_map.py index 451b07d..dec9c0e 100644 --- a/app/schemas/insight_map.py +++ b/app/schemas/insight_map.py @@ -34,4 +34,15 @@ class InsightMapSearchRequest(BaseModel): class SourceLinkResponse(BaseModel): - url: str \ No newline at end of file + url: str + + +class EvidenceItem(BaseModel): + title: str + content: str + score: float + doi_url: str | None = None + + +class EvidenceResponse(BaseModel): + evidence: list[EvidenceItem] \ No newline at end of file diff --git a/app/services/insight_map.py b/app/services/insight_map.py index adbd2f5..0be5bc0 100644 --- a/app/services/insight_map.py +++ b/app/services/insight_map.py @@ -16,6 +16,8 @@ from app.repositories.session import SessionRepository from app.schemas.auth import UserRead from app.schemas.insight_map import ( + EvidenceItem, + EvidenceResponse, InsightMapEdge, InsightMapNode, InsightMapResponse, @@ -52,9 +54,10 @@ def get_insight_map( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found.") patient_ref_id = session.patient_ref_id else: - # report_draft_id 없이 접근한 경우(사이드바에서 직접 진입) — 환자 히스토리와 - # 내 SOAP 기록은 특정 리포트/세션에 묶여 있으므로 노출하지 않는다. - mode = [m for m in mode if m == "research"] + # report_draft_id 없이 접근한 경우(사이드바에서 직접 진입) — 환자 히스토리(지표 + # 추이)는 특정 환자에 묶여 있어 노출할 수 없지만, 내 SOAP 기록은 담당 환자 + # 전체를 대상으로 둘러볼 수 있다. + mode = [m for m in mode if m in ("research", "my_soap")] concepts = self._repo.list_concepts() if "research" in mode else [] return self._build_graph(concepts, patient_ref_id, current_user, mode) @@ -88,11 +91,22 @@ def search( concepts = concepts + self._repo.list_concepts(list(related_ids)) if patient_ref_id is None: - # report_draft_id 없이 검색한 경우 환자 히스토리/SOAP 기록은 노출하지 않는다. - mode = [m for m in mode if m == "research"] + # report_draft_id 없이 검색한 경우 환자 지표 추이는 노출할 수 없지만, + # 내 SOAP 기록은 담당 환자 전체를 대상으로 검색할 수 있다. + mode = [m for m in mode if m in ("research", "my_soap")] return self._build_graph(concepts, patient_ref_id, current_user, mode) + def get_concept_evidence(self, concept_key: str, current_user: UserRead) -> EvidenceResponse: + """개념 라벨+동의어로 문헌 근거를 검색한다. 로그인만 하면 누구나 조회 가능(환자 데이터 아님).""" + del current_user # 소유권 검증 불필요 — 온톨로지 개념은 환자 데이터가 아니다. + concepts = self._repo.find_concepts_by_key([concept_key]) + if not concepts: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concept not found.") + concept = concepts[0] + query = " ".join([concept.label, *concept.synonyms[:5]]) + return self._fetch_evidence(query) + def get_source_link(self, case_index_id: uuid.UUID, current_user: UserRead) -> SourceLinkResponse: case_index = ensure_case_index_access(self._db, case_index_id, current_user) url = ( @@ -139,10 +153,7 @@ def _build_graph( ) ) - if patient_ref_id is None: - return InsightMapResponse(nodes=nodes, edges=edges) - - if "current_patient" in mode: + if "current_patient" in mode and patient_ref_id is not None: for trend in self._repo.list_patient_metric_trends(patient_ref_id, current_user.id): node_id = f"trend_{trend.id}" nodes.append( @@ -164,18 +175,32 @@ def _build_graph( if "my_soap" in mode: concept_ids = [c.id for c in concepts] if concepts else None - for case_index in self._repo.list_case_indexes(patient_ref_id, current_user.id, concept_ids): + if patient_ref_id is not None: + case_indexes = self._repo.list_case_indexes(patient_ref_id, current_user.id, concept_ids) + else: + # report_draft_id 없이 사이드바에서 직접 진입한 경우 — 담당 환자 전체의 + # SOAP 기록을 대상으로 한다. therapist_id는 항상 현재 사용자로 강제된다. + case_indexes = self._repo.list_case_indexes_by_therapist(current_user.id, concept_ids) + + session_dates = self._repo.get_session_dates([ci.session_id for ci in case_indexes]) + + for case_index in case_indexes: node_id = f"caseidx_{case_index.id}" + session_date = session_dates.get(case_index.session_id) + label = case_index.observed_issue or case_index.metric or "SOAP 기록" + if session_date is not None: + label = f"{label} ({session_date.isoformat()})" nodes.append( InsightMapNode( id=node_id, type="soap_case_index", - label=case_index.observed_issue or case_index.metric or "SOAP 기록", + label=label, source_layer="my_soap", summary=case_index.summary, data={ "session_id": str(case_index.session_id), "report_id": str(case_index.report_id), + "session_date": session_date.isoformat() if session_date else None, }, ) ) @@ -192,6 +217,20 @@ def _build_graph( _AI_SERVICE_MAX_ATTEMPTS = 2 + def _fetch_evidence(self, query: str) -> EvidenceResponse: + url = f"{settings.ai_service_base_url}/ai/insight-map/evidence" + try: + with httpx.Client(timeout=30.0) as client: + resp = client.post(url, json={"query": query, "top_k": 3}) + resp.raise_for_status() + data = resp.json() + return EvidenceResponse( + evidence=[EvidenceItem(**item) for item in data.get("evidence", [])] + ) + except httpx.HTTPError as exc: + logger.warning(f"[insight-map] 문헌 근거 조회 실패: {exc}") + return EvidenceResponse(evidence=[]) + def _resolve_query(self, query: str) -> list[str]: """AI 서비스의 insight_query_resolver_agent를 호출해 concept_key 후보를 받는다. diff --git a/tests/unit/test_service_insight_map.py b/tests/unit/test_service_insight_map.py index e567543..e927be6 100644 --- a/tests/unit/test_service_insight_map.py +++ b/tests/unit/test_service_insight_map.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch import pytest +from fastapi import HTTPException from app.core.enums import UserRole, UserStatus from app.models.entities import OntologyConcept, OntologyEdge, Report, Session as SessionEntity, SoapCaseIndex @@ -96,19 +97,43 @@ def test_my_soap_mode_only_queries_current_user_case_indexes(self, service): service._repo.list_case_indexes.assert_called_once_with(session.patient_ref_id, user.id, None) - def test_without_report_draft_id_only_returns_research_layer(self, service): + def test_without_report_draft_id_drops_current_patient_but_keeps_my_soap(self, service): user = _make_user() mlu = _concept("MLU") service._repo.list_concepts.return_value = [mlu] service._repo.list_edges.return_value = [] + service._repo.list_case_indexes_by_therapist.return_value = [] + service._repo.get_session_dates.return_value = {} result = service.get_insight_map(None, ["research", "current_patient", "my_soap"], user) service._session_repo.get_by_id.assert_not_called() service._repo.list_case_indexes.assert_not_called() service._repo.list_patient_metric_trends.assert_not_called() + service._repo.list_case_indexes_by_therapist.assert_called_once_with(user.id, [mlu.id]) assert {n.id for n in result.nodes} == {"concept_mlu"} + def test_without_report_draft_id_my_soap_lists_all_patients_for_therapist(self, service): + user = _make_user() + case_index = MagicMock(spec=SoapCaseIndex) + case_index.id = uuid.uuid4() + case_index.session_id = uuid.uuid4() + case_index.report_id = uuid.uuid4() + case_index.observed_issue = "짧은 발화" + case_index.metric = None + case_index.summary = "요약" + case_index.concept_ids = [] + + service._repo.list_concepts.return_value = [] + service._repo.list_case_indexes_by_therapist.return_value = [case_index] + service._repo.get_session_dates.return_value = {} + + result = service.get_insight_map(None, ["my_soap"], user) + + service._repo.list_case_indexes_by_therapist.assert_called_once_with(user.id, None) + assert len(result.nodes) == 1 + assert result.nodes[0].label == "짧은 발화" + class TestSearch: def test_falls_back_to_term_match_when_resolver_returns_nothing(self, service): @@ -136,16 +161,49 @@ def test_uses_resolved_concepts_when_available(self, service): service._repo.find_concepts_by_key.assert_called_once_with(["mlu"]) assert {n.id for n in result.nodes} == {"concept_mlu"} - def test_without_report_draft_id_drops_patient_and_soap_modes(self, service): + def test_without_report_draft_id_drops_current_patient_but_keeps_my_soap(self, service): user = _make_user() service._repo.find_concepts_by_key.return_value = [] service._repo.search_concepts_by_term.return_value = [] + service._repo.list_case_indexes_by_therapist.return_value = [] + service._repo.get_session_dates.return_value = {} with patch.object(service, "_resolve_query", return_value=[]): service.search("아무개념", None, ["research", "current_patient", "my_soap"], user) service._repo.list_case_indexes.assert_not_called() service._repo.list_patient_metric_trends.assert_not_called() + service._repo.list_case_indexes_by_therapist.assert_called_once_with(user.id, None) + + +class TestGetConceptEvidence: + def test_404_when_concept_not_found(self, service): + user = _make_user() + service._repo.find_concepts_by_key.return_value = [] + + with pytest.raises(HTTPException) as exc: + service.get_concept_evidence("unknown", user) + assert exc.value.status_code == 404 + + def test_fetches_evidence_using_concept_label_and_synonyms(self, service): + user = _make_user() + mlu = _concept("MLU") + mlu.synonyms = ["평균 발화 길이", "MLU-w"] + service._repo.find_concepts_by_key.return_value = [mlu] + + with patch.object(service, "_fetch_evidence") as mock_fetch: + service.get_concept_evidence("MLU", user) + + mock_fetch.assert_called_once_with("MLU 평균 발화 길이 MLU-w") + + def test_returns_empty_evidence_when_ai_service_unreachable(self, service): + import httpx + + with patch("app.services.insight_map.httpx.Client") as MockClient: + MockClient.return_value.__enter__.return_value.post.side_effect = httpx.ConnectError("refused") + result = service._fetch_evidence("MLU") + + assert result.evidence == [] class TestGetSourceLink: