diff --git a/alembic/versions/5a556853a0e7_add_report_generation_runs_drafts_and_.py b/alembic/versions/5a556853a0e7_add_report_generation_runs_drafts_and_.py new file mode 100644 index 0000000..c6b087b --- /dev/null +++ b/alembic/versions/5a556853a0e7_add_report_generation_runs_drafts_and_.py @@ -0,0 +1,86 @@ +"""add report generation runs drafts and events tables""" + +revision = '5a556853a0e7' +down_revision = '838ba1ab5f45' +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('report_generation_runs', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('session_id', sa.UUID(), nullable=False), + sa.Column('patient_ref_id', sa.UUID(), nullable=False), + sa.Column('therapist_id', sa.UUID(), nullable=False), + sa.Column('status', sa.String(length=32), nullable=False), + sa.Column('current_node', sa.String(length=64), nullable=True), + sa.Column('agent_runtime', sa.String(length=64), server_default='agentcore', nullable=False), + sa.Column('graph_version', sa.String(length=64), nullable=False), + sa.Column('context_agent_version', sa.String(length=64), nullable=True), + sa.Column('research_agent_version', sa.String(length=64), nullable=True), + sa.Column('critic_agent_version', sa.String(length=64), nullable=True), + sa.Column('report_agent_version', sa.String(length=64), nullable=True), + sa.Column('qa_agent_version', sa.String(length=64), nullable=True), + sa.Column('model_config_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('retry_budget_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('quality_scores_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('failure_code', sa.String(length=128), nullable=True), + sa.Column('failure_detail', sa.Text(), nullable=True), + sa.Column('trace_id', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['patient_ref_id'], ['patient_refs.id'], name=op.f('fk_report_generation_runs_patient_ref_id_patient_refs'), ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], name=op.f('fk_report_generation_runs_session_id_sessions'), ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['therapist_id'], ['users.id'], name=op.f('fk_report_generation_runs_therapist_id_users'), ondelete='RESTRICT'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_report_generation_runs')) + ) + op.create_table('report_drafts', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('generation_run_id', sa.UUID(), nullable=False), + sa.Column('session_id', sa.UUID(), nullable=False), + sa.Column('patient_ref_id', sa.UUID(), nullable=False), + sa.Column('therapist_id', sa.UUID(), nullable=False), + sa.Column('status', sa.String(length=32), server_default='DRAFT', nullable=False), + sa.Column('soap_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('reasoning_summary_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('claims_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('evidence_trace_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('validation_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('quality_scores_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['generation_run_id'], ['report_generation_runs.id'], name=op.f('fk_report_drafts_generation_run_id_report_generation_runs'), ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['patient_ref_id'], ['patient_refs.id'], name=op.f('fk_report_drafts_patient_ref_id_patient_refs'), ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], name=op.f('fk_report_drafts_session_id_sessions'), ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['therapist_id'], ['users.id'], name=op.f('fk_report_drafts_therapist_id_users'), ondelete='RESTRICT'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_report_drafts')) + ) + op.create_table('report_generation_events', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('run_id', sa.UUID(), nullable=False), + sa.Column('node_name', sa.String(length=64), nullable=False), + sa.Column('event_type', sa.String(length=64), nullable=False), + sa.Column('decision', sa.String(length=64), nullable=True), + sa.Column('retry_count', sa.Integer(), nullable=True), + sa.Column('duration_ms', sa.BigInteger(), nullable=True), + sa.Column('token_usage_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('metadata_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['run_id'], ['report_generation_runs.id'], name=op.f('fk_report_generation_events_run_id_report_generation_runs'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_report_generation_events')) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('report_generation_events') + op.drop_table('report_drafts') + op.drop_table('report_generation_runs') + # ### end Alembic commands ### diff --git a/app/core/enums.py b/app/core/enums.py index b413471..ae63bc7 100644 --- a/app/core/enums.py +++ b/app/core/enums.py @@ -115,3 +115,23 @@ class ReportPatchStatus(StrEnum): REJECTED = "REJECTED" +class ReportGenerationRunStatus(StrEnum): + """report_generation_runs.status. plain VARCHAR 컬럼이라 pg ENUM으로 강제하지 + 않고 애플리케이션 레벨에서만 검증한다 (5-Agent Graph가 값을 자주 갱신하므로 + ENUM 타입 변경 마이그레이션 비용을 피한다).""" + QUEUED = "QUEUED" + RUNNING = "RUNNING" + RESEARCHING = "RESEARCHING" + WRITING = "WRITING" + VALIDATING = "VALIDATING" + HUMAN_REVIEW = "HUMAN_REVIEW" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + + +class ReportDraftStatus(StrEnum): + """report_drafts.status. Agent는 DRAFT만 쓸 수 있고, 최종 확정은 + 치료사가 기존 reports 워크플로에서 수행한다.""" + DRAFT = "DRAFT" + + diff --git a/app/models/entities.py b/app/models/entities.py index 9f419de..a85c19a 100644 --- a/app/models/entities.py +++ b/app/models/entities.py @@ -499,3 +499,104 @@ class PatientMetricTrend(Base): 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) + + +class ReportGenerationRun(Base): + """Strands Graph 기반 5-Agent 리포트 생성 1회 실행 기록. + + status는 pg ENUM이 아니라 plain VARCHAR다 — Graph 실행 중 자주 갱신되므로 + ENUM 타입 변경 마이그레이션 비용을 피한다. 값은 ReportGenerationRunStatus로 + 애플리케이션 레벨에서 검증한다. + """ + + __tablename__ = "report_generation_runs" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + session_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="RESTRICT"), nullable=False + ) + 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 + ) + + status: Mapped[str] = mapped_column(String(32), nullable=False) + current_node: Mapped[str | None] = mapped_column(String(64), nullable=True) + agent_runtime: Mapped[str] = mapped_column(String(64), nullable=False, default="agentcore", server_default="agentcore") + graph_version: Mapped[str] = mapped_column(String(64), nullable=False) + + context_agent_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + research_agent_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + critic_agent_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + report_agent_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + qa_agent_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + + model_config_json: Mapped[dict] = mapped_column(JSONB, nullable=False) + retry_budget_json: Mapped[dict] = mapped_column(JSONB, nullable=False) + quality_scores_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + failure_code: Mapped[str | None] = mapped_column(String(128), nullable=True) + failure_detail: Mapped[str | None] = mapped_column(Text, nullable=True) + trace_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class ReportDraft(Base): + """5-Agent Graph가 생성한 SOAP 리포트 초안. + + QA/deterministic validation을 모두 통과한 결과만 status='DRAFT'로 저장된다. + Agent는 이 테이블을 최종 확정(FINALIZED)하지 않는다 — 치료사가 기존 + reports 워크플로에서 검토 후 확정한다. report_draft_revisions와는 무관하다 + (그쪽은 이미 확정된 reports 행의 수정 이력). + """ + + __tablename__ = "report_drafts" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + generation_run_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("report_generation_runs.id", ondelete="RESTRICT"), nullable=False + ) + session_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="RESTRICT"), nullable=False + ) + 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 + ) + + status: Mapped[str] = mapped_column(String(32), nullable=False, default="DRAFT", server_default="DRAFT") + soap_json: Mapped[dict] = mapped_column(JSONB, nullable=False) + reasoning_summary_json: Mapped[dict] = mapped_column(JSONB, nullable=False) + claims_json: Mapped[list] = mapped_column(JSONB, nullable=False) + evidence_trace_json: Mapped[dict] = mapped_column(JSONB, nullable=False) + validation_json: Mapped[dict] = mapped_column(JSONB, nullable=False) + quality_scores_json: Mapped[dict] = mapped_column(JSONB, nullable=False) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class ReportGenerationEvent(Base): + """report_generation_runs의 노드별 진행 이벤트. 민감 원문 없이 실행 상태만 기록한다.""" + + __tablename__ = "report_generation_events" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + run_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("report_generation_runs.id", ondelete="CASCADE"), nullable=False + ) + node_name: Mapped[str] = mapped_column(String(64), nullable=False) + event_type: Mapped[str] = mapped_column(String(64), nullable=False) + decision: Mapped[str | None] = mapped_column(String(64), nullable=True) + retry_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + duration_ms: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + token_usage_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/app/models/registry.py b/app/models/registry.py index c795f7f..7f4435d 100644 --- a/app/models/registry.py +++ b/app/models/registry.py @@ -11,6 +11,9 @@ PatientRef, RefreshToken, Report, + ReportDraft, + ReportGenerationEvent, + ReportGenerationRun, ReportSegment, Session, SoapCaseIndex, @@ -31,6 +34,9 @@ "PatientRef", "RefreshToken", "Report", + "ReportDraft", + "ReportGenerationEvent", + "ReportGenerationRun", "ReportSegment", "Session", "SoapCaseIndex",