diff --git a/README.md b/README.md index ba721ff..3832092 100644 --- a/README.md +++ b/README.md @@ -12,15 +12,14 @@ neuro-sync/ ├── docs/ │ ├── prd/PRD_neuro-sync.md Platform 마스터 PRD │ ├── todo_plan/PLAN_neuro-sync.md Platform 마스터 PLAN +│ ├── AI_master_plan.md AI 전체 개발 계획 │ └── ai/ 🤖 AI Research 워크스페이스 -│ ├── README.md AI 진입점 + Boundary Contract -│ ├── PRD_ai.md AI 도메인 PRD -│ ├── PLAN_ai.md AI 팀 계획 -│ ├── AI_API_가이드.md 5종 벤더 가이드 -│ ├── orchestration/ prompts/ safety_guard/ stt/ ocr/ eval/ +│ ├── PRD_task1_v2.md Task 1 활성 AI PRD +│ ├── checklist_task1.md Task 1 개발·검증 체크리스트 +│ └── agents/ prompts/ api/ personas/ ├── apps/ │ ├── api/ Platform — FastAPI 백엔드 (Auth/DB/WS/Workers) -│ ├── ai-server/ 🤖 AI — FastAPI AI 서비스 (5개 인터페이스) +│ ├── ai-server/ 🤖 AI — FastAPI AI 서비스 │ ├── mobile/ Platform — React Native (환자 앱) │ └── web/ Platform — Next.js (의료진 대시보드) ├── packages/ @@ -39,9 +38,9 @@ neuro-sync/ 3. `apps/api/`, `apps/mobile/`, `apps/web/`, `infra/` ### AI Research 팀 -1. [`docs/ai/README.md`](./docs/ai/README.md) — AI 워크스페이스 진입점 -2. [`docs/ai/PRD_ai.md`](./docs/ai/PRD_ai.md) — AI 도메인 PRD -3. [`docs/ai/PLAN_ai.md`](./docs/ai/PLAN_ai.md) — AI 팀 계획 +1. [`docs/AI_master_plan.md`](./docs/AI_master_plan.md) — AI 전체 개발 계획 +2. [`docs/ai/PRD_task1_v2.md`](./docs/ai/PRD_task1_v2.md) — Task 1 활성 AI PRD +3. [`docs/ai/checklist_task1.md`](./docs/ai/checklist_task1.md) — Task 1 개발·검증 체크리스트 4. `apps/ai-server/` ## 통신 아키텍처 @@ -55,16 +54,22 @@ Mobile / Web ──HTTPS──> apps/api ──HTTP(internal)──> apps/ai-ser - 모바일/웹은 **Platform API만** 호출 (AI 서버 직접 접근 금지) - AI 서버는 **DB 직접 접근 금지** — 결과는 HTTP 응답으로만 반환 -- 5개 AI 인터페이스 스키마는 `packages/shared-contracts/`가 단일 소스 +- Platform↔AI 공유 스키마는 `packages/shared-contracts/`가 단일 소스다. 실제 마운트된 AI 서버 + 엔드포인트 목록은 [`apps/ai-server/src/main.py`](./apps/ai-server/src/main.py)를 기준으로 한다. ## 인터페이스 변경 절차 `packages/shared-contracts/` 변경 시: 1. CODEOWNERS에 따라 양 팀 리뷰어 자동 할당 -2. [`docs/prd/PRD_neuro-sync.md` §0.3](./docs/prd/PRD_neuro-sync.md) + [`docs/ai/PRD_ai.md` §1](./docs/ai/PRD_ai.md) 동시 갱신 +2. [`docs/prd/PRD_neuro-sync.md` §0.3](./docs/prd/PRD_neuro-sync.md) + + [`docs/ai/PRD_task1_v2.md`](./docs/ai/PRD_task1_v2.md) 동시 갱신 3. 양 팀 approve 후 머지 4. `apps/api`·`apps/ai-server`가 버전 업데이트 +현재 변경도 이 절차의 예외가 아니다. Platform·AI CODEOWNER 승인은 PR 리뷰에서 받아야 하며, +이 문서 갱신 자체가 승인을 획득했다는 뜻은 아니다. + ## 현재 단계 -**Phase 0 차단 게이트** — 코드 작성 전 (마스터 PLAN 참조). 본 README는 진입 직전 골격. +현재 구현·검증 상태는 [`docs/ai/PRD_task1_v2.md`](./docs/ai/PRD_task1_v2.md)와 기능별 체크리스트를 +기준으로 확인한다. 과거 Phase 표나 완료 표시는 해당 문서에 기록된 증거 범위 안에서만 해석한다. diff --git a/apps/ai-server/README.md b/apps/ai-server/README.md index 1638174..0230478 100644 --- a/apps/ai-server/README.md +++ b/apps/ai-server/README.md @@ -2,13 +2,14 @@ > **Owner**: AI Research 팀 (단독) > **언어/프레임워크**: Python 3.12 + FastAPI + LangChain/LangGraph + (vLLM 또는 외부 LLM SDK) -> **PRD**: [`../../docs/ai/PRD_ai.md`](../../docs/ai/PRD_ai.md) -> **계획**: [`../../docs/ai/PLAN_ai.md`](../../docs/ai/PLAN_ai.md) +> **PRD**: [`../../docs/ai/PRD_task1_v2.md`](../../docs/ai/PRD_task1_v2.md) +> **계획**: [`../../docs/AI_master_plan.md`](../../docs/AI_master_plan.md) > **Platform 팀은 본 폴더에 PR 금지** — 인터페이스 변경이 필요하면 `packages/shared-contracts/`로 합의 ## 책임 범위 -5개 HTTP 인터페이스를 구현한다 (`docs/ai/README.md` §3.1). +현재 마운트된 전체 인터페이스의 실행 기준은 [`src/main.py`](src/main.py)다. 아래 표는 주요 POST +route를 요약하며, `/ai/nearby/*` GET route도 `src/main.py`에서 마운트한다. | 엔드포인트 | FR | SLA (p95) | |-----------|-----|-----------| @@ -17,6 +18,12 @@ | `POST /ai/stt/transcribe` | FR-033, FR-037 | < 2,000ms | | `POST /ai/ocr/parse` | FR-009 | < 10s | | `POST /ai/handoff/generate` | FR-018 | < 30s | +| `POST /ai/handoff/report` | F4+F5 stateless export | 입력 크기·PDF 옵션에 따름 | +| `POST /ai/slots/extract` | F1 임상 슬롯 | 모델 설정에 따름 | +| `POST /ai/survey/score`, `/plan` | F3 채점·계획 | score는 zero-LLM | +| `POST /ai/domain/infer` | F2 영역 추론 | 모델 설정에 따름 | +| `POST /ai/temporal/summarize`, `/analyze` | F4 종단 분석 | analyze는 zero-LLM | +| `POST /ai/sentiment/utterance`, `/session` | 감정 분석 | 모델 설정에 따름 | ## 본 폴더에서 하지 않는 것 @@ -25,24 +32,25 @@ - 환자 식별정보 처리 — 가명처리된 텍스트만 받는다고 가정 - 감사 로그 작성 — Platform `audit_logs`에 위임 -## 디렉토리 구조 (예정) +## 디렉토리 구조 ``` apps/ai-server/ ├── pyproject.toml ├── Dockerfile ├── src/ -│ ├── chat/ # /ai/chat/respond -│ ├── safety/ # /ai/safety/classify (키워드 + LLM 분류기) -│ ├── stt/ # STT Adapter (A.dot / Whisper / Whisper Local) -│ ├── ocr/ # Upstage Document Parse 클라이언트 + 후처리 -│ ├── handoff/ # Handoff Report 생성 + 인용 검증 -│ ├── orchestration/ # 멀티 LLM 라우팅 (Post-MVP) -│ ├── prompts/ # 프롬프트 로딩·버전 관리 (docs/ai/prompts와 sync) +│ ├── agents/ # LLM·rule-based agent │ ├── adapters/ # LLM/STT 벤더 어댑터 -│ └── main.py +│ ├── routes/ # FastAPI route +│ ├── routing/ # 모델 선택·fallback +│ ├── schemas/ # AI 서버 로컬 schema +│ ├── services/ # Handoff/F4/F5 변환·export 경계 +│ ├── eval/ # 평가 코드 +│ ├── prompts/ # 프롬프트 로딩·버전 관리 +│ ├── rag/ # in-process RAG +│ └── main.py # 마운트된 route의 실행 기준 ├── tests/ -└── eval/ # docs/ai/eval과 sync — 회귀 테스트 +└── assets/ # F5 PDF 폰트 등 runtime asset ``` ## 외부 의존성 @@ -50,7 +58,7 @@ apps/ai-server/ - LLM API Key: Claude / GPT / Solar Pro 3 / KT Mi:dm / SKT A.X K1 / LG K-EXAONE (시점별 선택) - STT: OpenAI Whisper (Phase 1b) → SK A.dot STT (계약 후) - OCR: Upstage Document Parse -- 트레이싱: LangSmith (또는 자체) — `docs/ai/PRD_ai.md` AI-5 결정 후 +- 트레이싱 정책: [`../../docs/ai/PRD_task1_v2.md`](../../docs/ai/PRD_task1_v2.md)의 현재 구현·검증 상태를 따른다. ## 배포 diff --git a/apps/ai-server/src/agents/handoff_contract_generator.py b/apps/ai-server/src/agents/handoff_contract_generator.py new file mode 100644 index 0000000..a58cb0d --- /dev/null +++ b/apps/ai-server/src/agents/handoff_contract_generator.py @@ -0,0 +1,197 @@ +"""Structured official-contract handoff generation with citation validation.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Final, override + +from contracts.handoff import Citation, HandoffRequest, HandoffResponse, SleepAppetiteActivity +from pydantic import BaseModel, ConfigDict, ValidationError + +from src.adapters.base import ChatMessage, LLMAdapter +from src.prompts.loader import PromptLoader +from src.routing.fallback_policy import AdapterHealthFailure +from src.routing.model_router import ModelRouter +from src.schemas.common import ModelSelection +from src.schemas.handoff import HandoffInput +from src.services.handoff_claim_guard import handoff_claims_are_valid + +_AGENT_NAME: Final = "handoff_generator" +_PROMPT_VERSION: Final = "v4" +_MAX_RETRIES: Final = 2 + + +@dataclass(frozen=True, slots=True) +class HandoffContractValidationError(AdapterHealthFailure): + """The provider response was not valid official handoff JSON.""" + + @override + def __str__(self) -> str: + return "handoff response failed contract validation" + + +@dataclass(frozen=True, slots=True) +class HandoffProviderError(AdapterHealthFailure): + """The configured provider could not produce a response.""" + + @override + def __str__(self) -> str: + return "handoff provider failed" + + +class _SleepAppetiteActivityDraft(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + sleep: str | None + appetite: str | None + activity: str | None + + +class _HandoffDraft(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + chief_complaint: str + present_illness: str + symptoms: list[str] + onset: str | None + recent_changes: str | None + triggers: list[str] + sleep_appetite_activity: _SleepAppetiteActivityDraft + psych_history: str | None + medications: str | None + documents_summary: list[str] + clinician_attention: list[str] + evidence: list[Citation] + + +def _validate_citations(draft: _HandoffDraft, request: HandoffRequest) -> None: + if not handoff_claims_are_valid(draft, request): + raise HandoffContractValidationError() + + +def _to_response(draft: _HandoffDraft, latency_ms: int) -> HandoffResponse: + return HandoffResponse( + chief_complaint=draft.chief_complaint, + present_illness=draft.present_illness, + symptoms=draft.symptoms, + onset=draft.onset, + recent_changes=draft.recent_changes, + triggers=draft.triggers, + sleep_appetite_activity=SleepAppetiteActivity( + sleep=draft.sleep_appetite_activity.sleep, + appetite=draft.sleep_appetite_activity.appetite, + activity=draft.sleep_appetite_activity.activity, + ), + psych_history=draft.psych_history, + medications=draft.medications, + documents_summary=draft.documents_summary, + clinician_attention=draft.clinician_attention, + evidence=draft.evidence, + latency_ms=latency_ms, + ) + + +class HandoffContractGenerator: + """Generate an official HandoffResponse while retaining local run() elsewhere.""" + + def __init__(self, model_router: ModelRouter, prompt_loader: PromptLoader) -> None: + self._router = model_router + self._prompt_loader = prompt_loader + + async def _request_json( + self, + selection: ModelSelection, + local_input: HandoffInput, + ) -> str: + if selection.supports_json_schema: + response_format = { + "type": "json_schema", + "json_schema": { + "name": "handoff_response", + "strict": True, + "schema": _HandoffDraft.model_json_schema(), + }, + } + elif selection.supports_json_object: + response_format = {"type": "json_object"} + else: + response_format = None + messages = [ + ChatMessage( + role="system", + content=self._prompt_loader.load_system_prompt(_AGENT_NAME, _PROMPT_VERSION), + ), + ChatMessage(role="user", content=local_input.model_dump_json(exclude_none=True)), + ] + content: str | None = None + provider_failed = False + # Provider boundary: discard arbitrary Exception content; BaseException propagates. + try: + adapter = self._router.get_adapter(selection.adapter_name) + if isinstance(adapter, LLMAdapter): + response = await adapter.chat_timed( + messages, + model=selection.model_id, + temperature=0.2, + max_tokens=4096, + response_format=response_format, + ) + content = response.content + else: + provider_failed = True + except Exception: + provider_failed = True + if provider_failed or content is None: + raise HandoffProviderError() from None + return content + + def _select_model(self) -> ModelSelection: + selection: ModelSelection | None = None + provider_failed = False + # Router boundary: discard arbitrary Exception content; BaseException propagates. + try: + selection = self._router.select_model(_AGENT_NAME, require_json=True) + except Exception: + provider_failed = True + if provider_failed or selection is None: + raise HandoffProviderError() from None + return selection + + async def generate( + self, + request: HandoffRequest, + local_input: HandoffInput, + ) -> HandoffResponse: + """Try three configured tiers and return the first grounded response.""" + started = time.perf_counter() + selection = self._select_model() + for attempt in range(1 + _MAX_RETRIES): + try: + content = await self._request_json(selection, local_input) + draft = _HandoffDraft.model_validate_json(content) + _validate_citations(draft, request) + elapsed_ms = max(0, round((time.perf_counter() - started) * 1000)) + response = _to_response(draft, elapsed_ms) + except (ValidationError, HandoffContractValidationError): + failure: HandoffContractValidationError | HandoffProviderError = ( + HandoffContractValidationError() + ) + except HandoffProviderError as exc: + failure = exc + else: + self._router.record_success(selection.adapter_name) + return response + + _ = self._router.record_failure(selection.adapter_name, failure) + if attempt == _MAX_RETRIES: + raise failure + fallback = self._router.get_fallback( + _AGENT_NAME, + selection.adapter_name, + "handoff attempt failed", + ) + if fallback is None: + raise failure + selection = fallback + raise HandoffContractValidationError() diff --git a/apps/ai-server/src/agents/handoff_generator.py b/apps/ai-server/src/agents/handoff_generator.py index 0036f81..1e28448 100644 --- a/apps/ai-server/src/agents/handoff_generator.py +++ b/apps/ai-server/src/agents/handoff_generator.py @@ -4,6 +4,7 @@ import logging import time +from collections.abc import Sequence from typing import Any from pydantic import BaseModel @@ -12,8 +13,9 @@ from src.agents.base import AgentInput, BaseAgent from src.prompts.loader import PromptLoader from src.routing.model_router import ModelRouter -from src.schemas.common import CTRS_TO_RISK, CTRSLevel, EvidencePacket, EvidenceSource, RiskLevel +from src.schemas.common import EvidencePacket, EvidenceSource, RiskLevel from src.schemas.handoff import HandoffInput, HandoffOutput, SlotData +from src.services.handoff_risk import RiskEventInput, detect_risk_level, risk_event_text logger = logging.getLogger(__name__) @@ -59,7 +61,7 @@ def _build_user_content(inp: HandoffInput) -> str: if inp.risk_events: parts.append("\n## 위험 이벤트") for idx, evt in enumerate(inp.risk_events, 1): - parts.append(f"- [{idx}] {evt}") + parts.append(f"- [{idx}] {risk_event_text(evt)}") # OCR documents if inp.ocr_documents: @@ -91,45 +93,8 @@ def _find_missing_slots(slots: SlotData) -> list[str]: return [k for k, v in data.items() if v is None] -_RISK_ORDER: dict[RiskLevel, int] = { - RiskLevel.none: 0, - RiskLevel.low: 1, - RiskLevel.medium: 2, - RiskLevel.high: 3, - RiskLevel.critical: 4, -} -_RISK_BY_VALUE: dict[str, RiskLevel] = {r.value: r for r in RiskLevel} - - -def _event_risk(evt: object) -> RiskLevel: - """Best-effort severity of a single risk event. - - Prefers an explicit ``risk_level``; otherwise maps ``ctrs_level`` via - CTRS_TO_RISK; a present-but-unlabelled event keeps the "at least medium" - floor. - """ - if not isinstance(evt, dict): - return RiskLevel.medium - raw = str(evt.get("risk_level", "")).strip().lower() - if raw in _RISK_BY_VALUE: - return _RISK_BY_VALUE[raw] - ctrs_raw = str(evt.get("ctrs_level", "")).strip() - if ctrs_raw.isdigit(): - try: - return CTRS_TO_RISK.get(CTRSLevel(int(ctrs_raw)), RiskLevel.medium) - except ValueError: - pass - return RiskLevel.medium - - -def _detect_risk_level(risk_events: list[dict[str, str]]) -> RiskLevel: - """Return the maximum severity across all risk events (none if empty).""" - if not risk_events: - return RiskLevel.none - return max( - (_event_risk(evt) for evt in risk_events), - key=lambda r: _RISK_ORDER[r], - ) +def _detect_risk_level(risk_events: Sequence[RiskEventInput]) -> RiskLevel: + return detect_risk_level(risk_events) class NarrativeGenerationOutput(BaseModel): @@ -356,7 +321,7 @@ def _next_id(prefix: str) -> str: evidence_id=_next_id("risk"), source_type=EvidenceSource.risk_event, source_ref="Safety Agent", - content_summary=str(evt)[:120], + content_summary=risk_event_text(evt)[:120], ) ) diff --git a/apps/ai-server/src/continuous_test.py b/apps/ai-server/src/continuous_test.py index 9f4b84b..8197cb9 100644 --- a/apps/ai-server/src/continuous_test.py +++ b/apps/ai-server/src/continuous_test.py @@ -92,6 +92,9 @@ from typing import Any from urllib.parse import urlsplit, urlunsplit +from src.services.f5_artifact_store import F5ArtifactPaths +from src.services.f5_stage_status import stage_return_code + logger = logging.getLogger(__name__) # Multi-session chaining default (PLAN-2026-W28-Q W2, plan §3 "Multi-session @@ -231,6 +234,7 @@ class ChainContext: # was actually administered this session — `None` otherwise (not # needed, or needed but skipped for lack of an answer_fn). f3_si_supplement_pathway: dict[str, Any] | None = None + f4_temporal_path: Path | None = None StageFn = Callable[[ChainContext], Awaitable[StageResult]] @@ -950,6 +954,30 @@ async def run_multi_session_chain( if run_f4 and not halted: f4_result = await _run_f4_analysis(persona_id, out_dir) all_results.append(f4_result) + # F5 runs from the post-ledger path ONLY after a "pass" F4. F4 writes + # its *_temporal.json exclusively on "pass" — a "warn"/"skip"/"fail" + # produces NO fresh longitudinal output — so gating on "warn" too would + # let F5 silently consume a STALE temporal from an earlier run and emit + # a passing report mixing the current header with old data (codex P1). + # (Multi-session F5 was previously bypassed entirely for --sessions>1.) + if f4_result.status == "pass": + f5_ctx = ChainContext( + persona_id=persona_id, + max_turns=0, + k=0, + out_dir=out_dir, + scale_scores_path=None, + f4_temporal_path=f4_result.artifacts["json"], + ) + all_results.append(await run_f5_stage(f5_ctx)) + else: + all_results.append( + StageResult( + "F5", + "skip", + "F4 produced no fresh longitudinal output — F5 skipped (dependency not met)", + ) + ) return all_results @@ -1306,14 +1334,20 @@ def _build_f5_f3_administration(entry: dict[str, Any]) -> Any | None: def _run_f5_report( - persona_id: str, out_dir: Path | None, *, write_dir: Path | None = None -) -> dict[str, Path]: + persona_id: str, + out_dir: Path | None, + f4_temporal_path: Path, + *, + write_dir: Path | None = None, +) -> F5ArtifactPaths: """Read the VP's session ledger + every F1/F2/F4 artifact it points at, build `src.f5.HandoffReportInput`, call `src.f5.assemble_handoff_report` + `src.services.f5_report. save_f5_result`. Shared by `run_f5_stage` (STAGE_REGISTRY, live single-session path) and the standalone `--f5-from-artifacts` replay - CLI below — same assembly logic, never duplicated (mirrors + CLI below. The caller must supply the exact successful F4 JSON path; + only replay may resolve historical artifacts before calling this seam. + Assembly logic is never duplicated (mirrors `_run_f4_analysis`'s own dual-caller role). `out_dir` is where the ledger + F1-F4 artifacts are READ from @@ -1392,16 +1426,12 @@ def _run_f5_report( for e in entries ) - temporal_path = _find_latest_f5_temporal_artifact(persona_id, out_dir) - if temporal_path is None: - from src.f1 import OUTPUT_DIR - - base = out_dir or OUTPUT_DIR - raise RuntimeError( - f"F5: no F4 longitudinal output (*_temporal.json) found for {persona_id} " - f"under {base / persona_id} — run F4 first (e.g. a `--sessions N` chain " - "without --no-f4, or the F4 STAGE_REGISTRY stage) before F5 can assemble its " - "B-section, which consumes F4's own output verbatim" + temporal_path = f4_temporal_path + if not temporal_path.is_file(): + raise FileNotFoundError( + f"F5: exact F4 longitudinal output not found: {temporal_path} " + f"(persona={persona_id}) — run F4 first and pass that successful stage's " + "JSON artifact; live F5 will not fall back to artifact history" ) try: longitudinal_data = json.loads(temporal_path.read_text(encoding="utf-8")) @@ -1416,6 +1446,7 @@ def _run_f5_report( inp = HandoffReportInput( vp_id=persona_id, + generated_at=datetime.now().astimezone().isoformat(), session=session, current_session_f3=current_f3, all_f3_administrations=all_f3, @@ -1441,23 +1472,58 @@ def _run_f5_report( async def run_f5_stage(ctx: ChainContext) -> StageResult: - """STAGE_REGISTRY entry point (single-session `run_chain` path, right - after F4 — same "reads whatever ledger entries have accumulated" - post-loop role `run_f4_stage` already plays for F4). Delegates entirely - to `_run_f5_report`; maps `F5InsufficientSessionsError` to a "skip" - `StageResult` (same discipline as `_run_f4_analysis`'s own <2-entries - skip), any other exception to a named "fail".""" + """Post-ledger F5 invocation shared by BOTH chain paths (`_main`'s + single-session path after its ledger append, and + `run_multi_session_chain` after a successful F4) — F5 reads the session + ledger, so it must only ever run once the current invocation's entries + are all appended. Delegates entirely to `_run_f5_report`; maps + `F5InsufficientSessionsError` to a "skip" `StageResult` (same + discipline as `_run_f4_analysis`'s own <2-entries skip), any other + exception to a named "fail".""" t0 = time.perf_counter() + if ctx.f4_temporal_path is None: + return StageResult( + "F5", + "fail", + "F5 requires the exact JSON artifact from the successful F4 stage; " + "no f4_temporal_path was provided and live fallback is disabled", + duration_ms=_ms(t0), + ) try: - paths = _run_f5_report(ctx.persona_id, ctx.out_dir) + paths = _run_f5_report(ctx.persona_id, ctx.out_dir, ctx.f4_temporal_path) except F5InsufficientSessionsError as exc: return StageResult("F5", "skip", str(exc), duration_ms=_ms(t0)) except Exception as exc: # noqa: BLE001 — harness must report, not crash, on stage failure logger.exception("continuous_test.f5.failed") return StageResult("F5", "fail", f"F5 report assembly raised: {exc}", duration_ms=_ms(t0)) + artifacts = { + name: path for name, path in paths.items() if isinstance(path, Path) + } + if "pdf" not in paths: + # PDF export failed inside save_f5_result (md+FHIR still written) — + # surface as WARN, never a clean pass, so the report/CLI/automation + # can detect the missing clinical artifact (a PDF LayoutError must + # not masquerade as a complete hand-off). + detail = ( + "F5 hand-off report PARTIAL — PDF export failed (see logs); " + f"markdown+FHIR written -> {paths['markdown'].name}" + ) + return StageResult( + "F5", + "warn", + detail, + artifacts=artifacts, + duration_ms=_ms(t0), + ) detail = f"F5 hand-off report complete -> {paths['markdown'].name}" - return StageResult("F5", "pass", detail, artifacts=paths, duration_ms=_ms(t0)) + return StageResult( + "F5", + "pass", + detail, + artifacts=artifacts, + duration_ms=_ms(t0), + ) # ── F5 standalone replay CLI (`--f5-from-artifacts`) ───────────────────── @@ -1497,14 +1563,29 @@ def _run_f5_replay_cli(artifacts_dir: Path, out_dir: Path | None) -> int: write_base = out_dir.resolve() if out_dir else read_base try: - paths = _run_f5_report(persona_id, read_base, write_dir=write_base) + temporal_path = _find_latest_f5_temporal_artifact(persona_id, read_base) + if temporal_path is None: + raise FileNotFoundError( + f"F5: no F4 longitudinal output (*_temporal.json) found for {persona_id} " + f"under {resolved} — run F4 first before replay" + ) + paths = _run_f5_report( + persona_id, read_base, temporal_path, write_dir=write_base + ) except Exception as exc: # noqa: BLE001 — CLI must report, not traceback-dump print(f"F5 replay failed for persona={persona_id} (artifacts_dir={resolved}): {exc}") return 1 - print(f"F5 hand-off report complete for {persona_id}:") + pdf_ok = "pdf" in paths + status = "complete" if pdf_ok else "PARTIAL (PDF export failed — see logs)" + print(f"F5 hand-off report {status} for {persona_id}:") for label, path in paths.items(): print(f" {label}: {path}") + if not pdf_ok: + # md+FHIR written but the PDF is missing — signal partial export with + # a non-zero exit so callers never treat it as a clean success. + print(" WARNING: PDF artifact missing — markdown/FHIR written, PDF export failed") + return 2 return 0 @@ -1728,6 +1809,10 @@ def _build_single_session_ledger_entry( } +def _chain_return_code(results: list[StageResult]) -> int: + return stage_return_code(results) + + async def _main(args: argparse.Namespace) -> int: out_dir = Path(args.out) if args.out else None @@ -1775,7 +1860,7 @@ async def _main(args: argparse.Namespace) -> int: run_f4=run_f4, ) print_multi_session_report(args.persona, results) - return 0 if all(r.status != "fail" for r in results) else 1 + return _chain_return_code(results) ctx = ChainContext( persona_id=args.persona, @@ -1794,8 +1879,16 @@ async def _main(args: argparse.Namespace) -> int: return 1 ctx.conversation_path = path - results = await run_chain(ctx) - print_report(ctx, results) + # F4 AND F5 both read the session ledger, so BOTH are excluded from the + # in-chain traversal and invoked from the post-ledger path below: inside + # run_chain they would read a ledger that did not yet contain THIS + # session's entry — F5 got a stale/skipped hand-off report, and F4 a + # longitudinal window one session short of (and inconsistent with) F5's. + # This mirrors run_multi_session_chain, whose per-session ledger append + # precedes a single post-loop F4-then-F5 pass over the complete ledger. + results = await run_chain( + ctx, stages=[s for s in STAGE_REGISTRY if s.name not in ("F4", "F5")] + ) # Plan §6 item 5: single-session ledger gap fix. Only written when F1 # itself did not hard-fail (mirrors run_multi_session_chain's own @@ -1806,7 +1899,34 @@ async def _main(args: argparse.Namespace) -> int: ledger_path = _ledger_path(args.persona, out_dir) _append_ledger_entry(ledger_path, _build_single_session_ledger_entry(ctx, results)) - return 0 if all(r.status != "fail" for r in results) else 1 + # F4 then F5 over the SAME (now-complete) ledger snapshot. A prior hard + # failure skips both; F4's own outcome then gates F5 (F5's B-section + # consumes F4's longitudinal output verbatim), exactly as multi-session. + chain_failed = any(r.status == "fail" for r in results) + if chain_failed: + f4_result = StageResult("F4", "skip", "prior stage failed — F4 skipped (dependency)") + else: + f4_result = await run_f4_stage(ctx) + + if chain_failed: + f5_result = StageResult("F5", "skip", "prior stage failed — F5 skipped (dependency)") + elif f4_result.status != "pass": + f5_result = StageResult( + "F5", + "skip", + "F4 produced no fresh longitudinal output this run — F5 skipped (dependency not met)", + ) + else: + ctx.f4_temporal_path = f4_result.artifacts["json"] + f5_result = await run_f5_stage(ctx) + + # Preserve report order F1..F4, F5, F6 — insert the deferred F4+F5 just + # before F6 (or at the end when F6 is absent). + f6_index = next((i for i, r in enumerate(results) if r.name == "F6"), len(results)) + results[f6_index:f6_index] = [f4_result, f5_result] + + print_report(ctx, results) + return _chain_return_code(results) def main() -> int: diff --git a/apps/ai-server/src/f5.py b/apps/ai-server/src/f5.py index 1cc3a35..d431417 100644 --- a/apps/ai-server/src/f5.py +++ b/apps/ai-server/src/f5.py @@ -47,7 +47,6 @@ import re from dataclasses import dataclass, field from datetime import date as _date -from datetime import datetime from typing import Literal from src.grounding import reply_has_negation @@ -90,6 +89,8 @@ StalenessPointer, ) from src.schemas.longitudinal import LongitudinalAnalysisOutput +from src.services.f5_generation_time import parse_aware_iso_timestamp +from src.services.f5_narrative_guard import contains_candidate_disease # ── Harness -> production contract (design doc §4.1) ──────────────────── @@ -212,6 +213,7 @@ class HandoffReportInput: all_f3_administrations: tuple[F3Administration, ...] domain_inference: DomainInferenceSnapshot longitudinal: LongitudinalAnalysisOutput + generated_at: str chart_filenames: ChartFilenames = field(default_factory=ChartFilenames) # Task 1 — all-session slot maximization. Caller-sorted by non- # decreasing `session_index` (same non-re-validated discipline as @@ -229,6 +231,9 @@ class HandoffReportInput: # (non-empty) whenever `narrative_enabled=True`; ignored otherwise. narrative_text: str | None = None + def __post_init__(self) -> None: + object.__setattr__(self, "generated_at", parse_aware_iso_timestamp(self.generated_at)) + # ── Shared helpers ───────────────────────────────────────────────────── @@ -794,22 +799,22 @@ def _build_a8(inp: HandoffReportInput) -> NarrativeSection: whenever the caller does not opt in. Task 2 adds the OPT-IN path: when `narrative_enabled=True` (enforced non-empty `narrative_text`, `assemble_handoff_report` below), this function applies ONE - defense-in-depth check before rendering it — a plain substring scan of - every A6 candidate's `disease` name against the given text (HPI hard - red line, design doc §6.1 point 1). A match REFUSES the narrative + defense-in-depth check before rendering it — an NFKC+casefold + normalized, ASCII-token-boundary scan of every A6 candidate's `disease` + name against the given text (HPI hard red line, design doc §6.1 point 1). + A match REFUSES the narrative entirely (never silently strips/redacts the matched substring, which could leave a mangled sentence that still implies the missing content) — this function still never calls any LLM/agent itself - (module docstring's zero-LLM invariant is unaffected: this is a pure - string containment check over caller-supplied data).""" + (module docstring's zero-LLM invariant is unaffected: this is a pure, + boundary-aware containment check over caller-supplied data).""" if not inp.narrative_enabled: return NarrativeSection(narrative_enabled=False, text=None) text = (inp.narrative_text or "").strip() apd = inp.domain_inference.ai_predicted_disease candidate_diseases = [c.disease for c in (apd.candidates if apd else []) if c.disease] - leaked = [d for d in candidate_diseases if d in text] - if leaked: + if contains_candidate_disease(text, candidate_diseases): return NarrativeSection( narrative_enabled=False, text=None, absent_marker=NARRATIVE_REJECTED_DISEASE_LEAK_KO ) @@ -865,7 +870,7 @@ def assemble_handoff_report(inp: HandoffReportInput) -> HandoffReportOutput: return HandoffReportOutput( vp_id=inp.vp_id, - generated_at=datetime.now().isoformat(), + generated_at=inp.generated_at, a0_header=_build_header(inp), a1_chief_complaint=_build_a1(inp), a2_hpi=_build_a2(inp), diff --git a/apps/ai-server/src/main.py b/apps/ai-server/src/main.py index d9e12a2..ffb0299 100644 --- a/apps/ai-server/src/main.py +++ b/apps/ai-server/src/main.py @@ -30,7 +30,10 @@ import logging -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.exception_handlers import request_validation_exception_handler +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse from src import __version__ from src.routes.chat import router as chat_router @@ -53,6 +56,19 @@ description="Multi-agent AI service — Safety, Chat, Handoff (+ STT, OCR planned).", ) + +@app.exception_handler(RequestValidationError) +async def redact_handoff_request_validation( + request: Request, + exc: RequestValidationError, +) -> JSONResponse: + if request.url.path == "/ai/handoff/generate": + return JSONResponse( + status_code=422, + content={"detail": "Handoff request validation failed"}, + ) + return await request_validation_exception_handler(request, exc) + # ── Mount domain routers ────────────────────────────────────────────── app.include_router(safety_router) app.include_router(handoff_router) diff --git a/apps/ai-server/src/routes/handoff.py b/apps/ai-server/src/routes/handoff.py index 5100361..3e13987 100644 --- a/apps/ai-server/src/routes/handoff.py +++ b/apps/ai-server/src/routes/handoff.py @@ -19,24 +19,31 @@ import base64 import logging import tempfile -import uuid +from datetime import datetime from pathlib import Path +from typing import assert_never +from contracts.handoff import HandoffRequest, HandoffResponse from contracts.longitudinal import HandoffReportRequest, HandoffReportResponse from fastapi import APIRouter, Depends, HTTPException from src import f4, f5 -from src.agents.evidence_verifier import ( - EvidenceVerifierAgent, - EvidenceVerifierInput, - VerifierAction, +from src.agents.handoff_contract_generator import ( + HandoffContractGenerator, + HandoffContractValidationError, + HandoffProviderError, ) -from src.agents.handoff_generator import HandoffGeneratorAgent from src.dependencies import get_model_router, get_prompt_loader from src.prompts.loader import PromptLoader from src.routing.model_router import ModelRouter -from src.schemas.handoff import HandoffInput, HandoffOutput -from src.services.f5_report import build_fhir_bundle, build_markdown_report, build_pdf_report +from src.services.f5_pdf_boundary import PdfRendered, PdfRenderFailed, render_pdf +from src.services.f5_report import ( + build_fhir_bundle, + build_markdown_report, + build_pdf_report, + validate_fhir_bundle, +) +from src.services.handoff_contract_adapter import adapt_handoff_request from src.services.stateless_longitudinal import ( build_all_sessions, build_domain_inference_snapshot, @@ -55,104 +62,28 @@ # base64 inflates bytes by ~4/3, so this caps the PRE-encode PDF byte size. _PDF_SIZE_GUARD_BYTES = 10 * 1024 * 1024 -_MAX_REGENERATE_ATTEMPTS = 2 - - -def _get_handoff_agent( +def get_handoff_agent( model_router: ModelRouter = Depends(get_model_router), prompt_loader: PromptLoader = Depends(get_prompt_loader), -) -> HandoffGeneratorAgent: - return HandoffGeneratorAgent(model_router=model_router, prompt_loader=prompt_loader) - +) -> HandoffContractGenerator: + return HandoffContractGenerator(model_router=model_router, prompt_loader=prompt_loader) -def _get_verifier_agent() -> EvidenceVerifierAgent: - return EvidenceVerifierAgent() - -@router.post("/generate", response_model=HandoffOutput) +@router.post("/generate", response_model=HandoffResponse) async def generate( - body: HandoffInput, - handoff_agent: HandoffGeneratorAgent = Depends(_get_handoff_agent), - verifier: EvidenceVerifierAgent = Depends(_get_verifier_agent), -) -> HandoffOutput: - """Generate a handoff report, then verify evidence integrity. - - If the verifier says ``regenerate``, the report is regenerated up to - ``_MAX_REGENERATE_ATTEMPTS`` times. If it says ``reject``, a 422 is returned. - """ - if not body.request_id: - body.request_id = str(uuid.uuid4()) - - logger.info( - "Handoff generate request_id=%s session_id=%s", - body.request_id, - body.session_id, - ) - - last_result: HandoffOutput | None = None - - for attempt in range(1 + _MAX_REGENERATE_ATTEMPTS): - try: - result = await handoff_agent.run(body) - except Exception as exc: - logger.error("Handoff generation failed: %s", exc, exc_info=True) - raise HTTPException( - status_code=500, detail="Handoff report generation failed" - ) from exc - - last_result = result - - # Verify - verifier_input = EvidenceVerifierInput( - session_id=body.session_id, - request_id=body.request_id, - report_markdown=result.report_markdown, - evidence_packets=result.evidence_packets, - ) - - try: - verification = await verifier.run(verifier_input) - except Exception as exc: - logger.warning("Evidence verification failed, returning unverified: %s", exc) - break - - if verification.action == VerifierAction.passed: - logger.info( - "Handoff verified (attempt %d): latency=%.0fms", - attempt + 1, - result.latency_ms, - ) - return result - - if verification.action == VerifierAction.reject: - logger.warning( - "Handoff REJECTED: %d issues — %s", - len(verification.issues), - [i.description for i in verification.issues], - ) - raise HTTPException( - status_code=422, - detail={ - "message": "Handoff report rejected by evidence verifier", - "issues": [i.model_dump() for i in verification.issues], - }, - ) - - # regenerate - logger.info( - "Handoff needs regeneration (attempt %d/%d): %d issues", - attempt + 1, - 1 + _MAX_REGENERATE_ATTEMPTS, - len(verification.issues), - ) - - # Exhausted regeneration attempts — return the last result with a warning - if last_result: - last_result.requires_human_review = True - last_result.reason_summary += " [WARNING: verification issues remain after regeneration]" - return last_result - - raise HTTPException(status_code=500, detail="Handoff generation failed unexpectedly") + body: HandoffRequest, + handoff_agent: HandoffContractGenerator = Depends(get_handoff_agent), +) -> HandoffResponse: + """Generate the shared response contract and reject unverifiable citations.""" + local_input = adapt_handoff_request(body) + try: + return await handoff_agent.generate(body, local_input) + except HandoffContractValidationError as exc: + logger.warning("Handoff response rejected after validation retries") + raise HTTPException(status_code=422, detail="Handoff response validation failed") from exc + except HandoffProviderError as exc: + logger.error("Handoff response provider failed") + raise HTTPException(status_code=500, detail="Handoff report generation failed") from exc @router.post("/report", response_model=HandoffReportResponse) @@ -196,6 +127,7 @@ async def report(body: HandoffReportRequest) -> HandoffReportResponse: handoff_input = f5.HandoffReportInput( vp_id=body.vp_id, + generated_at=datetime.now().astimezone().isoformat(), session=build_session_snapshot(header), current_session_f3=current_f3, all_f3_administrations=all_f3, @@ -213,6 +145,22 @@ async def report(body: HandoffReportRequest) -> HandoffReportResponse: report_markdown = build_markdown_report(handoff_report) fhir_bundle = build_fhir_bundle(handoff_report) + # The FHIR validator reads externally shaped nested JSON; keep ordinary + # shape failures behind the same generic, PHI-free HTTP boundary. + try: + fhir_violations = validate_fhir_bundle(fhir_bundle) + except Exception: + fhir_violations = None + if fhir_violations is None or fhir_violations: + violation_count = 1 if fhir_violations is None else len(fhir_violations) + logger.error( + "Handoff report FHIR validation failed: violation_count=%d", + violation_count, + ) + raise HTTPException( + status_code=500, + detail="Handoff report FHIR validation failed", + ) from None pdf_base64: str | None = None pdf_omitted_reason: str | None = None @@ -229,14 +177,17 @@ async def report(body: HandoffReportRequest) -> HandoffReportResponse: path = tmp_dir / f"{key}.png" path.write_bytes(png) chart_paths[key] = path - try: - pdf_bytes = build_pdf_report(handoff_report, chart_paths) - except RuntimeError as exc: - # Missing/mismatched embedded Korean font asset (ADR-038 - # Decision 1 / BUG-044) — an honest 500, never a silently - # PDF-less response with no explanation. - logger.error("Handoff report PDF build failed: %s", exc) - raise HTTPException(status_code=500, detail=str(exc)) from exc + match render_pdf(build_pdf_report, handoff_report, chart_paths): + case PdfRendered(content=pdf_bytes): + pass + case PdfRenderFailed(): + logger.error("Handoff report PDF generation failed: pdf_status=failed") + raise HTTPException( + status_code=500, + detail="Handoff report PDF generation failed", + ) from None + case unreachable: + assert_never(unreachable) if len(pdf_bytes) > _PDF_SIZE_GUARD_BYTES: pdf_omitted_reason = ( @@ -245,8 +196,10 @@ async def report(body: HandoffReportRequest) -> HandoffReportResponse: "report_markdown/fhir_bundle are unaffected." ) logger.warning( - "Handoff report PDF omitted (size guard): vp_id=%s bytes=%d", - body.vp_id, len(pdf_bytes), + "Handoff report PDF omitted by response size guard: " + "pdf_status=omitted bytes=%d limit=%d", + len(pdf_bytes), + _PDF_SIZE_GUARD_BYTES, ) else: pdf_base64 = base64.b64encode(pdf_bytes).decode("ascii") diff --git a/apps/ai-server/src/routing/fallback_policy.py b/apps/ai-server/src/routing/fallback_policy.py index 6cf2b08..5ea1e12 100644 --- a/apps/ai-server/src/routing/fallback_policy.py +++ b/apps/ai-server/src/routing/fallback_policy.py @@ -15,8 +15,14 @@ _TRANSIENT_STATUS_CODES = {429, 500, 502, 503, 504} +class AdapterHealthFailure(Exception): + """Content-free failure whose occurrence counts toward adapter health.""" + + def is_transient(exc: Exception) -> bool: """Return True if the error is likely transient and worth retrying/falling back.""" + if isinstance(exc, AdapterHealthFailure): + return True if isinstance(exc, openai.RateLimitError): return True if isinstance(exc, openai.APIStatusError) and exc.status_code in _TRANSIENT_STATUS_CODES: diff --git a/apps/ai-server/src/routing/model_router.py b/apps/ai-server/src/routing/model_router.py index 3a292a4..a63c46e 100644 --- a/apps/ai-server/src/routing/model_router.py +++ b/apps/ai-server/src/routing/model_router.py @@ -143,10 +143,7 @@ def get_fallback( failed_adapter: str, reason: str, ) -> ModelSelection | None: - """Return the next-tier adapter after *failed_adapter*, or None if exhausted. - - Also notifies the fallback policy about the failure. - """ + """Return the next healthy tier after *failed_adapter*, or None if exhausted.""" agent_cfg = self._registry.get(agent_name) if agent_cfg is None or agent_cfg.get("strategy") != "benchmarked": return None diff --git a/apps/ai-server/src/schemas/handoff.py b/apps/ai-server/src/schemas/handoff.py index 908ae95..93ca95c 100644 --- a/apps/ai-server/src/schemas/handoff.py +++ b/apps/ai-server/src/schemas/handoff.py @@ -2,11 +2,89 @@ from __future__ import annotations -from pydantic import BaseModel, Field +from typing import Final, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic.json_schema import SkipJsonSchema from src.agents.base import AgentInput, AgentOutput from src.schemas.common import EvidencePacket, RiskLevel +RiskLevelLabel = Literal["none", "low", "medium", "high", "critical"] +CtrsLabel = Literal["1", "2", "3", "4", "5"] +type JsonScalar = str | int | float | bool | None + +_RISK_LABELS: Final[dict[str, RiskLevelLabel]] = { + "none": "none", + "low": "low", + "medium": "medium", + "high": "high", + "critical": "critical", +} +_CTRS_LABELS: Final[dict[str, CtrsLabel]] = { + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5", +} +_VALID_RISK_LABELS: Final = frozenset(_RISK_LABELS) +_VALID_CTRS_LABELS: Final = frozenset(_CTRS_LABELS) + + +class RiskEvent(BaseModel): + """A safety event in the local legacy handoff-agent input. + + This model is not the public ``POST /ai/handoff/generate`` request shape; + that boundary uses the shared ``HandoffRiskSignal`` contract. Local + severity labels remain strict (ISS-021): an invalid + ``risk_level``/``ctrs_level`` is rejected instead of being silently + floored to ``medium`` downstream. Extra legacy keys are preserved. An + event with no severity keys remains valid and floors to medium downstream + (issue #21). + """ + + model_config = ConfigDict(extra="allow") + + risk_level: RiskLevelLabel | SkipJsonSchema[None] = Field( + default_factory=lambda: None, + exclude_if=lambda value: value is None, + description="One of: none | low | medium | high | critical (omit the field if unknown)", + ) + ctrs_level: CtrsLabel | SkipJsonSchema[None] = Field( + default_factory=lambda: None, + exclude_if=lambda value: value is None, + description="Crisis Triage Rating Scale, ASCII digit '1'-'5' (omit the field if unknown)", + ) + + @field_validator("risk_level", mode="before") + @classmethod + def _normalize_risk_level(cls, value: JsonScalar) -> RiskLevelLabel: + if value is None: + # Explicit null is a malformed severity claim — reject. A genuinely + # ABSENT field never reaches this validator (pydantic skips + # validators for unset fields) and keeps the None default. + raise ValueError("risk_level must not be null — omit the field instead") + raw = str(value).strip().lower() + normalized = _RISK_LABELS.get(raw) + if normalized is None: + raise ValueError( + f"risk_level must be one of {sorted(_VALID_RISK_LABELS)}, got {value!r}" + ) + return normalized + + @field_validator("ctrs_level", mode="before") + @classmethod + def _normalize_ctrs_level(cls, value: JsonScalar) -> CtrsLabel: + if value is None: + raise ValueError("ctrs_level must not be null — omit the field instead") + raw = str(value).strip() + # ASCII-strict: rejects unicode digits ("١", "①"), out-of-range, non-digits. + normalized = _CTRS_LABELS.get(raw) + if normalized is None: + raise ValueError(f"ctrs_level must be an ASCII digit '1'-'5', got {value!r}") + return normalized + class SlotData(BaseModel): """Collected clinical slot data from the dialogue session. @@ -42,14 +120,15 @@ class ScaleScore(BaseModel): class HandoffInput(AgentInput): - """Input to the handoff generator.""" + """Local legacy input adapted from the public shared request contract.""" slots: SlotData = Field(default_factory=SlotData) conversation_history: list[dict[str, str]] = Field(default_factory=list) scale_scores: list[ScaleScore] = Field(default_factory=list) - risk_events: list[dict[str, str]] = Field( + risk_events: list[RiskEvent] = Field( default_factory=list, - description="Safety events during the session", + max_length=100, + description="Safety events during the session (severity labels strictly validated)", ) ocr_documents: list[dict[str, str]] = Field( default_factory=list, diff --git a/apps/ai-server/src/services/f5_artifact_cleanup.py b/apps/ai-server/src/services/f5_artifact_cleanup.py new file mode 100644 index 0000000..28508cf --- /dev/null +++ b/apps/ai-server/src/services/f5_artifact_cleanup.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Literal, override + +type ArtifactKey = Literal["markdown", "pdf", "fhir"] + + +@dataclass(frozen=True, slots=True) +class ArtifactCleanupError(OSError): + failure_count: int + + @override + def __str__(self) -> str: + return f"F5 artifact cleanup failed: failure_count={self.failure_count}" + + +@dataclass(frozen=True, slots=True) +class ReservedArtifact: + key: ArtifactKey + name: str + content: bytes + descriptor: int + + +def _unlink_first_pass(vp_descriptor: int, reserved: list[ReservedArtifact]) -> list[str]: + retry_names: list[str] = [] + for artifact in reserved: + try: + os.unlink(artifact.name, dir_fd=vp_descriptor) + except FileNotFoundError: + pass + except OSError: + retry_names.append(artifact.name) + return retry_names + + +def _retry_unlinks(vp_descriptor: int, names: list[str]) -> int: + failure_count = 0 + for name in names: + try: + os.unlink(name, dir_fd=vp_descriptor) + except FileNotFoundError: + pass + except OSError: + failure_count += 1 + return failure_count + + +def _close_all(reserved: list[ReservedArtifact]) -> int: + failure_count = 0 + for artifact in reserved: + try: + os.close(artifact.descriptor) + except OSError: + failure_count += 1 + return failure_count + + +def discard_reserved(vp_descriptor: int, reserved: list[ReservedArtifact]) -> None: + retry_names = _unlink_first_pass(vp_descriptor, reserved) + failure_count = _close_all(reserved) + failure_count += _retry_unlinks(vp_descriptor, retry_names) + if failure_count: + raise ArtifactCleanupError(failure_count) from None + + +def close_completed(vp_descriptor: int, reserved: list[ReservedArtifact]) -> None: + failure_count = _close_all(reserved) + if not failure_count: + return + retry_names = _unlink_first_pass(vp_descriptor, reserved) + failure_count += _retry_unlinks(vp_descriptor, retry_names) + raise ArtifactCleanupError(failure_count) from None + + +def discard_after_primary( + vp_descriptor: int, + reserved: list[ReservedArtifact], + primary: BaseException, +) -> None: + try: + discard_reserved(vp_descriptor, reserved) + except ArtifactCleanupError as cleanup_error: + primary.add_note( + f"F5 artifact cleanup failed: failure_count={cleanup_error.failure_count}" + ) diff --git a/apps/ai-server/src/services/f5_artifact_store.py b/apps/ai-server/src/services/f5_artifact_store.py new file mode 100644 index 0000000..cd27bae --- /dev/null +++ b/apps/ai-server/src/services/f5_artifact_store.py @@ -0,0 +1,165 @@ +"""POSIX-only, descriptor-relative persistence for one F5 artifact group. + +The caller-authorized root may itself be a symlink: it is resolved exactly +once before opening. VP children and artifact leaves are always opened relative +to retained directory descriptors with no-follow semantics. Each service-owned +VP namespace is mode 0700 and exclusively locked while names can change. +""" + +from __future__ import annotations + +import fcntl +import os +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Final, NotRequired, TypedDict, override + +from src.services.f5_artifact_cleanup import ( + ArtifactCleanupError, + ArtifactKey, + ReservedArtifact, + close_completed, + discard_after_primary, + discard_reserved, +) + +_MAX_GROUP_ATTEMPTS: Final = 100 +_DIRECTORY_FLAGS: Final = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC +_FILE_FLAGS: Final = os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW | os.O_CLOEXEC + + +@dataclass(frozen=True, slots=True) +class InvalidVpIdError(ValueError): + """The requested VP directory is not one safe relative component.""" + + @override + def __str__(self) -> str: + return "vp_id must be one nonempty relative path component" + + +@dataclass(frozen=True, slots=True) +class ArtifactCollisionError(FileExistsError): + """Every bounded artifact-group reservation attempt collided.""" + + attempts: int + + @override + def __str__(self) -> str: + return "unable to reserve a unique F5 artifact group" + + +@dataclass(frozen=True, slots=True) +class F5ArtifactBundle: + """Fully rendered bytes and identity for one atomic-name group.""" + + vp_id: str + timestamp: str + markdown: bytes + fhir: bytes + pdf: bytes | None + + +class F5ArtifactPaths(TypedDict): + markdown: Path + fhir: Path + pdf: NotRequired[Path] + + +def _validated_vp_id(raw: str) -> str: + separators = {"/", "\\", os.sep} + if os.altsep is not None: + separators.add(os.altsep) + if ( + not raw + or raw in {".", ".."} + or Path(raw).is_absolute() + or any(separator in raw for separator in separators) + ): + raise InvalidVpIdError + return raw + + +def _write_content(descriptor: int, content: bytes) -> None: + remaining = memoryview(content) + while remaining: + written = os.write(descriptor, remaining) + remaining = remaining[written:] + + +def _reserve_group(vp_descriptor: int, bundle: F5ArtifactBundle) -> list[ReservedArtifact]: + artifacts: list[tuple[ArtifactKey, str, bytes]] = [ + ("markdown", "handoff.md", bundle.markdown), + ("fhir", "handoff_fhir.json", bundle.fhir), + ] + if bundle.pdf is not None: + artifacts.insert(1, ("pdf", "handoff.pdf", bundle.pdf)) + for _attempt in range(_MAX_GROUP_ATTEMPTS): + prefix = f"{bundle.vp_id}_{bundle.timestamp}_{uuid.uuid4().hex}" + reserved: list[ReservedArtifact] = [] + try: + for key, suffix, content in artifacts: + name = f"{prefix}_{suffix}" + descriptor = os.open(name, _FILE_FLAGS, 0o600, dir_fd=vp_descriptor) + reserved.append(ReservedArtifact(key, name, content, descriptor)) + os.fchmod(descriptor, 0o600) + except FileExistsError: + try: + discard_reserved(vp_descriptor, reserved) + except ArtifactCleanupError: + raise + continue + except BaseException as primary: + discard_after_primary(vp_descriptor, reserved, primary) + raise + return reserved + raise ArtifactCollisionError(attempts=_MAX_GROUP_ATTEMPTS) + + +def _open_vp_directory(root_descriptor: int, vp_id: str) -> int: + try: + os.mkdir(vp_id, 0o700, dir_fd=root_descriptor) + except FileExistsError: + descriptor = os.open(vp_id, _DIRECTORY_FLAGS, dir_fd=root_descriptor) + else: + descriptor = os.open(vp_id, _DIRECTORY_FLAGS, dir_fd=root_descriptor) + try: + os.fchmod(descriptor, 0o700) + fcntl.flock(descriptor, fcntl.LOCK_EX) + except OSError: + os.close(descriptor) + raise + return descriptor + + +def persist_f5_artifacts(root: Path, bundle: F5ArtifactBundle) -> F5ArtifactPaths: + """Persist one collision-proof group beneath an authorized output root.""" + vp_id = _validated_vp_id(bundle.vp_id) + root.mkdir(parents=True, exist_ok=True) + authorized_root = root.resolve(strict=True) + root_descriptor = os.open(authorized_root, _DIRECTORY_FLAGS) + try: + vp_descriptor = _open_vp_directory(root_descriptor, vp_id) + try: + reserved = _reserve_group(vp_descriptor, bundle) + try: + for artifact in reserved: + _write_content(artifact.descriptor, artifact.content) + except BaseException as primary: + discard_after_primary(vp_descriptor, reserved, primary) + raise + close_completed(vp_descriptor, reserved) + finally: + os.close(vp_descriptor) + finally: + os.close(root_descriptor) + + output_dir = authorized_root / vp_id + names = {artifact.key: artifact.name for artifact in reserved} + paths = F5ArtifactPaths( + markdown=output_dir / names["markdown"], + fhir=output_dir / names["fhir"], + ) + if "pdf" in names: + paths["pdf"] = output_dir / names["pdf"] + return paths diff --git a/apps/ai-server/src/services/f5_fhir_safety.py b/apps/ai-server/src/services/f5_fhir_safety.py new file mode 100644 index 0000000..c3044e8 --- /dev/null +++ b/apps/ai-server/src/services/f5_fhir_safety.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from collections.abc import Sequence, Set +from dataclasses import dataclass +from typing import assert_never, override +from xml.etree import ElementTree +from xml.sax.saxutils import escape as xml_escape + +from src.services.f5_xml_text import sanitize_xml_10 + +type JsonValue = str | int | float | bool | None | Sequence[JsonValue] | dict[str, JsonValue] +type JsonObject = dict[str, JsonValue] + + +@dataclass(frozen=True, slots=True) +class InvalidFhirBundleError(RuntimeError): + violation_count: int + + @override + def __str__(self) -> str: + return f"F5 FHIR validation failed: violation_count={self.violation_count}" + + +def narrative_div(text: str) -> JsonObject: + return { + "status": "generated", + "div": ( + "
" + f"{xml_escape(sanitize_xml_10(text))}
" + ), + } + + +def _walk_fhir_json( + value: JsonValue, + path: str, + full_urls: Set[str | None], + violations: list[str], +) -> None: + if isinstance(value, dict): + reference = value.get("reference") + if ( + isinstance(reference, str) + and reference.startswith("urn:uuid:") + and reference not in full_urls + ): + violations.append(f"unresolved reference: {reference}") + div = value.get("div") + if isinstance(div, str): + try: + _ = ElementTree.fromstring(div) + except ElementTree.ParseError as error: + violations.append( + f"{path}: Narrative.div is not well-formed XHTML ({error})" + ) + for key, child in value.items(): + if child is None: + violations.append(f"{path}.{key}: null values are forbidden in FHIR JSON") + else: + _walk_fhir_json(child, f"{path}.{key}", full_urls, violations) + return + match value: + case None: + return + case str() | int() | float(): + return + case Sequence(): + for index, child in enumerate(value): + _walk_fhir_json(child, f"{path}[{index}]", full_urls, violations) + case unreachable: + assert_never(unreachable) + + +def fhir_structure_violations( + bundle: JsonObject, + full_urls: Set[str | None], +) -> list[str]: + violations: list[str] = [] + _walk_fhir_json(bundle, "bundle", full_urls, violations) + return violations diff --git a/apps/ai-server/src/services/f5_generation_time.py b/apps/ai-server/src/services/f5_generation_time.py new file mode 100644 index 0000000..0f1d702 --- /dev/null +++ b/apps/ai-server/src/services/f5_generation_time.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from datetime import datetime + + +class InvalidGeneratedAtError(ValueError): + __slots__: tuple[str, ...] = ("value",) + + value: str + + def __init__(self, value: str) -> None: + self.value = value + super().__init__("generated_at must be a valid timezone-aware ISO timestamp") + + +def parse_aware_iso_timestamp(value: str) -> str: + try: + parsed = datetime.fromisoformat(value) + except ValueError as exc: + raise InvalidGeneratedAtError(value) from exc + if parsed.utcoffset() is None: + raise InvalidGeneratedAtError(value) + return value diff --git a/apps/ai-server/src/services/f5_markdown.py b/apps/ai-server/src/services/f5_markdown.py new file mode 100644 index 0000000..db38ae1 --- /dev/null +++ b/apps/ai-server/src/services/f5_markdown.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import html +import re +from typing import Final + +_MARKDOWN_PUNCTUATION_RE: Final = re.compile(r"([\\`*_{}\[\]()#+\-.!|~=])") +_SAFE_CHART_FILENAME_RE: Final = re.compile(r"[A-Za-z0-9._-]+\Z") + + +def _literal(text: str, *, preserve_lines: bool) -> str: + normalized = text.replace("\r\n", "\n").replace("\r", "\n") + laid_out = normalized if preserve_lines else " ".join(normalized.split()) + html_safe = html.escape(laid_out, quote=False) + return _MARKDOWN_PUNCTUATION_RE.sub(r"\\\1", html_safe) + + +def inline_literal(text: str) -> str: + return _literal(text, preserve_lines=False) + + +def table_cell_literal(text: str) -> str: + return _literal(text, preserve_lines=False) + + +def block_literal(text: str) -> str: + return _literal(text, preserve_lines=True) + + +def plain_text(text: str) -> str: + return text + + +def is_safe_chart_filename(filename: str) -> bool: + return filename not in {".", ".."} and _SAFE_CHART_FILENAME_RE.fullmatch(filename) is not None diff --git a/apps/ai-server/src/services/f5_narrative_guard.py b/apps/ai-server/src/services/f5_narrative_guard.py new file mode 100644 index 0000000..634e57d --- /dev/null +++ b/apps/ai-server/src/services/f5_narrative_guard.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import re +import unicodedata +from collections.abc import Iterable + +_GREEK_OR_CYRILLIC_PREFIXES = ("GREEK", "CYRILLIC") + + +def _is_letter_or_number(character: str) -> bool: + return unicodedata.category(character)[0] in {"L", "N"} + + +def _without_interstitial_marks(text: str) -> str: + kept: list[str] = [] + index = 0 + while index < len(text): + if unicodedata.category(text[index])[0] != "M": + kept.append(text[index]) + index += 1 + continue + end = index + 1 + while end < len(text) and unicodedata.category(text[end])[0] == "M": + end += 1 + if not ( + index > 0 + and end < len(text) + and _is_letter_or_number(text[index - 1]) + and _is_letter_or_number(text[end]) + ): + kept.extend(text[index:end]) + index = end + return "".join(kept) + + +def _normalized_for_comparison(text: str) -> str: + without_interstitial_marks = _without_interstitial_marks(text) + normalized = unicodedata.normalize("NFKC", without_interstitial_marks) + return "".join( + character + for character in normalized.casefold() + if unicodedata.category(character) != "Cf" + ) + + +def _has_greek_or_cyrillic(text: str) -> bool: + return any( + unicodedata.name(character, "").startswith(_GREEK_OR_CYRILLIC_PREFIXES) + for character in text + ) + + +def _is_ascii_candidate(text: str) -> bool: + return text.isascii() and any(character.isalnum() for character in text) + + +def _contains_disease(normalized_text: str, disease: str) -> bool: + normalized_disease = _normalized_for_comparison(disease).strip() + if not normalized_disease: + return False + pattern = rf"(? bool: + normalized_text = _normalized_for_comparison(text) + has_confusable_script = _has_greek_or_cyrillic(normalized_text) + for disease in diseases: + normalized_disease = _normalized_for_comparison(disease).strip() + if has_confusable_script and _is_ascii_candidate(normalized_disease): + return True + if _contains_disease(normalized_text, normalized_disease): + return True + return False diff --git a/apps/ai-server/src/services/f5_pdf_boundary.py b/apps/ai-server/src/services/f5_pdf_boundary.py new file mode 100644 index 0000000..f6215d3 --- /dev/null +++ b/apps/ai-server/src/services/f5_pdf_boundary.py @@ -0,0 +1,41 @@ +"""Typed failure boundary around the external ReportLab PDF renderer.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +from src.schemas.handoff_report import HandoffReportOutput + +type PdfRenderer = Callable[[HandoffReportOutput, dict[str, Path]], bytes] + + +@dataclass(frozen=True, slots=True) +class PdfRendered: + content: bytes + + +@dataclass(frozen=True, slots=True) +class PdfRenderFailed: + """Renderer failed without retaining exception or clinical content.""" + + +type PdfRenderResult = PdfRendered | PdfRenderFailed + +# ReportLab can surface multiple Exception subclasses from its rendering stack. +# This is the sole catch-all policy boundary; BaseException subclasses propagate. +_RENDERER_FAILURES: Final[tuple[type[Exception], ...]] = (Exception,) + + +def render_pdf( + renderer: PdfRenderer, + report: HandoffReportOutput, + chart_paths: dict[str, Path], +) -> PdfRenderResult: + """Render a PDF without carrying renderer exception data across the boundary.""" + try: + return PdfRendered(content=renderer(report, chart_paths)) + except _RENDERER_FAILURES: + return PdfRenderFailed() diff --git a/apps/ai-server/src/services/f5_pdf_text.py b/apps/ai-server/src/services/f5_pdf_text.py new file mode 100644 index 0000000..3222a16 --- /dev/null +++ b/apps/ai-server/src/services/f5_pdf_text.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Final +from xml.sax.saxutils import escape as xml_escape + +from src.services.f5_xml_text import sanitize_xml_10 + +DEFAULT_PDF_PARAGRAPH_MAX_LINES: Final = 40 +_REPLACEMENT_MARKUP: Final = '\ufffd' + + +def pdf_paragraph_markup(text: str) -> str: + return xml_escape(sanitize_xml_10(text)).replace("\ufffd", _REPLACEMENT_MARKUP) + + +def collapse_blank_runs(text: str) -> list[str]: + collapsed: list[str] = [] + for line in text.split("\n"): + if not line.strip() and collapsed and not collapsed[-1].strip(): + continue + collapsed.append(line) + return collapsed + + +def pdf_line_chunks( + text: str, + max_lines: int = DEFAULT_PDF_PARAGRAPH_MAX_LINES, +) -> list[list[str]]: + lines = collapse_blank_runs(text) + if len(lines) <= max_lines: + return [lines] + return [lines[index : index + max_lines] for index in range(0, len(lines), max_lines)] diff --git a/apps/ai-server/src/services/f5_report.py b/apps/ai-server/src/services/f5_report.py index 9b64d30..bf161aa 100644 --- a/apps/ai-server/src/services/f5_report.py +++ b/apps/ai-server/src/services/f5_report.py @@ -24,8 +24,10 @@ import logging import re import uuid +from collections.abc import Callable from datetime import datetime from pathlib import Path +from typing import assert_never from src.f1 import OUTPUT_DIR from src.schemas.handoff_report import ( @@ -44,9 +46,54 @@ SlotOverviewSection, ) from src.schemas.longitudinal import LongitudinalAnalysisOutput +from src.services.f5_artifact_store import ( + F5ArtifactBundle, + F5ArtifactPaths, + persist_f5_artifacts, +) +from src.services.f5_fhir_safety import ( + InvalidFhirBundleError, + JsonObject, + fhir_structure_violations, +) +from src.services.f5_fhir_safety import ( + narrative_div as _div, +) +from src.services.f5_markdown import ( + block_literal, + inline_literal, + is_safe_chart_filename, + plain_text, + table_cell_literal, +) +from src.services.f5_pdf_boundary import PdfRendered, PdfRenderFailed, render_pdf +from src.services.f5_pdf_text import ( + DEFAULT_PDF_PARAGRAPH_MAX_LINES, + collapse_blank_runs, + pdf_line_chunks, + pdf_paragraph_markup, +) logger = logging.getLogger(__name__) +type _RenderScalar = str | int | float | bool | None + + +def _md_inline(text: _RenderScalar) -> str: + return inline_literal(str(text)) + + +def _md_block(text: str) -> str: + return block_literal(text) + + +def _md_cell(text: _RenderScalar) -> str: + return table_cell_literal(str(text)) + + +def _pdf_raw(text: _RenderScalar) -> str: + return plain_text(str(text)) + # ═══════════════════════════════════════════════════════════════════════ # 상세 부록 (detail appendix) + 시스템 참고 (internal-ref audit) collectors @@ -95,7 +142,7 @@ def save_f5_result( *, vp_id: str | None = None, chart_paths: dict[str, Path] | None = None, -) -> dict[str, Path]: +) -> F5ArtifactPaths: """Save F5 result as markdown + PDF + FHIR R4 document Bundle (design doc §5). Naming mirrors `save_f1_result`/.../`save_f4_result`: `__handoff.md` / `_handoff.pdf` / `_handoff_fhir.json`, under @@ -110,29 +157,55 @@ def save_f5_result( design doc §2.2 B5 row). The markdown/FHIR exporters use only the filenames already carried on `report.b_longitudinal.chart_filenames`. """ - resolved_vp_id = vp_id or report.vp_id + resolved_vp_id = report.vp_id if vp_id is None else vp_id base = output_dir or OUTPUT_DIR - out = base / resolved_vp_id - out.mkdir(parents=True, exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S") - prefix = f"{resolved_vp_id}_{ts}" - - paths: dict[str, Path] = {} - - md_path = out / f"{prefix}_handoff.md" - md_path.write_text(build_markdown_report(report), encoding="utf-8") - paths["markdown"] = md_path - - pdf_path = out / f"{prefix}_handoff.pdf" - pdf_path.write_bytes(build_pdf_report(report, chart_paths or {})) - paths["pdf"] = pdf_path - + markdown = build_markdown_report(report).encode() fhir_bundle = build_fhir_bundle(report) - fhir_path = out / f"{prefix}_handoff_fhir.json" - fhir_path.write_text(json.dumps(fhir_bundle, ensure_ascii=False, indent=2), encoding="utf-8") - paths["fhir"] = fhir_path + # This structural validator consumes nested external-format JSON; sanitize + # ordinary shape failures here without retaining clinical exception content. + try: + violations = validate_fhir_bundle(fhir_bundle) + except Exception: + violations = None + if violations is None or violations: + violation_count = 1 if violations is None else len(violations) + raise InvalidFhirBundleError(violation_count) from None + fhir = json.dumps(fhir_bundle, ensure_ascii=False, indent=2).encode() + pdf: bytes | None + match render_pdf(build_pdf_report, report, chart_paths or {}): + case PdfRendered(content=pdf): + pdf_status = "saved" + case PdfRenderFailed(): + logger.error( + "F5 PDF export failed: " + "pdf_status=failed markdown_status=continued fhir_status=continued" + ) + pdf = None + pdf_status = "failed" + case unreachable: + assert_never(unreachable) + + paths = persist_f5_artifacts( + base, + F5ArtifactBundle( + vp_id=resolved_vp_id, + timestamp=ts, + markdown=markdown, + fhir=fhir, + pdf=pdf, + ), + ) - logger.info("F5 results saved: %s", ", ".join(str(p) for p in paths.values())) + logger.info( + "F5 artifacts saved: artifact_count=%d markdown_bytes=%d fhir_bytes=%d " + "pdf_status=%s pdf_bytes=%d", + len(paths), + len(markdown), + len(fhir), + pdf_status, + 0 if pdf is None else len(pdf), + ) return paths @@ -296,18 +369,32 @@ def _dimension_ko(dimension: str) -> str: return _DIMENSION_KO.get(dimension, dimension) +_PDF_PARAGRAPH_MAX_LINES = DEFAULT_PDF_PARAGRAPH_MAX_LINES +_collapse_blank_runs = collapse_blank_runs + + +def _pdf_line_chunks(text: str, max_lines: int = _PDF_PARAGRAPH_MAX_LINES) -> list[list[str]]: + return pdf_line_chunks(text, max_lines) + + def _truncate( - text: str | None, limit: int = 80, *, appendix: _AppendixCollector, label: str + text: str | None, + limit: int = 80, + *, + appendix: _AppendixCollector, + label: str, + sanitize: Callable[[_RenderScalar], str] = _md_cell, ) -> str: - """Truncates *text* for a compact body cell/line — CVR-026 Finding 1 - (blocking): a truncated value's marker now points to a REAL, numbered - "상세 부록" (detail appendix) entry carrying the SAME full *text* - (never `"(상세 아래)"`, which pointed nowhere). `appendix`/`label` are - mandatory — every call site owns an `_AppendixCollector` for its - render pass.""" + r"""Truncates *text* for a compact body cell/line; the full text is anchored + into a numbered "상세 부록" (detail appendix) entry (CVR-026 Finding 1). + *sanitize* escapes the value for its target renderer: the Markdown default + (`_md_cell`) neutralizes table pipes/HTML; PDF callers pass `_pdf_raw` so + reportlab's own P() escapes it (avoiding literal ``<``/``\|`` double- + escape artifacts). Each renderer owns its `_AppendixCollector`, so the + anchored full text is stored in the form correct for that renderer.""" if not text: return "" - t = str(text) + t = sanitize(text) if len(t) <= limit: return t n = appendix.anchor(label, t) @@ -470,7 +557,12 @@ def _key_concerns( return concerns or ["특이 우려 사항 없음"] -def _summary_box_lines(report: HandoffReportOutput, appendix: _AppendixCollector) -> list[str]: +def _summary_box_lines( + report: HandoffReportOutput, + appendix: _AppendixCollector, + *, + sanitize: Callable[[_RenderScalar], str] = _md_cell, +) -> list[str]: """핵심 요약 (SBAR식, ≤8줄) — binding rule 2.""" a0, a1, a3, a5 = ( report.a0_header, @@ -485,7 +577,7 @@ def _summary_box_lines(report: HandoffReportOutput, appendix: _AppendixCollector f"({a0.simulated_date}), 총 {lon.n_sessions}세션{span}", "주호소: " + ( - _truncate(a1.text, 80, appendix=appendix, label="주호소 전문") + _truncate(a1.text, 80, appendix=appendix, label="주호소 전문", sanitize=sanitize) if a1.present else "미수집" ), @@ -502,14 +594,23 @@ def _summary_box_lines(report: HandoffReportOutput, appendix: _AppendixCollector ] -def _risk_prose(a3: RiskSafetySection, appendix: _AppendixCollector) -> str: +def _risk_prose( + a3: RiskSafetySection, + appendix: _AppendixCollector, + *, + sanitize: Callable[[_RenderScalar], str] = _md_cell, +) -> str: # CVR-026 Finding 1 (blocking, 최우선): the current-session risk # narrative's own patient-quote — a truncated, dead-referenced cut of # this exact quote was the single highest-severity finding. Full text # now always lands in the 상세 부록. risk_text = ( _truncate( - a3.risk_assessment_text, 100, appendix=appendix, label="위험평가 발화 원문 (당해 세션)" + a3.risk_assessment_text, + 100, + appendix=appendix, + label="위험평가 발화 원문 (당해 세션)", + sanitize=sanitize, ) if a3.risk_assessment_present else "정보 없음" @@ -530,16 +631,26 @@ def _risk_prose(a3: RiskSafetySection, appendix: _AppendixCollector) -> str: return " ".join(parts) -def _risk_discordance_verdict(discordance_note: str, appendix: _AppendixCollector) -> str: +def _risk_discordance_verdict( + discordance_note: str, + appendix: _AppendixCollector, + *, + sanitize: Callable[[_RenderScalar], str] = _md_cell, +) -> str: if discordance_note.startswith("불일치"): return "불일치" if discordance_note.startswith("일치"): return "일치" - return _truncate(discordance_note, 20, appendix=appendix, label="위험 신호 판정 원문") + return _truncate( + discordance_note, 20, appendix=appendix, label="위험 신호 판정 원문", sanitize=sanitize + ) def _risk_table_rows( - a3: RiskSafetySection, appendix: _AppendixCollector + a3: RiskSafetySection, + appendix: _AppendixCollector, + *, + sanitize: Callable[[_RenderScalar], str] = _md_cell, ) -> list[tuple[str, str, str, str, str]]: rows = [] for sig in a3.longitudinal_risk_signals: @@ -549,7 +660,7 @@ def _risk_table_rows( item9 = "음성" else: item9 = "미상" - verdict = _risk_discordance_verdict(sig.discordance_note, appendix) + verdict = _risk_discordance_verdict(sig.discordance_note, appendix, sanitize=sanitize) if sig.ceiling_caveat: verdict += " · 만점" score = f"{sig.total_score}/{sig.max_score}" if sig.total_score is not None else "-" @@ -599,12 +710,16 @@ def _absent_session_rows( def _full_risk_table_rows( - a3: RiskSafetySection, lon: LongitudinalAnalysisOutput, appendix: _AppendixCollector + a3: RiskSafetySection, + lon: LongitudinalAnalysisOutput, + appendix: _AppendixCollector, + *, + sanitize: Callable[[_RenderScalar], str] = _md_cell, ) -> list[tuple[str, str, str, str, str]]: """`_risk_table_rows` (flagged sessions) merged with `_absent_session_rows` (every remaining ledger session), sorted back into session order -- the table renders every session once, never a silent gap.""" - combined = _risk_table_rows(a3, appendix) + _absent_session_rows(a3, lon) + combined = _risk_table_rows(a3, appendix, sanitize=sanitize) + _absent_session_rows(a3, lon) return sorted(combined, key=lambda row: int(row[0])) @@ -636,13 +751,15 @@ def _ceiling_caveat_summary(a3: RiskSafetySection) -> str | None: return f"[세션 {session_str} — {range_label}] {caveat_text}" -def _mse_lines(a4: MentalStatusSection) -> list[str]: +def _mse_lines( + a4: MentalStatusSection, *, sanitize: Callable[[_RenderScalar], str] = _md_inline +) -> list[str]: assessable = [d for d in a4.domain_checklist if d.assessable] if not a4.present and not assessable: return ["텍스트 문진 특성상 관찰 기반 MSE는 평가 불가; 대화에서 도출된 소견 없음"] - lines = [f"{a4.label}: {a4.raw_text}"] if a4.present else [] + lines = [f"{sanitize(a4.label)}: {sanitize(a4.raw_text)}"] if a4.present else [] if assessable: - lines += [f"- {d.domain}: {d.note}" for d in assessable] + lines += [f"- {sanitize(d.domain)}: {sanitize(d.note)}" for d in assessable] elif a4.present: lines.append("개별 영역(mood/insight 등) 평가는 이 슬롯 특성상 불가") return lines @@ -863,11 +980,14 @@ def build_markdown_report(report: HandoffReportOutput) -> str: lon = b.analysis so = report.slot_overview - lines: list[str] = [f"# F5 인계 요약 보고서 — {report.vp_id}", ""] + lines: list[str] = [f"# F5 인계 요약 보고서 — {_md_inline(report.vp_id)}", ""] # ── 핵심 요약 (SBAR box) ── lines += ["> **핵심 요약**", ">"] - lines += [f"> - {line}" for line in _summary_box_lines(report, appendix)] + lines += [ + f"> - {_md_inline(line)}" + for line in _summary_box_lines(report, appendix, sanitize=_pdf_raw) + ] lines.append("") # ── 면책 조항 (3줄 이내 박스) ── @@ -876,8 +996,13 @@ def build_markdown_report(report: HandoffReportOutput) -> str: lines.append("") # ── 위험/안전 평가 ── - lines += ["## 위험/안전 평가", "", _risk_prose(a3, appendix), ""] - risk_rows = _risk_table_rows(a3, appendix) + lines += [ + "## 위험/안전 평가", + "", + _md_inline(_risk_prose(a3, appendix, sanitize=_pdf_raw)), + "", + ] + risk_rows = _risk_table_rows(a3, appendix, sanitize=_pdf_raw) # CVR-026 Finding 8 (minor): the exact-ceiling caveat previously # repeated verbatim BOTH adjacent to the risk table AND in the # staleness-pointer note directly below it (same fact, same session, @@ -894,8 +1019,11 @@ def build_markdown_report(report: HandoffReportOutput) -> str: "|---|---|---|---|---|", ] lines += [ - f"| {sid} | {date} | {score} | {item9} | {verdict} |" - for sid, date, score, item9, verdict in _full_risk_table_rows(a3, lon, appendix) + f"| {_md_cell(sid)} | {_md_cell(date)} | {_md_cell(score)} | " + f"{_md_cell(item9)} | {_md_cell(verdict)} |" + for sid, date, score, item9, verdict in _full_risk_table_rows( + a3, lon, appendix, sanitize=_pdf_raw + ) ] # ADR-038 Decision 2c / renderer polish (VP-004 review): full # ceiling caveat text still rendered adjacent to the table (never @@ -906,27 +1034,27 @@ def build_markdown_report(report: HandoffReportOutput) -> str: lines.append("") if ceiling_summary: ceiling_shown_in_a3 = True - lines.append(f"> {ceiling_summary}") + lines.append(f"> {_md_inline(ceiling_summary)}") lines.append("") else: lines += ["해당 없음 — 전체 세션 중 item-9 양성/안전 의뢰 이력이 없습니다.", ""] - lines += [f"> 최신 시행 척도 안내: {_staleness_note_ko(a3.staleness_pointer)}"] + lines += [f"> 최신 시행 척도 안내: {_md_inline(_staleness_note_ko(a3.staleness_pointer))}"] if a3.staleness_pointer.total_score is not None: lines.append(f"> {NON_VALIDATED_ADMINISTRATION_CAVEAT_KO}") if a3.staleness_pointer.ceiling_caveat: staleness_ceiling_text = ( _CEILING_POINTER_KO if ceiling_shown_in_a3 else a3.staleness_pointer.ceiling_caveat ) - lines.append(f"> {staleness_ceiling_text}") + lines.append(f"> {_md_inline(staleness_ceiling_text)}") lines.append("") # ── 주호소 및 현병력 (+ MSE) ── lines += [ "## 주호소 및 현병력", "", - f"**주호소**: {a1.text if a1.present else '미수집'}", + f"**주호소**: {_md_inline(a1.text) if a1.present else '미수집'}", "", - f"**현병력**: {a2.text if a2.present else '미수집'}", + f"**현병력**: {_md_inline(a2.text) if a2.present else '미수집'}", "", "### 정신상태검사 (MSE)", "", @@ -938,7 +1066,7 @@ def build_markdown_report(report: HandoffReportOutput) -> str: lines += [ "## 전체 세션 요약", "", - f"> {so.non_validated_caveat}", + f"> {_md_inline(so.non_validated_caveat)}", "", "| 슬롯 | 최신값 | 출처 | 변화 | 비고 |", "|---|---|---|---|---|", @@ -948,19 +1076,24 @@ def build_markdown_report(report: HandoffReportOutput) -> str: value_cell, source_cell = SLOT_NEVER_COLLECTED_KO, "-" else: value_cell = _truncate( - row.latest_value, 80, appendix=appendix, label=f"{row.label} 최신값 전문" + row.latest_value, + 80, + appendix=appendix, + label=f"{row.label} 최신값 전문", + sanitize=_pdf_raw, ) source_cell = f"{row.source_session_index}회차/{row.source_simulated_date}" lines.append( - f"| {row.label} | {value_cell} | {source_cell} | {_change_history_summary(row)} | " - f"{row.section_pointer or '-'} |" + f"| {_md_cell(row.label)} | {_md_cell(value_cell)} | {_md_cell(source_cell)} | " + f"{_md_cell(_change_history_summary(row))} | " + f"{_md_cell(row.section_pointer or '-')} |" ) lines.append("") lines.append("**주요 경과**") lines.append("") course_bullets = _major_course_bullets(so) if course_bullets: - lines += [f"- {b_}" for b_ in course_bullets] + lines += [f"- {_md_inline(b_)}" for b_ in course_bullets] else: lines.append("- 표시할 주요 경과 변화 없음") lines.append("") @@ -972,11 +1105,11 @@ def build_markdown_report(report: HandoffReportOutput) -> str: lines += ["정보 없음 (전체 세션 중 시행된 설문 없음)", ""] else: lines += [ - f"> {a5.non_validated_caveat}", + f"> {_md_inline(a5.non_validated_caveat)}", "", - f"**{a5.scale_name} {a5.total_score}/{a5.max_score} " - f"({_severity_ko(a5.severity)})** — {a5.administering_session_index}회차 " - f"({a5.administering_simulated_date})" + f"**{_md_inline(a5.scale_name)} {a5.total_score}/{a5.max_score} " + f"({_md_inline(_severity_ko(a5.severity))})** — {a5.administering_session_index}회차 " + f"({_md_inline(a5.administering_simulated_date)})" + (" [당해 세션 미시행, 직전 시행값]" if a5.is_stale_relative_to_header else ""), "", f"- 응답: {','.join(str(v) for v in a5.responses)}", @@ -988,31 +1121,35 @@ def build_markdown_report(report: HandoffReportOutput) -> str: if a5.critical_item_positive is False else "미상" ), - f"- 문진 방식: {a5.administration_mode or '미상'}", + f"- 문진 방식: {_md_inline(a5.administration_mode or '미상')}", ] if a5.threshold_caveat: - lines.append(f"- {a5.threshold_caveat}") + lines.append(f"- {_md_inline(a5.threshold_caveat)}") if a5.threshold_caveat_asymmetry_note: - lines.append(f"- {a5.threshold_caveat_asymmetry_note}") + lines.append(f"- {_md_inline(a5.threshold_caveat_asymmetry_note)}") if a5.ceiling_caveat: # A5 stays the canonical cross-section occurrence (ADR-038 # Decision 2c, locked by test) — never deduped away. - lines.append(f"- {a5.ceiling_caveat}") + lines.append(f"- {_md_inline(a5.ceiling_caveat)}") lines.append("") if a5.gap_disclosure: lines.append("**F3 공백 (당해 세션까지):**") lines.append("") if a5.gap_acuity_framing_note: - lines += [f"> {_strip_internal_refs(a5.gap_acuity_framing_note, notes)}", ""] + lines += [ + f"> {_md_inline(_strip_internal_refs(a5.gap_acuity_framing_note, notes))}", + "", + ] gap_framing_shown = True - lines += [f"- {_humanize_engine_text(g, notes)}" for g in a5.gap_disclosure] + lines += [f"- {_md_inline(_humanize_engine_text(g, notes))}" for g in a5.gap_disclosure] lines.append("") # ── 종단 추세 + 차트 ── lines += [ "## 종단 추세", "", - f"전체 방향: **{_DIRECTION_KO.get(lon.overall_direction, lon.overall_direction)}** " + "전체 방향: **" + f"{_md_inline(_DIRECTION_KO.get(lon.overall_direction, lon.overall_direction))}** " f"({lon.n_sessions}세션" + (f", {lon.session_span_days}일" if lon.session_span_days is not None else "") + ")", @@ -1023,11 +1160,16 @@ def build_markdown_report(report: HandoffReportOutput) -> str: "|---|---|---|", ] for dim, direction, evidence in _trend_table_rows(lon): - lines.append(f"| {dim} | {direction} | {evidence} |") + lines.append( + f"| {_md_cell(dim)} | {_md_cell(direction)} | {_md_cell(evidence)} |" + ) lines.append("") - lines += _events_and_concordance_lines( - lon, b, gap_framing_already_shown=gap_framing_shown, notes=notes - ) + lines += [ + _md_inline(line) + for line in _events_and_concordance_lines( + lon, b, gap_framing_already_shown=gap_framing_shown, notes=notes + ) + ] lines.append("") lines.append("### 추세 차트") lines.append("") @@ -1040,7 +1182,7 @@ def build_markdown_report(report: HandoffReportOutput) -> str: any_chart = False fig_no = 0 for key, filename in chart_map.items(): - if filename: + if filename and is_safe_chart_filename(filename): any_chart = True fig_no += 1 lines.append(f"![{key}]({filename})") @@ -1061,11 +1203,11 @@ def build_markdown_report(report: HandoffReportOutput) -> str: "", ] if not a6.present: - lines += [_none_marker(a6.no_data_note, "정보 없음"), ""] + lines += [_md_inline(_none_marker(a6.no_data_note, "정보 없음")), ""] if a6.mode: - lines.append(f"mode: {a6.mode}") + lines.append(f"mode: {_md_inline(a6.mode)}") if a6.reason_summary: - lines += ["", f"사유: {_a6_reason_summary_ko(a6.reason_summary, notes)}"] + lines += ["", f"사유: {_md_inline(_a6_reason_summary_ko(a6.reason_summary, notes))}"] lines.append("") else: top, rest = a6.candidates[:3], a6.candidates[3:] @@ -1073,19 +1215,27 @@ def build_markdown_report(report: HandoffReportOutput) -> str: for rc in top: rank_cell = rc.tie_marker or str(rc.rank) score = f"{rc.candidate.similarity_score:.3f}" - lines.append(f"| {rank_cell} | {rc.candidate.disease} | {score} |") + lines.append( + f"| {_md_cell(rank_cell)} | {_md_cell(rc.candidate.disease)} | " + f"{_md_cell(score)} |" + ) lines.append("") if rest: rest_txt = ", ".join( - f"{rc.candidate.disease}({rc.candidate.similarity_score:.3f})" for rc in rest + f"{_md_inline(rc.candidate.disease)}({rc.candidate.similarity_score:.3f})" + for rc in rest ) lines.append(f"기타 후보: {rest_txt}") lines.append("") - lines.append(f"> {a6.disclaimer}") + lines.append(f"> {_md_inline(a6.disclaimer)}") if a6.recommended_questionnaire: lines.append( - f"> 추천 설문: {a6.recommended_questionnaire}" - + (f" — {a6.recommendation_caveat}" if a6.recommendation_caveat else "") + f"> 추천 설문: {_md_inline(a6.recommended_questionnaire)}" + + ( + f" — {_md_inline(a6.recommendation_caveat)}" + if a6.recommendation_caveat + else "" + ) ) lines.append("") @@ -1093,58 +1243,68 @@ def build_markdown_report(report: HandoffReportOutput) -> str: lines += ["## 권장 진료과 및 후속 조치", ""] if a7.department_candidates: lines += ["| 진료과 | 사유 |", "|---|---|"] - lines += [f"| {d.department} | {d.reason} |" for d in a7.department_candidates] + lines += [ + f"| {_md_cell(d.department)} | {_md_cell(d.reason)} |" + for d in a7.department_candidates + ] lines.append("") else: - lines += [_a7_absence_note_ko(a7, notes), ""] + lines += [_md_inline(_a7_absence_note_ko(a7, notes)), ""] if a7.recommended_questionnaire: lines.append( - f"추천 설문: {a7.recommended_questionnaire}" - + (f" — {a7.recommendation_caveat}" if a7.recommendation_caveat else "") + f"추천 설문: {_md_inline(a7.recommended_questionnaire)}" + + ( + f" — {_md_inline(a7.recommendation_caveat)}" + if a7.recommendation_caveat + else "" + ) ) lines.append("") - lines += [f"> {a7.medication_note}", ""] + lines += [f"> {_md_inline(a7.medication_note)}", ""] # ── 임상 종합 소견 (own top-level section — same structural-separation # discipline as AI 참고 정보/A6, never nested inside another section) ── lines += ["## 임상 종합 소견", ""] if a8.narrative_enabled and a8.text: - lines += [f"> {NARRATIVE_ENABLED_LABEL_KO}", "", a8.text, ""] + lines += [f"> {NARRATIVE_ENABLED_LABEL_KO}", "", _md_block(a8.text), ""] else: - lines += [a8.absent_marker, ""] + lines += [_md_inline(a8.absent_marker), ""] # ── 상세 부록 (CVR-026 Finding 1/2/3) ── lines += ["## 상세 부록", ""] if appendix.entries: for n, label, text in appendix.entries: - lines += [f"**{n}. {label}**", "", text, ""] + lines += [f"**{n}. {_md_inline(label)}**", "", _md_block(text), ""] slot_history_sections = [ - (row.label, " → ".join(row.change_history_full)) + (row.label, " → ".join(_md_inline(v) for v in row.change_history_full)) for row in _slot_table_rows(so) if len(row.change_history_full) >= 2 ] if slot_history_sections: lines += ["### 슬롯별 전체 변화 이력 (미압축)", ""] for label, text in slot_history_sections: - lines += [f"**{label}**", "", text, ""] + lines += [f"**{_md_inline(label)}**", "", text, ""] if not appendix.entries and not slot_history_sections: lines += ["해당 없음 — 본문에서 잘린 항목이 없습니다.", ""] # ── 각주 (감사용 메타) ── lines += ["## 각주", ""] - footnote = f"모델: {a0.model} · 생성 시각: {report.generated_at} · 세션ID: {a0.session_id}" + footnote = ( + f"모델: {_md_inline(a0.model)} · 생성 시각: {_md_inline(report.generated_at)} · " + f"세션ID: {_md_inline(a0.session_id)}" + ) lines.append(footnote) if a5.present: - lines.append(f"설문 문항 출처: {a5.item_bank_provenance or '미상'}") + lines.append(f"설문 문항 출처: {_md_inline(a5.item_bank_provenance or '미상')}") lines.append("") # CVR-026 Finding 5/7: internal review/bug-ID citations + code-path # audit refs live ONLY here, separated from the clinical footnote line # above (Finding 7's own recommendation) — never in the scannable body. lines.append("### 시스템 참고 (내부 감사용, 임상 판단 근거 아님)") lines.append("") - lines.append(f"종단 추세 판정 근거: {b.overall_direction_sensitivity_note}") + lines.append(f"종단 추세 판정 근거: {_md_block(b.overall_direction_sensitivity_note)}") for note in notes.notes: - lines.append(note) + lines.append(_md_block(note)) lines.append("") return "\n".join(lines) @@ -1342,8 +1502,19 @@ def build_pdf_report( } def P(text: str, style: str = "body") -> Paragraph: - safe = (text or "").replace("&", "&").replace("<", "<").replace(">", ">") - return Paragraph(safe.replace("\n", "
"), styles[style]) + safe = pdf_paragraph_markup(text or "") + # Collapse blank-line runs so newline-dense values don't explode into an + # unbounded
run (reportlab LayoutError). Content is NEVER dropped + # here — genuinely long free text is paginated across flowables by + # `_paras` at its (main-story) call sites, not truncated. + return Paragraph("
".join(_collapse_blank_runs(safe)), styles[style]) + + def _paras(text: str, style: str = "body") -> list[Paragraph]: + # Long free-text fields (주호소/현병력/A8 narrative/상세 부록) as a + # SEQUENCE of Paragraphs — reportlab page-breaks BETWEEN chunks, so no + # single oversized flowable can LayoutError and no line is ever dropped. + safe = pdf_paragraph_markup(text or "") + return [Paragraph("
".join(chunk), styles[style]) for chunk in _pdf_line_chunks(safe)] def _table(rows: list[list[str]], font_size: float = 7.5) -> Table: t = Table(rows, hAlign="LEFT") @@ -1398,7 +1569,10 @@ def _boxed(flow: list, border: str = "#4A6FA5", fill: str = "#F1F5FB") -> Table: # ── 핵심 요약 (SBAR box) ── summary_flow = [P("핵심 요약", "h3")] - summary_flow += [P(f"- {line}", "body") for line in _summary_box_lines(report, appendix)] + summary_flow += [ + P(f"- {line}", "body") + for line in _summary_box_lines(report, appendix, sanitize=_pdf_raw) + ] story.append(_boxed(summary_flow)) story.append(Spacer(1, 0.2 * cm)) @@ -1410,13 +1584,13 @@ def _boxed(flow: list, border: str = "#4A6FA5", fill: str = "#F1F5FB") -> Table: # ── 위험/안전 평가 ── story.append(P("위험/안전 평가", "h2")) - story.append(P(_risk_prose(a3, appendix), "body")) - risk_rows = _risk_table_rows(a3, appendix) + story.append(P(_risk_prose(a3, appendix, sanitize=_pdf_raw), "body")) + risk_rows = _risk_table_rows(a3, appendix, sanitize=_pdf_raw) ceiling_shown_in_a3 = False if risk_rows: story.append(P(NON_VALIDATED_ADMINISTRATION_CAVEAT_KO, "warn")) rows = [["세션", "일자", "점수", "9번 문항", "판정"]] + [ - list(r) for r in _full_risk_table_rows(a3, lon, appendix) + list(r) for r in _full_risk_table_rows(a3, lon, appendix, sanitize=_pdf_raw) ] story.append(_table(rows)) # ADR-038 Decision 2c / renderer polish (VP-004 review): full @@ -1443,10 +1617,10 @@ def _boxed(flow: list, border: str = "#4A6FA5", fill: str = "#F1F5FB") -> Table: # ── 주호소 및 현병력 (+ MSE) ── story.append(P("주호소 및 현병력", "h2")) - story.append(P(f"주호소: {a1.text if a1.present else '미수집'}", "body")) - story.append(P(f"현병력: {a2.text if a2.present else '미수집'}", "body")) + story.extend(_paras(f"주호소: {a1.text if a1.present else '미수집'}", "body")) + story.extend(_paras(f"현병력: {a2.text if a2.present else '미수집'}", "body")) story.append(P("정신상태검사 (MSE)", "h3")) - for line in _mse_lines(a4): + for line in _mse_lines(a4, sanitize=_pdf_raw): story.append(P(line, "body")) # ── 전체 세션 요약 (슬롯) + 주요 경과 ── @@ -1458,7 +1632,11 @@ def _boxed(flow: list, border: str = "#4A6FA5", fill: str = "#F1F5FB") -> Table: value_cell, source_cell = SLOT_NEVER_COLLECTED_KO, "-" else: value_cell = _truncate( - row.latest_value, 80, appendix=appendix, label=f"{row.label} 최신값 전문" + row.latest_value, + 80, + appendix=appendix, + label=f"{row.label} 최신값 전문", + sanitize=_pdf_raw, ) source_cell = f"{row.source_session_index}회차/{row.source_simulated_date}" rows.append( @@ -1620,7 +1798,7 @@ def _boxed(flow: list, border: str = "#4A6FA5", fill: str = "#F1F5FB") -> Table: story.append(P("임상 종합 소견", "h2")) if a8.narrative_enabled and a8.text: story.append(P(NARRATIVE_ENABLED_LABEL_KO, "meta")) - story.append(P(a8.text, "body")) + story.extend(_paras(a8.text, "body")) else: story.append(P(a8.absent_marker, "body")) @@ -1630,7 +1808,7 @@ def _boxed(flow: list, border: str = "#4A6FA5", fill: str = "#F1F5FB") -> Table: if appendix.entries: for n, label, full_text in appendix.entries: story.append(P(f"{n}. {label}", "h3")) - story.append(P(full_text, "body")) + story.extend(_paras(full_text, "body")) slot_history_sections = [ (row.label, " → ".join(row.change_history_full)) for row in _slot_table_rows(so) @@ -1640,7 +1818,7 @@ def _boxed(flow: list, border: str = "#4A6FA5", fill: str = "#F1F5FB") -> Table: story.append(P("슬롯별 전체 변화 이력 (미압축)", "h3")) for label, full_text in slot_history_sections: story.append(P(label, "meta")) - story.append(P(full_text, "body")) + story.extend(_paras(full_text, "body")) if not appendix.entries and not slot_history_sections: story.append(P("해당 없음 — 본문에서 잘린 항목이 없습니다.", "body")) @@ -1658,17 +1836,17 @@ def _boxed(flow: list, border: str = "#4A6FA5", fill: str = "#F1F5FB") -> Table: for note in notes.notes: story.append(P(note, "meta")) - buf = io.BytesIO() - doc = SimpleDocTemplate( - buf, - pagesize=A4, - topMargin=1.5 * cm, - bottomMargin=1.5 * cm, - leftMargin=1.5 * cm, - rightMargin=1.5 * cm, - ) - doc.build(story) - return buf.getvalue() + with io.BytesIO() as buf: + doc = SimpleDocTemplate( + buf, + pagesize=A4, + topMargin=1.5 * cm, + bottomMargin=1.5 * cm, + leftMargin=1.5 * cm, + rightMargin=1.5 * cm, + ) + doc.build(story) + return buf.getvalue() # ═══════════════════════════════════════════════════════════════════════ @@ -1704,26 +1882,20 @@ def _boxed(flow: list, border: str = "#4A6FA5", fill: str = "#F1F5FB") -> Table: } -def _new_entry(resource: dict) -> tuple[str, dict]: +def _new_entry(resource: JsonObject) -> tuple[str, JsonObject]: full_url = f"urn:uuid:{uuid.uuid4()}" return full_url, {"fullUrl": full_url, "resource": resource} -def _loinc_concept(key: str, text: str) -> dict: +def _loinc_concept(key: str, text: str) -> JsonObject: return {"coding": [{"system": "http://loinc.org", "code": _LOINC[key]}], "text": text} -def _local_concept(code: str, text: str) -> dict: +def _local_concept(code: str, text: str) -> JsonObject: return {"coding": [{"system": _LOCAL_CODE_SYSTEM, "code": code}], "text": text} -def _div(text: str) -> dict: - """`Narrative` (status=generated) wrapping free text in the required - xhtml div — used for every `Composition.section.text` below.""" - return {"status": "generated", "div": f"
{text}
"} - - -def build_fhir_bundle(report: HandoffReportOutput) -> dict: +def build_fhir_bundle(report: HandoffReportOutput) -> JsonObject: """R4 `Bundle(type="document")`, `Composition` first entry (design doc §5.3). File-export only — no `$validate` call, no server round-trip (D3). Structural validity only — see `validate_fhir_bundle` and the @@ -1743,10 +1915,10 @@ def build_fhir_bundle(report: HandoffReportOutput) -> dict: b = report.b_longitudinal lon = b.analysis - entries: list[dict] = [] + entries: list[JsonObject] = [] full_urls: dict[str, str] = {} - def add(key: str, resource: dict) -> str: + def add(key: str, resource: JsonObject) -> str: full_url, entry = _new_entry(resource) entries.append(entry) full_urls[key] = full_url @@ -1757,7 +1929,7 @@ def add(key: str, resource: dict) -> str: "patient", { "resourceType": "Patient", - "id": a0.persona_id, + "id": a0.persona_id or report.vp_id, "meta": { "tag": [{"system": "urn:neurosync:simulation-flag", "code": "simulated-patient"}] }, @@ -1766,7 +1938,7 @@ def add(key: str, resource: dict) -> str: }, ) - sections: list[dict] = [] + sections: list[JsonObject] = [] # ── A1 CC ── sections.append( @@ -1828,7 +2000,24 @@ def add(key: str, resource: dict) -> str: "code": _local_concept("ctrs", "Crisis Triage Rating Scale (session_ctrs, local)"), "subject": {"reference": patient_url}, "effectiveDateTime": a3.current_simulated_date, - "valueInteger": a3.session_ctrs, + # FHIR JSON forbids null primitives — absent CTRS becomes + # dataAbsentReason below instead of "valueInteger": null. + **( + {"valueInteger": a3.session_ctrs} + if a3.session_ctrs is not None + else { + "dataAbsentReason": { + "coding": [ + { + "system": ( + "http://terminology.hl7.org/CodeSystem/data-absent-reason" + ), + "code": "unknown", + } + ] + } + } + ), "note": [ { "text": ( @@ -1934,7 +2123,7 @@ def add(key: str, resource: dict) -> str: ) # ── A5 Questionnaires: QuestionnaireResponse + Observation(total) ── - a5_entries: list[dict] = [] + a5_entries: list[JsonObject] = [] if a5.present: qr_url = add( "questionnaire_response", @@ -1953,7 +2142,7 @@ def add(key: str, resource: dict) -> str: ) a5_entries.append({"reference": qr_url}) total_code = _SCALE_TOTAL_LOINC.get(a5.scale_name or "") - obs_code = ( + obs_code: JsonObject = ( { "coding": [{"system": "http://loinc.org", "code": total_code}], "text": f"{a5.scale_name} total", @@ -1961,7 +2150,7 @@ def add(key: str, resource: dict) -> str: if total_code else _local_concept("scale-total", f"{a5.scale_name} total") ) - notes = [{"text": a5.non_validated_caveat}] + notes: list[JsonObject] = [{"text": a5.non_validated_caveat}] if a5.threshold_caveat: notes.append({"text": a5.threshold_caveat}) if a5.threshold_caveat_asymmetry_note: @@ -2010,9 +2199,9 @@ def add(key: str, resource: dict) -> str: ) # ── A6 AI-predicted-disease (hard red line — own section/resource) ── - a6_entries: list[dict] = [] + a6_entries: list[JsonObject] = [] if a6.present: - components = [ + components: list[JsonObject] = [ { "code": {"text": rc.candidate.disease}, "valueQuantity": {"value": rc.candidate.similarity_score, "unit": "similarity"}, @@ -2069,7 +2258,7 @@ def add(key: str, resource: dict) -> str: ) # ── A7 Department recommendation: ServiceRequest ── - a7_entries: list[dict] = [] + a7_entries: list[JsonObject] = [] for d in a7.department_candidates: sr_url = add( f"service_request_{len(a7_entries)}", @@ -2099,10 +2288,10 @@ def add(key: str, resource: dict) -> str: ) # ── B1-B3: repeated Observation per scale_series/ctrs_series point ── - b_entries: list[dict] = [] + b_entries: list[JsonObject] = [] for scale_name, points in lon.scale_series.items(): total_code = _SCALE_TOTAL_LOINC.get(scale_name) - code = ( + code: JsonObject = ( { "coding": [{"system": "http://loinc.org", "code": total_code}], "text": f"{scale_name} total", @@ -2215,7 +2404,7 @@ def add(key: str, resource: dict) -> str: } ) - composition = { + composition: JsonObject = { "resourceType": "Composition", "id": str(uuid.uuid4()), "status": "final", @@ -2241,7 +2430,7 @@ def add(key: str, resource: dict) -> str: } -def validate_fhir_bundle(bundle: dict) -> list[str]: +def _validate_fhir_bundle(bundle: dict) -> list[str]: """Structural self-check only (design doc §5.3 last paragraph) — NEVER an HL7 `$validate` call (D3). Returns a list of violation strings (empty = structurally OK per this project's own checks). Checks: @@ -2291,18 +2480,7 @@ def validate_fhir_bundle(bundle: dict) -> list[str]: f"{rtype} (fullUrl={e.get('fullUrl')}) missing required field '{f}'" ) - def _walk(obj: object) -> None: - if isinstance(obj, dict): - ref = obj.get("reference") - if isinstance(ref, str) and ref.startswith("urn:uuid:") and ref not in full_url_set: - violations.append(f"unresolved reference: {ref}") - for v in obj.values(): - _walk(v) - elif isinstance(obj, list): - for v in obj: - _walk(v) - - _walk(bundle) + violations.extend(fhir_structure_violations(bundle, full_url_set)) for sec in first.get("section", []): if not sec.get("title"): @@ -2321,3 +2499,7 @@ def _walk(obj: object) -> None: ) return violations + + +def validate_fhir_bundle(bundle: JsonObject) -> list[str]: + return _validate_fhir_bundle(bundle) diff --git a/apps/ai-server/src/services/f5_stage_status.py b/apps/ai-server/src/services/f5_stage_status.py new file mode 100644 index 0000000..ec379db --- /dev/null +++ b/apps/ai-server/src/services/f5_stage_status.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Protocol + + +class StageStatus(Protocol): + name: str + status: str + + +def stage_return_code(results: Sequence[StageStatus]) -> int: + if any(result.status == "fail" for result in results): + return 1 + if any(result.name == "F5" and result.status == "warn" for result in results): + return 2 + return 0 diff --git a/apps/ai-server/src/services/f5_xml_text.py b/apps/ai-server/src/services/f5_xml_text.py new file mode 100644 index 0000000..f8e4332 --- /dev/null +++ b/apps/ai-server/src/services/f5_xml_text.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Final + +_REPLACEMENT_CHARACTER: Final = "\ufffd" + + +def is_xml_10_character(character: str) -> bool: + code_point = ord(character) + return ( + character in "\t\n\r" + or 0x20 <= code_point <= 0xD7FF + or 0xE000 <= code_point <= 0xFFFD + or 0x10000 <= code_point <= 0x10FFFF + ) + + +def sanitize_xml_10(text: str) -> str: + """Replace each XML 1.0-invalid scalar one-for-one with U+FFFD.""" + return "".join( + character if is_xml_10_character(character) else _REPLACEMENT_CHARACTER + for character in text + ) diff --git a/apps/ai-server/src/services/handoff_claim_guard.py b/apps/ai-server/src/services/handoff_claim_guard.py new file mode 100644 index 0000000..07d02fb --- /dev/null +++ b/apps/ai-server/src/services/handoff_claim_guard.py @@ -0,0 +1,133 @@ +"""Pure extractive-grounding checks for official handoff claims.""" + +from __future__ import annotations + +import re +from typing import Final, Protocol + +from contracts.handoff import Citation, HandoffRequest + +_DIRECT_DIAGNOSIS_PATTERNS: Final = ( + re.compile( + r"(?:우울증|조현병|불안장애)\s*(?:입니다|이다)", + re.IGNORECASE, + ), + re.compile( + r"\S+(?:으)?로\s*(?:판단|진단)(?:합니다|됩니다|내립니다)", + re.IGNORECASE, + ), + re.compile(r"(?:진단|확진)(?:을)?\s*(?:합니다|내립니다|됩니다)", re.IGNORECASE), + re.compile(r"\b(?:patient|you)\s+(?:has|have)\s+\w+", re.IGNORECASE), + re.compile(r"\bdiagnose\s+(?:the\s+)?patient\b", re.IGNORECASE), + re.compile(r"\b(?:the\s+)?diagnosis\s+(?:is|:)\s*\w+", re.IGNORECASE), +) +_DIRECT_TREATMENT_PATTERNS: Final = ( + re.compile( + r"(?:복용|투약|투여|처방)(?:을)?\s*(?:하세요|하십시오|해야\s*합니다|권고합니다)", + re.IGNORECASE, + ), + re.compile( + r"(?:시작|중단|증량|감량|변경)(?:을)?\s*(?:하세요|하십시오|해야\s*합니다)", + re.IGNORECASE, + ), + re.compile( + r"\b(?:prescribe|start taking|stop taking|increase|decrease)\b", + re.IGNORECASE, + ), +) + + +class SleepAppetiteActivityClaims(Protocol): + """Nested extractive claim fields consumed by the guard.""" + + sleep: str | None + appetite: str | None + activity: str | None + + +class HandoffClaims(Protocol): + """Structural claim view shared by the provider draft and guard.""" + + chief_complaint: str + present_illness: str + symptoms: list[str] + onset: str | None + recent_changes: str | None + triggers: list[str] + @property + def sleep_appetite_activity(self) -> SleepAppetiteActivityClaims: ... + psych_history: str | None + medications: str | None + documents_summary: list[str] + clinician_attention: list[str] + evidence: list[Citation] + + +def _populated_targets(claims: HandoffClaims) -> dict[str, str]: + targets = { + field: value + for field, value in ( + ("chief_complaint", claims.chief_complaint), + ("present_illness", claims.present_illness), + ("onset", claims.onset), + ("recent_changes", claims.recent_changes), + ("psych_history", claims.psych_history), + ("medications", claims.medications), + ("sleep_appetite_activity.sleep", claims.sleep_appetite_activity.sleep), + ("sleep_appetite_activity.appetite", claims.sleep_appetite_activity.appetite), + ("sleep_appetite_activity.activity", claims.sleep_appetite_activity.activity), + ) + if value is not None and value.strip() + } + for field, values in ( + ("symptoms", claims.symptoms), + ("triggers", claims.triggers), + ("clinician_attention", claims.clinician_attention), + ): + targets.update( + {f"{field}[{index}]": value for index, value in enumerate(values) if value.strip()} + ) + return targets + + +def _contains_direct_clinical_assertion(value: str) -> bool: + return any( + pattern.search(value) is not None + for pattern in (*_DIRECT_DIAGNOSIS_PATTERNS, *_DIRECT_TREATMENT_PATTERNS) + ) + + +def handoff_claims_are_valid(claims: HandoffClaims, request: HandoffRequest) -> bool: + """Require one exact user-message span for every populated claim target.""" + if claims.documents_summary: + return False + + targets = _populated_targets(claims) + if any(_contains_direct_clinical_assertion(value) for value in targets.values()): + return False + if len(claims.evidence) != len(targets): + return False + + messages: dict[str, tuple[str, str]] = {} + for message in request.messages: + source_id = str(message.message_id) + if source_id in messages: + return False + messages[source_id] = (message.role, message.content) + + cited_targets: set[str] = set() + for citation in claims.evidence: + source = messages.get(str(citation.source_message_id)) + claim = targets.get(citation.field) + if source is None or claim is None or citation.field in cited_targets: + return False + role, content = source + if ( + role != "user" + or len(citation.quote.strip()) < 2 + or citation.quote not in content + or claim != citation.quote + ): + return False + cited_targets.add(citation.field) + return cited_targets == targets.keys() diff --git a/apps/ai-server/src/services/handoff_contract_adapter.py b/apps/ai-server/src/services/handoff_contract_adapter.py new file mode 100644 index 0000000..90b9c67 --- /dev/null +++ b/apps/ai-server/src/services/handoff_contract_adapter.py @@ -0,0 +1,57 @@ +"""Lossless conversion from the shared handoff contract to the local agent input.""" + +from __future__ import annotations + +from typing import Final + +from contracts.handoff import HandoffRequest, HandoffRiskSignal + +from src.schemas.handoff import HandoffInput, RiskEvent, RiskLevelLabel, ScaleScore + +_RISK_LEVELS: Final[dict[str, RiskLevelLabel]] = { + "none": "none", + "low": "low", + "medium": "medium", + "high": "high", + "critical": "critical", +} + + +def _adapt_risk_signal(signal: HandoffRiskSignal) -> RiskEvent: + values: dict[str, str] = {"level": signal.level} + recognized = _RISK_LEVELS.get(signal.level.strip().lower()) + if recognized is not None: + values["risk_level"] = recognized + if signal.category is not None: + values["category"] = signal.category + if signal.source_message_id is not None: + values["source_message_id"] = str(signal.source_message_id) + return RiskEvent.model_validate(values) + + +def adapt_handoff_request(request: HandoffRequest) -> HandoffInput: + """Preserve every official request field in the local generator shape.""" + return HandoffInput( + session_id=str(request.session_id), + conversation_history=[ + { + "message_id": str(message.message_id), + "role": message.role, + "content": message.content, + } + for message in request.messages + ], + scale_scores=[ + ScaleScore( + scale_name=questionnaire.type, + total_score=questionnaire.total_score, + severity=questionnaire.severity, + ) + for questionnaire in request.questionnaires + ], + ocr_documents=[ + {"document_id": f"doc-{index}", "content": content} + for index, content in enumerate(request.doc_texts, start=1) + ], + risk_events=[_adapt_risk_signal(signal) for signal in request.risk_signals], + ) diff --git a/apps/ai-server/src/services/handoff_risk.py b/apps/ai-server/src/services/handoff_risk.py new file mode 100644 index 0000000..4d03bec --- /dev/null +++ b/apps/ai-server/src/services/handoff_risk.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Final, assert_never + +from src.schemas.common import CTRS_TO_RISK, CTRSLevel, RiskLevel +from src.schemas.handoff import JsonScalar, RiskEvent + +type RiskEventData = Mapping[str, JsonScalar] +type RiskEventInput = RiskEvent | RiskEventData + +_RISK_ORDER: Final = { + RiskLevel.none: 0, + RiskLevel.low: 1, + RiskLevel.medium: 2, + RiskLevel.high: 3, + RiskLevel.critical: 4, +} +_RISK_BY_VALUE: Final = {risk.value: risk for risk in RiskLevel} + + +def risk_event_text(event: RiskEventInput) -> str: + match event: + case RiskEvent(): + view = event.model_dump(exclude_none=True) + return str(view or event) + case Mapping(): + return str(event) + case _: + assert_never(event) + + +def _risk_labels(event: RiskEventInput) -> tuple[str, str]: + match event: + case RiskEvent(risk_level=risk_level, ctrs_level=ctrs_level): + return risk_level or "", ctrs_level or "" + case Mapping(): + return str(event.get("risk_level", "")), str(event.get("ctrs_level", "")) + case _: + assert_never(event) + + +def _event_risk(event: RiskEventInput) -> RiskLevel: + risk_label, ctrs_label = _risk_labels(event) + candidates: list[RiskLevel] = [] + normalized_risk = risk_label.strip().lower() + explicit_risk = _RISK_BY_VALUE.get(normalized_risk) + if explicit_risk is not None: + candidates.append(explicit_risk) + normalized_ctrs = ctrs_label.strip() + if normalized_ctrs.isascii() and normalized_ctrs.isdigit(): + try: + ctrs_risk = CTRS_TO_RISK.get(CTRSLevel(int(normalized_ctrs))) + except ValueError: + ctrs_risk = None + if ctrs_risk is not None: + candidates.append(ctrs_risk) + if not candidates: + return RiskLevel.medium + return max(candidates, key=_RISK_ORDER.__getitem__) + + +def detect_risk_level(events: Sequence[RiskEventInput]) -> RiskLevel: + if not events: + return RiskLevel.none + return max((_event_risk(event) for event in events), key=_RISK_ORDER.__getitem__) diff --git a/apps/ai-server/src/services/trend_plotter.py b/apps/ai-server/src/services/trend_plotter.py index 2d8cda9..99b1966 100644 --- a/apps/ai-server/src/services/trend_plotter.py +++ b/apps/ai-server/src/services/trend_plotter.py @@ -21,6 +21,19 @@ import logging from dataclasses import dataclass from datetime import datetime +from pathlib import Path + +_FONT_ASSET_DIR = Path(__file__).resolve().parents[2] / "assets" / "fonts" + + +def _korean_font_candidates() -> list[str]: + """Hangul-capable font files, bundled SHA-pinned assets first so charts + render Korean labels on any host, then common system locations.""" + return [ + str(_FONT_ASSET_DIR / "NotoSansKR-Subset.ttf"), + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/nanum/NanumGothic.ttf", + ] logger = logging.getLogger(__name__) @@ -199,11 +212,10 @@ def _render_plot( import matplotlib.font_manager as fm import matplotlib.pyplot as plt - # Korean font - for fpath in [ - "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", - "/usr/share/fonts/truetype/nanum/NanumGothic.ttf", - ]: + # Korean font — bundled SHA-pinned assets first (portable: macOS/slim + # containers have neither /usr/share path, which silently degraded every + # Hangul label to missing glyphs), system fonts as fallback. + for fpath in _korean_font_candidates(): try: fm.fontManager.addfont(fpath) plt.rcParams["font.family"] = fm.FontProperties(fname=fpath).get_name() diff --git a/apps/ai-server/tests/repro/test_cvr028_p1_f5_bundle.py b/apps/ai-server/tests/repro/test_cvr028_p1_f5_bundle.py index cf8455e..c9d95d7 100644 --- a/apps/ai-server/tests/repro/test_cvr028_p1_f5_bundle.py +++ b/apps/ai-server/tests/repro/test_cvr028_p1_f5_bundle.py @@ -92,6 +92,7 @@ def _session(final_slots: dict[str, str]) -> SessionSnapshot: def _minimal_input(final_slots: dict[str, str], all_sessions=()) -> HandoffReportInput: return HandoffReportInput( vp_id="VP-010", + generated_at="2026-01-15T12:00:00+09:00", session=_session(final_slots), current_session_f3=None, all_f3_administrations=(), diff --git a/apps/ai-server/tests/test_continuous_test_f4.py b/apps/ai-server/tests/test_continuous_test_f4.py index 2a9ef0e..22f831b 100644 --- a/apps/ai-server/tests/test_continuous_test_f4.py +++ b/apps/ai-server/tests/test_continuous_test_f4.py @@ -426,12 +426,15 @@ async def _fake_f3_stage(f2_ctx): "VP-001", n_sessions=2, max_turns=1, k=1, out_dir=tmp_path, scale_scores_path=None, ) - assert results[-1].name == "F4" + # F5 (hand-off) now follows F4 in the post-loop path — F4 sits second + # to last, F5 last. + assert results[-2].name == "F4" + assert results[-1].name == "F5" # F3 was stubbed to "skip" every session -> no "f3" ledger content -> # F4 assembly still runs (>=2 ledger entries exist) but with no # scale data -> "skip"/"warn"/"pass" are all acceptable non-crash # outcomes here; the key assertion is that F4 was invoked at all. - assert results[-1].status in ("pass", "warn", "skip") + assert results[-2].status in ("pass", "warn", "skip") @pytest.mark.asyncio async def test_run_f4_false_skips_post_loop_step( diff --git a/apps/ai-server/tests/test_continuous_test_f5.py b/apps/ai-server/tests/test_continuous_test_f5.py index b717e79..61f5103 100644 --- a/apps/ai-server/tests/test_continuous_test_f5.py +++ b/apps/ai-server/tests/test_continuous_test_f5.py @@ -11,11 +11,16 @@ import json import subprocess import sys +from datetime import datetime from pathlib import Path +from typing import Literal import pytest import src.continuous_test as ct +from src.services.f5_markdown import inline_literal + +type JsonValue = str | int | float | bool | None | list[JsonValue] | dict[str, JsonValue] # ── Fixture helpers (synthetic F1/F2/F3/F4 artifacts, never real ones) ── @@ -69,8 +74,8 @@ def _write_domain_inference( session_index: int, *, with_candidates: bool = True, - department_candidates: list[dict] | None = None, - validation_errors: list[dict] | None = None, + department_candidates: list[JsonValue] | None = None, + validation_errors: list[JsonValue] | None = None, ) -> Path: """`validation_errors` (`ADR-038` Decision 2a / `VAL-016`): the artifact's own top-level field mirroring a real Pydantic atomic-parse @@ -78,7 +83,7 @@ def _write_domain_inference( vp_dir = tmp_path / persona_id vp_dir.mkdir(parents=True, exist_ok=True) path = vp_dir / f"{persona_id}_202601{session_index:02d}_010000_domain_inference.json" - artifact: dict = { + artifact: dict[str, JsonValue] = { "domain_candidates": [{"domain": "depression", "confidence": 0.7}], "department_candidates": ( department_candidates @@ -159,22 +164,23 @@ def _ledger_entry( di_path: Path | None, *, simulated_date: str = "2026-01-01", - f3: dict | None = "__default__", # type: ignore[assignment] + f3: dict[str, JsonValue] | Literal["__default__"] | None = "__default__", survey_path: Path | None = None, outcome: str = "administered", scale_name: str = "PHQ-9", total_score: int = 15, safety_referral: bool = False, final_slots: dict[str, str] | None = None, -) -> dict: +) -> dict[str, JsonValue]: if f3 == "__default__": - f3 = { + responses: list[JsonValue] = [2] * 9 if outcome == "administered" else [] + default_f3: dict[str, JsonValue] = { "outcome": outcome, "scale_name": scale_name if outcome == "administered" else None, "administration_mode": "natural", "item_bank_version": "v1" if outcome == "administered" else None, "item_bank_provenance": "v1 test provenance" if outcome == "administered" else None, - "responses": [2] * 9 if outcome == "administered" else [], + "responses": responses, "total_score": total_score if outcome == "administered" else None, "max_score": 27 if outcome == "administered" else None, "severity": "moderate" if outcome == "administered" else None, @@ -188,11 +194,17 @@ def _ledger_entry( "scenario_pack_id": None, "arc_mode": None, } + f3 = default_f3 + serialized_final_slots: dict[str, JsonValue] = ( + {"chief_complaint": "x"} + if final_slots is None + else {key: value for key, value in final_slots.items()} + ) return { "session_index": session_index, "simulated_date": simulated_date, "is_revisit": session_index > 1, - "final_slots": final_slots if final_slots is not None else {"chief_complaint": "x"}, + "final_slots": serialized_final_slots, "missing_slots": [], "repro": {"model": "m", "prompt_version": "v"}, "conversation_path": str(conv_path) if conv_path else None, @@ -334,16 +346,51 @@ def test_no_path_degrades_to_empty_snapshot_validation_errors_false(self) -> Non class TestRunF5Report: + def test_explicit_temporal_path_beats_lexically_newer_stale_file( + self, tmp_path: Path + ) -> None: + persona_id = "VP-EXACT-F4" + current_path = _build_two_session_fixture(tmp_path, persona_id) + stale_path = _write_temporal(tmp_path, persona_id, ts="99991231_235959") + stale_data = json.loads(stale_path.read_text(encoding="utf-8")) + stale_data["n_sessions"] = 99 + stale_path.write_text(json.dumps(stale_data), encoding="utf-8") + + paths = ct._run_f5_report(persona_id, tmp_path, current_path) + + markdown = paths["markdown"].read_text(encoding="utf-8") + assert "(2세션" in markdown + assert "(99세션" not in markdown + + def test_missing_exact_temporal_path_fails_without_glob_fallback( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + persona_id = "VP-MISSING-EXACT-F4" + current_path = _build_two_session_fixture(tmp_path, persona_id) + _write_temporal(tmp_path, persona_id, ts="99991231_235959") + current_path.unlink() + + def _unexpected_glob(*_args, **_kwargs): + raise AssertionError("live F5 must not search for a replacement temporal artifact") + + monkeypatch.setattr(ct, "_find_latest_f5_temporal_artifact", _unexpected_glob) + + with pytest.raises(FileNotFoundError, match=current_path.name): + ct._run_f5_report(persona_id, tmp_path, current_path) + def test_happy_path_builds_all_three_outputs(self, tmp_path: Path) -> None: - _build_two_session_fixture( + temporal_path = _build_two_session_fixture( tmp_path, "VP-TEST", chart_keys=( "scales_ctrs_sentiment", "ctrs_zoom", "disease_similarity", "domain_confidence", ), ) - paths = ct._run_f5_report("VP-TEST", tmp_path) + paths = ct._run_f5_report("VP-TEST", tmp_path, temporal_path) assert set(paths) == {"markdown", "pdf", "fhir"} - for p in paths.values(): + artifacts = [paths["markdown"], paths["fhir"]] + if "pdf" in paths: + artifacts.append(paths["pdf"]) + for p in artifacts: assert p.exists() assert p.stat().st_size > 0 @@ -351,6 +398,7 @@ def test_happy_path_builds_all_three_outputs(self, tmp_path: Path) -> None: bundle = json.loads(paths["fhir"].read_text(encoding="utf-8")) assert validate_fhir_bundle(bundle) == [] + assert datetime.fromisoformat(bundle["timestamp"]).utcoffset() is not None md = paths["markdown"].read_text(encoding="utf-8") assert "scales_ctrs_sentiment" in md @@ -384,9 +432,9 @@ def test_all_sessions_slot_overview_built_from_ledger_final_slots(self, tmp_path final_slots={"chief_complaint": "수면 개선 추세"}, ), ) - _write_temporal(tmp_path, persona_id) + temporal_path = _write_temporal(tmp_path, persona_id) - paths = ct._run_f5_report(persona_id, tmp_path) + paths = ct._run_f5_report(persona_id, tmp_path, temporal_path) md = paths["markdown"].read_text(encoding="utf-8") section = md.split("## 전체 세션 요약")[1].split("## 시행된 설문")[0] assert "S1: '2주 전부터 불면'" in section @@ -429,15 +477,15 @@ def test_validation_errors_flow_end_to_end_into_a7_disclosure(self, tmp_path: Pa ledger_path, _ledger_entry(2, conv2, di2, simulated_date="2026-01-08", survey_path=survey2), ) - _write_temporal(tmp_path, persona_id) + temporal_path = _write_temporal(tmp_path, persona_id) - paths = ct._run_f5_report(persona_id, tmp_path) + paths = ct._run_f5_report(persona_id, tmp_path, temporal_path) md = paths["markdown"].read_text(encoding="utf-8") a7_section = md.split("## 권장 진료과 및 후속 조치")[1].split("## 임상 종합 소견")[0] assert "VAL-016" not in a7_section # internal ticket ID relocated, not inline assert "정보 없음 (권장 진료과 없음)" not in a7_section # old bare wording gone assert "이번 실행에서는 진료과 후보가 산출되지 않았습니다" in a7_section # honest KO note - assert "VAL-016" in md.split("## 각주")[1] # relocated, not dropped + assert inline_literal("VAL-016") in md.split("## 각주")[1] # relocated, not dropped def test_fewer_than_2_ledger_entries_raises_insufficient_sessions( self, tmp_path: Path @@ -448,7 +496,7 @@ def test_fewer_than_2_ledger_entries_raises_insufficient_sessions( ct._append_ledger_entry(ledger_path, _ledger_entry(1, conv, None)) with pytest.raises(ct.F5InsufficientSessionsError, match="needs >=2"): - ct._run_f5_report(persona_id, tmp_path) + ct._run_f5_report(persona_id, tmp_path, tmp_path / "unused-temporal.json") def test_missing_temporal_json_raises_named_failure_directing_to_f4( self, tmp_path: Path @@ -463,8 +511,9 @@ def test_missing_temporal_json_raises_named_failure_directing_to_f4( ) # deliberately no *_temporal.json written this run - with pytest.raises(RuntimeError, match="run F4 first"): - ct._run_f5_report(persona_id, tmp_path) + missing_temporal_path = tmp_path / persona_id / "current_temporal.json" + with pytest.raises(FileNotFoundError, match="run F4 first"): + ct._run_f5_report(persona_id, tmp_path, missing_temporal_path) def test_missing_conversation_file_raises_named_failure(self, tmp_path: Path) -> None: persona_id = "VP-NOCONV" @@ -479,21 +528,21 @@ def test_missing_conversation_file_raises_named_failure(self, tmp_path: Path) -> ledger_path, _ledger_entry(2, missing_conv, None, simulated_date="2026-01-08"), ) - _write_temporal(tmp_path, persona_id) + temporal_path = _write_temporal(tmp_path, persona_id) with pytest.raises(FileNotFoundError, match="conversation.json not found"): - ct._run_f5_report(persona_id, tmp_path) + ct._run_f5_report(persona_id, tmp_path, temporal_path) def test_chart_missing_tolerance_only_present_charts_referenced( self, tmp_path: Path ) -> None: persona_id = "VP-CHARTS" - _build_two_session_fixture( + temporal_path = _build_two_session_fixture( tmp_path, persona_id, chart_keys=("scales_ctrs_sentiment", "ctrs_zoom") ) # disease_similarity / domain_confidence deliberately never written # (mirrors EXP-023 VP-003's own real gap, per the wave-1 handoff note). - paths = ct._run_f5_report(persona_id, tmp_path) + paths = ct._run_f5_report(persona_id, tmp_path, temporal_path) md = paths["markdown"].read_text(encoding="utf-8") assert "scales_ctrs_sentiment" in md assert "ctrs_zoom" in md @@ -503,8 +552,8 @@ def test_chart_missing_tolerance_only_present_charts_referenced( def test_no_charts_at_all_renders_absent_marker(self, tmp_path: Path) -> None: persona_id = "VP-NOCHARTS" - _build_two_session_fixture(tmp_path, persona_id, chart_keys=()) - paths = ct._run_f5_report(persona_id, tmp_path) + temporal_path = _build_two_session_fixture(tmp_path, persona_id, chart_keys=()) + paths = ct._run_f5_report(persona_id, tmp_path, temporal_path) md = paths["markdown"].read_text(encoding="utf-8") assert "정보 없음 (차트 없음)" in md @@ -523,9 +572,9 @@ def test_f3_never_administered_persona_renders_a5_absent_marked( outcome="no_questionnaire_indicated", ), ) - _write_temporal(tmp_path, persona_id) + temporal_path = _write_temporal(tmp_path, persona_id) - paths = ct._run_f5_report(persona_id, tmp_path) + paths = ct._run_f5_report(persona_id, tmp_path, temporal_path) md = paths["markdown"].read_text(encoding="utf-8") assert "정보 없음 (전체 세션 중 시행된 설문 없음)" in md @@ -533,10 +582,15 @@ def test_write_dir_overrides_where_output_lands_read_stays_at_out_dir( self, tmp_path: Path ) -> None: persona_id = "VP-SPLIT" - _build_two_session_fixture(tmp_path, persona_id) + temporal_path = _build_two_session_fixture(tmp_path, persona_id) write_dir = tmp_path / "elsewhere" - paths = ct._run_f5_report(persona_id, tmp_path, write_dir=write_dir) - for p in paths.values(): + paths = ct._run_f5_report( + persona_id, tmp_path, temporal_path, write_dir=write_dir + ) + artifacts = [paths["markdown"], paths["fhir"]] + if "pdf" in paths: + artifacts.append(paths["pdf"]) + for p in artifacts: assert p.exists() assert p.is_relative_to(write_dir) @@ -545,6 +599,33 @@ def test_write_dir_overrides_where_output_lands_read_stays_at_out_dir( class TestRunF5Stage: + @pytest.mark.asyncio + async def test_fails_closed_when_exact_temporal_path_is_missing_even_with_stale_file( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + persona_id = "VP-STAGE-EXACT" + current_path = _build_two_session_fixture(tmp_path, persona_id) + _write_temporal(tmp_path, persona_id, ts="99991231_235959") + current_path.unlink() + + def _unexpected_glob(*_args, **_kwargs): + raise AssertionError("live F5 must not search for a replacement temporal artifact") + + monkeypatch.setattr(ct, "_find_latest_f5_temporal_artifact", _unexpected_glob) + ctx = ct.ChainContext( + persona_id=persona_id, + max_turns=1, + k=1, + out_dir=tmp_path, + scale_scores_path=None, + f4_temporal_path=current_path, + ) + + result = await ct.run_f5_stage(ctx) + + assert result.status == "fail" + assert current_path.name in result.detail + @pytest.mark.asyncio async def test_skips_when_fewer_than_2_ledger_entries(self, tmp_path: Path) -> None: persona_id = "VP-SOLO2" @@ -553,7 +634,12 @@ async def test_skips_when_fewer_than_2_ledger_entries(self, tmp_path: Path) -> N ct._append_ledger_entry(ledger_path, _ledger_entry(1, conv, None)) ctx = ct.ChainContext( - persona_id=persona_id, max_turns=1, k=1, out_dir=tmp_path, scale_scores_path=None + persona_id=persona_id, + max_turns=1, + k=1, + out_dir=tmp_path, + scale_scores_path=None, + f4_temporal_path=tmp_path / "unused-temporal.json", ) result = await ct.run_f5_stage(ctx) assert result.status == "skip" @@ -570,7 +656,12 @@ async def test_fails_named_when_temporal_json_missing(self, tmp_path: Path) -> N ) ctx = ct.ChainContext( - persona_id=persona_id, max_turns=1, k=1, out_dir=tmp_path, scale_scores_path=None + persona_id=persona_id, + max_turns=1, + k=1, + out_dir=tmp_path, + scale_scores_path=None, + f4_temporal_path=tmp_path / persona_id / "current_temporal.json", ) result = await ct.run_f5_stage(ctx) assert result.status == "fail" @@ -579,10 +670,15 @@ async def test_fails_named_when_temporal_json_missing(self, tmp_path: Path) -> N @pytest.mark.asyncio async def test_passes_and_writes_artifacts(self, tmp_path: Path) -> None: persona_id = "VP-STAGE-PASS" - _build_two_session_fixture(tmp_path, persona_id) + temporal_path = _build_two_session_fixture(tmp_path, persona_id) ctx = ct.ChainContext( - persona_id=persona_id, max_turns=1, k=1, out_dir=tmp_path, scale_scores_path=None + persona_id=persona_id, + max_turns=1, + k=1, + out_dir=tmp_path, + scale_scores_path=None, + f4_temporal_path=temporal_path, ) result = await ct.run_f5_stage(ctx) assert result.status == "pass" @@ -600,6 +696,48 @@ async def test_stage_registry_f5_entry_is_implemented(self) -> None: class TestF5ReplayCli: + def test_resolves_temporal_history_once_and_passes_path_explicitly( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + persona_id = "VP-REPLAY-RESOLVE" + base = tmp_path / "artifacts_root" + selected_path = _build_two_session_fixture(base, persona_id) + artifacts_dir = base / persona_id + resolved_paths: list[Path] = [] + report_paths: list[Path] = [] + + def _resolve_once(candidate_persona_id: str, candidate_base: Path | None) -> Path: + assert candidate_persona_id == persona_id + assert candidate_base == base + resolved_paths.append(selected_path) + return selected_path + + def _report( + candidate_persona_id: str, + candidate_base: Path | None, + f4_temporal_path: Path, + *, + write_dir: Path | None = None, + ) -> dict[str, Path]: + assert candidate_persona_id == persona_id + assert candidate_base == base + assert write_dir == base + report_paths.append(f4_temporal_path) + return { + "markdown": artifacts_dir / "report.md", + "pdf": artifacts_dir / "report.pdf", + "fhir": artifacts_dir / "report.json", + } + + monkeypatch.setattr(ct, "_find_latest_f5_temporal_artifact", _resolve_once) + monkeypatch.setattr(ct, "_run_f5_report", _report) + + exit_code = ct._run_f5_replay_cli(artifacts_dir, out_dir=None) + + assert exit_code == 0 + assert resolved_paths == [selected_path] + assert report_paths == [selected_path] + def test_default_out_writes_into_artifacts_dir_itself(self, tmp_path: Path) -> None: persona_id = "VP-REPLAY" base = tmp_path / "artifacts_root" @@ -684,3 +822,354 @@ def test_subprocess_invocation_produces_outputs_in_out_dir(self, tmp_path: Path) assert "F5 hand-off report complete" in result.stdout produced = list((out_dir / persona_id).glob("*_handoff.pdf")) assert len(produced) == 1 + + +class TestF5ChainWiring: + """Round-1 review blocker: with --sessions>1 F5 never ran (registry + bypassed), and with --sessions 1 F5 ran BEFORE the session's own ledger + entry existed. F5 must run from the post-ledger path after F4.""" + + @staticmethod + async def _fake_f1(persona_id, max_turns, followup_from=None, **kwargs): + from src.f1 import F1Result + + session_index = kwargs["session_index"] + return F1Result( + session_id=f"f1_{persona_id}_s{session_index}", + persona_id=persona_id, + persona_name="테스트", + session_index=session_index, + is_revisit=session_index > 1, + model="stub-model", + prompt_version="v3", + final_slots=[{"key": "chief_complaint", "value": "cc"}], + ) + + @pytest.mark.asyncio + async def test_multi_session_runs_f5_after_f4_with_exact_json_path_over_complete_ledger( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + conv_paths: list[Path] = [] + ledger_len_at_f5_call: list[int] = [] + temporal_paths_at_f5_call: list[Path | None] = [] + f4_temporal_path = tmp_path / "current-multi-temporal.json" + + async def _fake_run_simulation(persona_id, max_turns, followup_from=None, **kwargs): + session_index = kwargs["session_index"] + conv_paths.append(_write_conversation(tmp_path, persona_id, session_index)) + return await TestF5ChainWiring._fake_f1( + persona_id, max_turns, followup_from, **kwargs + ) + + async def _fake_run_f2_stage(f2_ctx): + return ct.StageResult("F2", "pass", "ok") + + async def _fake_f4(persona_id, out_dir): + return ct.StageResult( + "F4", "pass", "ok", artifacts={"json": f4_temporal_path} + ) + + async def _fake_f5(f5_ctx): + entries = json.loads( + ct._ledger_path(f5_ctx.persona_id, f5_ctx.out_dir).read_text(encoding="utf-8") + ) + ledger_len_at_f5_call.append(len(entries)) + temporal_paths_at_f5_call.append(f5_ctx.f4_temporal_path) + return ct.StageResult("F5", "pass", "ok") + + import src.f1 as f1_module + + monkeypatch.setattr(f1_module, "_run_simulation", _fake_run_simulation) + monkeypatch.setattr(ct, "run_f2_stage", _fake_run_f2_stage) + monkeypatch.setattr(ct, "_find_latest_f1_conversation", lambda persona_id: conv_paths[-1]) + monkeypatch.setattr(ct, "_run_f4_analysis", _fake_f4) + monkeypatch.setattr(ct, "run_f5_stage", _fake_f5) + + results = await ct.run_multi_session_chain( + "VP-W1", + n_sessions=2, + max_turns=3, + k=3, + out_dir=tmp_path, + scale_scores_path=None, + answer_mode="expected", + run_f4=True, + ) + names = [r.name for r in results] + assert "F5" in names, f"F5 stage never ran in multi-session chain: {names}" + assert names.index("F5") > names.index("F4") + assert ledger_len_at_f5_call == [2], ( + f"F5 must run over the COMPLETE 2-entry ledger, saw {ledger_len_at_f5_call}" + ) + assert temporal_paths_at_f5_call == [f4_temporal_path] + + @pytest.mark.asyncio + async def test_multi_session_skips_f5_when_f4_fails( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + conv_paths: list[Path] = [] + + async def _fake_run_simulation(persona_id, max_turns, followup_from=None, **kwargs): + session_index = kwargs["session_index"] + conv_paths.append(_write_conversation(tmp_path, persona_id, session_index)) + return await TestF5ChainWiring._fake_f1( + persona_id, max_turns, followup_from, **kwargs + ) + + async def _fake_run_f2_stage(f2_ctx): + return ct.StageResult("F2", "pass", "ok") + + async def _fake_f4_fail(persona_id, out_dir): + return ct.StageResult("F4", "fail", "boom") + + f5_calls: list[str] = [] + + async def _fake_f5(f5_ctx): + f5_calls.append(f5_ctx.persona_id) + return ct.StageResult("F5", "pass", "ok") + + import src.f1 as f1_module + + monkeypatch.setattr(f1_module, "_run_simulation", _fake_run_simulation) + monkeypatch.setattr(ct, "run_f2_stage", _fake_run_f2_stage) + monkeypatch.setattr(ct, "_find_latest_f1_conversation", lambda persona_id: conv_paths[-1]) + monkeypatch.setattr(ct, "_run_f4_analysis", _fake_f4_fail) + monkeypatch.setattr(ct, "run_f5_stage", _fake_f5) + + results = await ct.run_multi_session_chain( + "VP-W1B", + n_sessions=1, + max_turns=3, + k=3, + out_dir=tmp_path, + scale_scores_path=None, + answer_mode="expected", + run_f4=True, + ) + f5_results = [r for r in results if r.name == "F5"] + assert f5_calls == [], "F5 must not execute when F4 failed" + assert f5_results and f5_results[0].status == "skip" + + @pytest.mark.asyncio + async def test_multi_session_skips_f5_when_f4_warns( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # codex P1: F4 "warn" (fewer than 2 readable ledger entries) writes NO + # fresh *_temporal.json, so F5 must be skipped — otherwise it would read + # a STALE temporal from an earlier run and emit a passing report mixing + # the current header with old longitudinal data. + conv_paths: list[Path] = [] + + async def _fake_run_simulation(persona_id, max_turns, followup_from=None, **kwargs): + session_index = kwargs["session_index"] + conv_paths.append(_write_conversation(tmp_path, persona_id, session_index)) + return await TestF5ChainWiring._fake_f1( + persona_id, max_turns, followup_from, **kwargs + ) + + async def _fake_run_f2_stage(f2_ctx): + return ct.StageResult("F2", "pass", "ok") + + async def _fake_f4_warn(persona_id, out_dir): + return ct.StageResult("F4", "warn", "only 1/2 ledger entries had readable artifacts") + + f5_calls: list[str] = [] + + async def _fake_f5(f5_ctx): + f5_calls.append(f5_ctx.persona_id) + return ct.StageResult("F5", "pass", "ok") + + import src.f1 as f1_module + + monkeypatch.setattr(f1_module, "_run_simulation", _fake_run_simulation) + monkeypatch.setattr(ct, "run_f2_stage", _fake_run_f2_stage) + monkeypatch.setattr(ct, "_find_latest_f1_conversation", lambda persona_id: conv_paths[-1]) + monkeypatch.setattr(ct, "_run_f4_analysis", _fake_f4_warn) + monkeypatch.setattr(ct, "run_f5_stage", _fake_f5) + + results = await ct.run_multi_session_chain( + "VP-W1WARN", + n_sessions=1, + max_turns=3, + k=3, + out_dir=tmp_path, + scale_scores_path=None, + answer_mode="expected", + run_f4=True, + ) + f5_results = [r for r in results if r.name == "F5"] + assert f5_calls == [], "F5 must not execute when F4 only warned (no fresh temporal)" + assert f5_results and f5_results[0].status == "skip" + + @pytest.mark.asyncio + async def test_single_session_runs_f4_then_passes_exact_json_path_to_f5_after_ledger_append( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Round-2 review blocker (codex): F4 and F5 both read the session + ledger, so BOTH must run AFTER this session's entry is appended — + otherwise F4's longitudinal window is one session short of, and + inconsistent with, F5's header. Assert both observe the SAME complete + (1-entry) ledger snapshot and are ordered F4 -> F5 -> F6.""" + import argparse + + conv_path = _write_conversation(tmp_path, "VP-W1S", 1) + ledger_len_at_f4_call: list[int] = [] + ledger_len_at_f5_call: list[int] = [] + temporal_paths_at_f5_call: list[Path | None] = [] + f4_temporal_path = tmp_path / "current-single-temporal.json" + captured: dict[str, list[ct.StageResult]] = {} + + async def _fake_run_chain(ctx, stages=None): + ctx.conversation_path = conv_path + stage_names = [s.name for s in (stages if stages is not None else ct.STAGE_REGISTRY)] + assert "F4" not in stage_names and "F5" not in stage_names, ( + f"F4 and F5 must be deferred out of run_chain, got {stage_names}" + ) + return [ + ct.StageResult(n, "pass" if n != "F6" else "skip", "ok") for n in stage_names + ] + + async def _fake_f4(ctx): + entries = json.loads( + ct._ledger_path(ctx.persona_id, ctx.out_dir).read_text(encoding="utf-8") + ) + ledger_len_at_f4_call.append(len(entries)) + return ct.StageResult( + "F4", "pass", "ok", artifacts={"json": f4_temporal_path} + ) + + async def _fake_f5(f5_ctx): + entries = json.loads( + ct._ledger_path(f5_ctx.persona_id, f5_ctx.out_dir).read_text(encoding="utf-8") + ) + ledger_len_at_f5_call.append(len(entries)) + temporal_paths_at_f5_call.append(f5_ctx.f4_temporal_path) + return ct.StageResult("F5", "pass", "ok") + + monkeypatch.setattr(ct, "run_chain", _fake_run_chain) + monkeypatch.setattr(ct, "run_f4_stage", _fake_f4) + monkeypatch.setattr(ct, "run_f5_stage", _fake_f5) + monkeypatch.setattr( + ct, "print_report", lambda ctx, results: captured.update(results=results) + ) + + args = argparse.Namespace( + persona="VP-W1S", + sessions=1, + max_turns=3, + k=3, + out=str(tmp_path), + scale_scores=None, + start_from_conversation=None, + answer_mode="expected", + force_questionnaire=None, + patient_sex=None, + scenario_pack=None, + no_f4=False, + f5_from_artifacts=None, + session_interval_days=14, + ) + rc = await ct._main(args) + assert rc == 0 + assert ledger_len_at_f4_call == [1], ( + "F4 must run AFTER the single-session ledger entry is appended, " + f"saw ledger lengths {ledger_len_at_f4_call}" + ) + assert ledger_len_at_f5_call == [1], ( + "F5 must run AFTER the single-session ledger entry is appended, " + f"saw ledger lengths {ledger_len_at_f5_call}" + ) + assert ledger_len_at_f4_call == ledger_len_at_f5_call, ( + "F4 and F5 must observe the SAME ledger snapshot (codex consistency fix)" + ) + assert temporal_paths_at_f5_call == [f4_temporal_path] + names = [r.name for r in captured["results"]] + assert "F4" in names and "F5" in names and "F6" in names + assert names.index("F4") < names.index("F5") < names.index("F6") + + +class TestF5StagePartialExport: + """Round-2 review blocker (codex): a PDF export failure inside + save_f5_result must NOT be reported as a clean F5 pass. run_f5_stage + surfaces it as 'warn' (md+FHIR still produced) so the report and the + replay CLI can detect the missing clinical artifact.""" + + @pytest.mark.asyncio + async def test_run_f5_stage_warns_when_pdf_missing( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + md = tmp_path / "VP-PDF_handoff.md" + md.write_text("ok", encoding="utf-8") + fhir = tmp_path / "VP-PDF_handoff_fhir.json" + fhir.write_text("{}", encoding="utf-8") + + def _fake_report(persona_id, out_dir, f4_temporal_path, *, write_dir=None): + return {"markdown": md, "fhir": fhir} + + monkeypatch.setattr(ct, "_run_f5_report", _fake_report) + ctx = ct.ChainContext( + persona_id="VP-PDF", + max_turns=0, + k=0, + out_dir=tmp_path, + scale_scores_path=None, + f4_temporal_path=tmp_path / "current-temporal.json", + ) + result = await ct.run_f5_stage(ctx) + assert result.status == "warn" + assert "PDF" in result.detail + + @pytest.mark.asyncio + async def test_run_f5_stage_passes_when_all_artifacts_present( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + md = tmp_path / "VP-OK_handoff.md" + md.write_text("ok", encoding="utf-8") + + def _fake_report(persona_id, out_dir, f4_temporal_path, *, write_dir=None): + return { + "markdown": md, + "pdf": tmp_path / "VP-OK_handoff.pdf", + "fhir": tmp_path / "VP-OK_handoff_fhir.json", + } + + monkeypatch.setattr(ct, "_run_f5_report", _fake_report) + ctx = ct.ChainContext( + persona_id="VP-OK", + max_turns=0, + k=0, + out_dir=tmp_path, + scale_scores_path=None, + f4_temporal_path=tmp_path / "current-temporal.json", + ) + result = await ct.run_f5_stage(ctx) + assert result.status == "pass" + + +class TestChainReturnCode: + """codex P2: a partial F5 export (md/FHIR written, PDF missing → F5 'warn') + must make the regular chain exit NONZERO (2), matching the replay CLI, so + automation detects the missing clinical artifact even without a hard fail.""" + + def test_partial_f5_export_exits_nonzero(self) -> None: + results = [ + ct.StageResult("F1", "pass", "ok"), + ct.StageResult("F5", "warn", "PDF export failed — md/FHIR written"), + ] + assert ct._chain_return_code(results) == 2 + + def test_hard_fail_dominates_partial_export(self) -> None: + results = [ + ct.StageResult("F5", "warn", "pdf missing"), + ct.StageResult("F4", "fail", "boom"), + ] + assert ct._chain_return_code(results) == 1 + + def test_clean_run_exits_zero(self) -> None: + results = [ct.StageResult("F1", "pass", "ok"), ct.StageResult("F5", "pass", "ok")] + assert ct._chain_return_code(results) == 0 + + def test_benign_non_f5_warn_stays_zero(self) -> None: + # An F4 'warn' (insufficient data) is not a missing artifact -> exit 0. + results = [ct.StageResult("F4", "warn", "only 1/2 readable")] + assert ct._chain_return_code(results) == 0 diff --git a/apps/ai-server/tests/test_deployment_stateless_routes.py b/apps/ai-server/tests/test_deployment_stateless_routes.py index 9384fc4..905f2d3 100644 --- a/apps/ai-server/tests/test_deployment_stateless_routes.py +++ b/apps/ai-server/tests/test_deployment_stateless_routes.py @@ -11,6 +11,7 @@ from __future__ import annotations +from datetime import datetime from pathlib import Path import pytest @@ -131,6 +132,7 @@ def test_two_session_report_markdown_and_fhir(self): assert "F5 인계 요약 보고서" in data["report_markdown"] assert data["fhir_bundle"]["resourceType"] == "Bundle" assert data["fhir_bundle"]["type"] == "document" + assert datetime.fromisoformat(data["fhir_bundle"]["timestamp"]).utcoffset() is not None assert data["pdf_base64"] is None assert data["chart_pngs_base64"] == {} diff --git a/apps/ai-server/tests/test_f5.py b/apps/ai-server/tests/test_f5.py index bab09a6..98362cf 100644 --- a/apps/ai-server/tests/test_f5.py +++ b/apps/ai-server/tests/test_f5.py @@ -32,6 +32,7 @@ ) from src.schemas.ai_predicted_disease import AIPredictedDiseaseCandidate, AIPredictedDiseaseOutput from src.schemas.longitudinal import CTRSSeriesPoint, LongitudinalAnalysisOutput, ScaleSeriesPoint +from src.services.f5_markdown import inline_literal from src.services.f5_report import build_markdown_report # ── Fixtures modeled on real VP-001/VP-003 EXP-023 shapes ────────────── @@ -173,6 +174,7 @@ def _longitudinal( def _build_input( *, + generated_at: str = "2026-01-15T12:00:00+09:00", session: SessionSnapshot | None = None, current_session_f3: F3Administration | None = None, all_f3_administrations: tuple[F3Administration, ...] = (), @@ -186,6 +188,7 @@ def _build_input( ) -> HandoffReportInput: return HandoffReportInput( vp_id="VP-TEST", + generated_at=generated_at, session=session or _session(), current_session_f3=current_session_f3, all_f3_administrations=all_f3_administrations, @@ -204,6 +207,52 @@ def _build_input( ) +build_test_input = _build_input + + +class TestDeterministicAssembly: + def test_required_generation_timestamp_is_part_of_the_input_contract(self) -> None: + parameter = inspect.signature(HandoffReportInput).parameters["generated_at"] + assert parameter.default is inspect.Parameter.empty + + def test_identical_input_produces_identical_output(self) -> None: + inp = _build_input() + + first = assemble_handoff_report(inp) + second = assemble_handoff_report(inp) + + assert first.model_dump() == second.model_dump() + + def test_naive_generation_timestamp_is_rejected(self) -> None: + with pytest.raises(ValueError, match="timezone-aware ISO timestamp"): + _build_input(generated_at="2026-01-15T12:00:00") + + def test_utc_z_generation_timestamp_is_accepted_and_preserved(self) -> None: + inp = _build_input(generated_at="2026-01-15T03:00:00Z") + + assert inp.generated_at == "2026-01-15T03:00:00Z" + + def test_malformed_generation_timestamp_is_rejected(self) -> None: + with pytest.raises(ValueError) as exc_info: + _build_input(generated_at="not-a-timestamp") + + assert str(exc_info.value) == "generated_at must be a valid timezone-aware ISO timestamp" + + def test_changing_timestamp_preserves_all_clinical_sections(self) -> None: + first = assemble_handoff_report( + _build_input(generated_at="2026-01-15T12:00:00+09:00") + ).model_dump() + second = assemble_handoff_report( + _build_input(generated_at="2026-01-15T13:00:00+09:00") + ).model_dump() + + first_timestamp = first.pop("generated_at") + second_timestamp = second.pop("generated_at") + + assert first_timestamp != second_timestamp + assert first == second + + # ── Narrative descope (ADR-037 Decision 1) ───────────────────────────── @@ -234,6 +283,54 @@ def test_narrative_enabled_false_default(self) -> None: class TestNarrativeOptIn: + @pytest.mark.parametrize("format_control", ["\u200b", "\u200d", "\u00ad"]) + def test_leak_guard_rejects_disease_split_by_unicode_format_control( + self, format_control: str + ) -> None: + from src.schemas.handoff_report import NARRATIVE_REJECTED_DISEASE_LEAK_KO + + apd = AIPredictedDiseaseOutput( + candidates=[ + AIPredictedDiseaseCandidate( + disease="PTSD", similarity_score=0.5, source_id="case_card:1", quote="q" + ) + ], + mode="rag_live", + ) + + out = assemble_handoff_report( + _build_input( + ai_predicted_disease=apd, + narrative_enabled=True, + narrative_text=f"The patient has P{format_control}TSD symptoms.", + ) + ) + + assert out.a8_narrative.narrative_enabled is False + assert out.a8_narrative.absent_marker == NARRATIVE_REJECTED_DISEASE_LEAK_KO + + def test_format_control_comparison_preserves_clean_short_token_narrative(self) -> None: + apd = AIPredictedDiseaseOutput( + candidates=[ + AIPredictedDiseaseCandidate( + disease="AD", similarity_score=0.5, source_id="case_card:1", quote="q" + ) + ], + mode="rag_live", + ) + clean_text = "The patient h\u00adad insomnia and low mood." + + out = assemble_handoff_report( + _build_input( + ai_predicted_disease=apd, + narrative_enabled=True, + narrative_text=clean_text, + ) + ) + + assert out.a8_narrative.narrative_enabled is True + assert out.a8_narrative.text == clean_text + def test_enabled_with_text_renders_verbatim(self) -> None: out = assemble_handoff_report( _build_input(narrative_enabled=True, narrative_text=" 환자는 수면 문제를 호소함. ") @@ -258,6 +355,136 @@ def test_disease_leak_is_rejected_not_rendered(self) -> None: assert out.a8_narrative.text is None assert out.a8_narrative.absent_marker == NARRATIVE_REJECTED_DISEASE_LEAK_KO + def test_leak_guard_is_case_insensitive(self) -> None: + # Adversarial: A6 candidate "PTSD" must also be caught as "ptsd". + from src.schemas.handoff_report import NARRATIVE_REJECTED_DISEASE_LEAK_KO + + apd = AIPredictedDiseaseOutput( + candidates=[ + AIPredictedDiseaseCandidate( + disease="PTSD", similarity_score=0.5, source_id="case_card:1", quote="q" + ) + ], + mode="rag_live", + ) + out = assemble_handoff_report( + _build_input( + ai_predicted_disease=apd, + narrative_enabled=True, + narrative_text="환자에게서 ptsd 소견이 의심됨.", + ) + ) + assert out.a8_narrative.narrative_enabled is False + assert out.a8_narrative.absent_marker == NARRATIVE_REJECTED_DISEASE_LEAK_KO + + def test_leak_guard_normalizes_fullwidth_unicode(self) -> None: + # Adversarial: fullwidth "PTSD" NFKC-normalizes to "PTSD" and must be caught. + from src.schemas.handoff_report import NARRATIVE_REJECTED_DISEASE_LEAK_KO + + apd = AIPredictedDiseaseOutput( + candidates=[ + AIPredictedDiseaseCandidate( + disease="PTSD", similarity_score=0.5, source_id="case_card:1", quote="q" + ) + ], + mode="rag_live", + ) + out = assemble_handoff_report( + _build_input( + ai_predicted_disease=apd, + narrative_enabled=True, + narrative_text="환자에게서 PTSD 소견이 의심됨.", + ) + ) + assert out.a8_narrative.narrative_enabled is False + assert out.a8_narrative.absent_marker == NARRATIVE_REJECTED_DISEASE_LEAK_KO + + def test_leak_guard_no_false_positive_on_short_latin_substring(self) -> None: + # codex P2: a short Latin candidate ("AD") must NOT match inside an + # ordinary English word ("had") — the opt-in narrative stays ENABLED. + apd = AIPredictedDiseaseOutput( + candidates=[ + AIPredictedDiseaseCandidate( + disease="AD", similarity_score=0.5, source_id="case_card:1", quote="q" + ) + ], + mode="rag_live", + ) + out = assemble_handoff_report( + _build_input( + ai_predicted_disease=apd, + narrative_enabled=True, + narrative_text="The patient had insomnia and low mood.", + ) + ) + assert out.a8_narrative.narrative_enabled is True + assert out.a8_narrative.text is not None + + def test_leak_guard_catches_standalone_latin_and_korean_particle(self) -> None: + # A real leak must still be caught: a standalone Latin token, and a CJK + # disease name followed by a Korean particle (우울증 in 우울증이). + from src.schemas.handoff_report import NARRATIVE_REJECTED_DISEASE_LEAK_KO + + apd_latin = AIPredictedDiseaseOutput( + candidates=[ + AIPredictedDiseaseCandidate( + disease="AD", similarity_score=0.5, source_id="case_card:1", quote="q" + ) + ], + mode="rag_live", + ) + latin = assemble_handoff_report( + _build_input( + ai_predicted_disease=apd_latin, + narrative_enabled=True, + narrative_text="환자는 AD 소견을 보임.", + ) + ) + assert latin.a8_narrative.narrative_enabled is False + assert latin.a8_narrative.absent_marker == NARRATIVE_REJECTED_DISEASE_LEAK_KO + + apd_ko = AIPredictedDiseaseOutput( + candidates=[ + AIPredictedDiseaseCandidate( + disease="우울증", similarity_score=0.5, source_id="case_card:1", quote="q" + ) + ], + mode="rag_live", + ) + korean = assemble_handoff_report( + _build_input( + ai_predicted_disease=apd_ko, + narrative_enabled=True, + narrative_text="환자는 우울증이 의심됨.", + ) + ) + assert korean.a8_narrative.narrative_enabled is False + assert korean.a8_narrative.absent_marker == NARRATIVE_REJECTED_DISEASE_LEAK_KO + + def test_leak_guard_strips_candidate_whitespace(self) -> None: + # codex P1: a candidate with surrounding whitespace ("PTSD ") must still + # be caught — the whitespace must not become part of the boundary regex + # and defeat the A6→A8 isolation guarantee. + from src.schemas.handoff_report import NARRATIVE_REJECTED_DISEASE_LEAK_KO + + apd = AIPredictedDiseaseOutput( + candidates=[ + AIPredictedDiseaseCandidate( + disease="PTSD ", similarity_score=0.5, source_id="case_card:1", quote="q" + ) + ], + mode="rag_live", + ) + out = assemble_handoff_report( + _build_input( + ai_predicted_disease=apd, + narrative_enabled=True, + narrative_text="The patient has PTSD symptoms.", + ) + ) + assert out.a8_narrative.narrative_enabled is False + assert out.a8_narrative.absent_marker == NARRATIVE_REJECTED_DISEASE_LEAK_KO + def test_text_without_any_candidate_disease_name_is_not_rejected(self) -> None: clean_text = "환자는 수면 문제와 무기력감을 자가보고함. 위험 관련 소견은 A3 참조." out = assemble_handoff_report( @@ -464,7 +691,7 @@ def test_bug_043_safety_referral_only_administration_included_in_a3(self) -> Non assert signals[0].critical_item_positive is False md = build_markdown_report(out) - assert "2026-08-01" in md + assert inline_literal(signals[0].simulated_date) in md def test_staleness_pointer_vp003_worked_example(self) -> None: """VP-003 worked example (ADR-037 Decision 2): S11 current @@ -752,6 +979,7 @@ def test_no_domain_inference_at_all_reason_summary_is_none(self) -> None: `HandoffReportInput` directly.""" inp = HandoffReportInput( vp_id="VP-TEST", + generated_at="2026-01-15T12:00:00+09:00", session=_session(), current_session_f3=None, all_f3_administrations=(), diff --git a/apps/ai-server/tests/test_f5_artifact_store.py b/apps/ai-server/tests/test_f5_artifact_store.py new file mode 100644 index 0000000..81f8389 --- /dev/null +++ b/apps/ai-server/tests/test_f5_artifact_store.py @@ -0,0 +1,300 @@ +"""Security and collision contracts for persisted F5 artifact groups.""" + +from __future__ import annotations + +import hashlib +import logging +import os +import stat +import uuid +from pathlib import Path + +import pytest + +import src.services.f5_artifact_store as artifact_store +import src.services.f5_report as f5_report +from src.schemas.handoff_report import HandoffReportOutput +from src.services.f5_artifact_store import F5ArtifactPaths +from src.services.f5_report import save_f5_result +from tests.test_f5_report import _minimal_report + +_STAMP = "20260102_030405" +_TOKEN_A = "a" * 32 + + +class _FixedInstant: + def strftime(self, _format: str) -> str: + return _STAMP + + +class _ClinicalSentinelError(RuntimeError): + """Deterministic clinical-content failure for privacy assertions.""" + + +class _FixedDateTime: + @classmethod + def now(cls) -> _FixedInstant: + return _FixedInstant() + + +class _UuidSequence: + def __init__(self, tokens: list[str]) -> None: + self._tokens = iter(tokens) + self.calls = 0 + + def __call__(self) -> uuid.UUID: + self.calls += 1 + return uuid.UUID(hex=next(self._tokens)) + + +class _ConstantUuid: + def __init__(self, token: str) -> None: + self._value = uuid.UUID(hex=token) + self.calls = 0 + + def __call__(self) -> uuid.UUID: + self.calls += 1 + return self._value + + +class _UuidApi: + def __init__(self, factory: _UuidSequence | _ConstantUuid) -> None: + self._factory = factory + + def uuid4(self) -> uuid.UUID: + return self._factory() + + +def _freeze_names( + monkeypatch: pytest.MonkeyPatch, uuid_factory: _UuidSequence | _ConstantUuid +) -> None: + monkeypatch.setattr(f5_report, "datetime", _FixedDateTime) + monkeypatch.setattr(artifact_store, "uuid", _UuidApi(uuid_factory)) + + +def _digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _all_paths(paths: F5ArtifactPaths) -> tuple[Path, ...]: + pdf = paths.get("pdf") + if pdf is None: + return paths["markdown"], paths["fhir"] + return paths["markdown"], pdf, paths["fhir"] + + +def _artifact_name(vp_id: str, token: str, suffix: str) -> str: + return f"{vp_id}_{_STAMP}_{token}_{suffix}" + + +class TestSecureF5OutputPaths: + @pytest.mark.parametrize( + "vp_id", + ["", ".", "..", "nested/vp", r"nested\vp", "/absolute", "a//b"], + ) + def test_malformed_vp_id_is_rejected(self, tmp_path: Path, vp_id: str) -> None: + root = tmp_path / "root" + root.mkdir() + + with pytest.raises((OSError, ValueError)): + save_f5_result(_minimal_report(), root, vp_id=vp_id) + + def test_absolute_and_traversal_paths_leave_outside_unchanged(self, tmp_path: Path) -> None: + root = tmp_path / "root" + outside = tmp_path / "outside" + root.mkdir() + outside.mkdir() + sentinel = outside / "sentinel.bin" + sentinel.write_bytes(b"outside-original") + before = _digest(sentinel) + + for vp_id in (str(outside), "../outside"): + with pytest.raises((OSError, ValueError)): + save_f5_result(_minimal_report(), root, vp_id=vp_id) + + assert _digest(sentinel) == before + assert set(outside.iterdir()) == {sentinel} + + def test_child_symlink_is_never_followed(self, tmp_path: Path) -> None: + root = tmp_path / "root" + outside = tmp_path / "outside" + root.mkdir() + outside.mkdir() + sentinel = outside / "sentinel.bin" + sentinel.write_bytes(b"outside-original") + (root / "VP-LINK").symlink_to(outside, target_is_directory=True) + + with pytest.raises((OSError, ValueError)): + save_f5_result(_minimal_report(), root, vp_id="VP-LINK") + + assert set(outside.iterdir()) == {sentinel} + assert sentinel.read_bytes() == b"outside-original" + + def test_child_file_is_never_replaced(self, tmp_path: Path) -> None: + root = tmp_path / "root" + root.mkdir() + child = root / "VP-FILE" + child.write_bytes(b"existing-child") + + with pytest.raises((OSError, ValueError)): + save_f5_result(_minimal_report(), root, vp_id="VP-FILE") + + assert child.read_bytes() == b"existing-child" + + def test_authorized_root_symlink_is_resolved_once(self, tmp_path: Path) -> None: + authorized = tmp_path / "authorized" + authorized.mkdir() + root_link = tmp_path / "root-link" + root_link.symlink_to(authorized, target_is_directory=True) + + paths = save_f5_result(_minimal_report(), root_link, vp_id="VP-ROOT-LINK") + + assert set(paths) == {"markdown", "pdf", "fhir"} + assert all(path.resolve().is_relative_to(authorized) for path in _all_paths(paths)) + + def test_output_modes_are_owner_read_write_only(self, tmp_path: Path) -> None: + paths = save_f5_result(_minimal_report(), tmp_path, vp_id="VP-MODE") + + assert {stat.S_IMODE(path.stat().st_mode) for path in _all_paths(paths)} == {0o600} + + +class TestExclusiveArtifactCreation: + def test_identical_timestamp_uuid_collision_retries_full_uuid_group( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + tokens = _UuidSequence([_TOKEN_A, _TOKEN_A, "b" * 32]) + _freeze_names(monkeypatch, tokens) + report = _minimal_report() + first = save_f5_result(report, tmp_path, vp_id="VP-COLLIDE") + first_paths = _all_paths(first) + first_hashes = tuple(_digest(path) for path in first_paths) + + second = save_f5_result(report, tmp_path, vp_id="VP-COLLIDE") + + assert set(first_paths).isdisjoint(_all_paths(second)) + assert all(_TOKEN_A in path.name for path in first_paths) + assert all("b" * 32 in path.name for path in _all_paths(second)) + assert tuple(_digest(path) for path in first_paths) == first_hashes + assert tokens.calls == 3 + + def test_preexisting_final_name_collision_is_preserved_and_partial_reservation_cleaned( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + vp_id = "VP-PARTIAL" + vp_dir = tmp_path / vp_id + vp_dir.mkdir() + collision = vp_dir / _artifact_name(vp_id, _TOKEN_A, "handoff_fhir.json") + collision.write_bytes(b"pre-existing-final") + tokens = _UuidSequence([_TOKEN_A, "b" * 32]) + _freeze_names(monkeypatch, tokens) + + paths = save_f5_result(_minimal_report(), tmp_path, vp_id=vp_id) + + assert collision.read_bytes() == b"pre-existing-final" + assert all("b" * 32 in path.name for path in _all_paths(paths)) + assert not (vp_dir / _artifact_name(vp_id, _TOKEN_A, "handoff.md")).exists() + assert not (vp_dir / _artifact_name(vp_id, _TOKEN_A, "handoff.pdf")).exists() + + def test_collision_retry_is_bounded_at_100_and_preserves_prior_bytes( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + vp_id = "VP-EXHAUST" + vp_dir = tmp_path / vp_id + vp_dir.mkdir() + collision = vp_dir / _artifact_name(vp_id, _TOKEN_A, "handoff.md") + collision.write_bytes(b"pre-existing-final") + token = _ConstantUuid(_TOKEN_A) + _freeze_names(monkeypatch, token) + + with pytest.raises(FileExistsError): + save_f5_result(_minimal_report(), tmp_path, vp_id=vp_id) + + assert token.calls == 100 + assert collision.read_bytes() == b"pre-existing-final" + assert set(vp_dir.iterdir()) == {collision} + + def test_partial_write_failure_removes_only_this_invocations_names( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + vp_id = "VP-WRITE-FAIL" + vp_dir = tmp_path / vp_id + vp_dir.mkdir() + prior = vp_dir / "prior_handoff.pdf" + prior.write_bytes(b"prior-valid-pdf") + before = {path.name: path.read_bytes() for path in vp_dir.iterdir()} + real_write = artifact_store._write_content + calls = 0 + + def _fail_second_write(descriptor: int, content: bytes) -> None: + nonlocal calls + calls += 1 + if calls == 2: + os.write(descriptor, content[:5]) + raise OSError("simulated partial write") + real_write(descriptor, content) + + monkeypatch.setattr(artifact_store, "_write_content", _fail_second_write) + + with pytest.raises(OSError, match="simulated partial write"): + save_f5_result(_minimal_report(), tmp_path, vp_id=vp_id) + + assert {path.name: path.read_bytes() for path in vp_dir.iterdir()} == before + + def test_pdf_failure_log_is_constant_and_prior_pdf_survives( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + first = save_f5_result(_minimal_report(), tmp_path, vp_id="VP-PDF") + prior_pdf = first.get("pdf") + assert prior_pdf is not None + prior_pdf_hash = _digest(prior_pdf) + + def _raise_secret( + _report: HandoffReportOutput, _chart_paths: dict[str, Path] + ) -> bytes: + raise _ClinicalSentinelError("CLINICAL-SECRET-SENTINEL") + + monkeypatch.setattr(f5_report, "build_pdf_report", _raise_secret) + second = save_f5_result(_minimal_report(), tmp_path, vp_id="VP-PDF") + + assert set(second) == {"markdown", "fhir"} + assert _digest(prior_pdf) == prior_pdf_hash + assert "CLINICAL-SECRET-SENTINEL" not in caplog.text + assert all(record.exc_info is None for record in caplog.records) + assert "pdf_status=failed" in caplog.text + + def test_success_log_contains_metrics_but_no_identifier_or_artifact_path( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + vp_sentinel = "CLINICAL-VP-SENTINEL" + root = tmp_path / "CLINICAL-ROOT-SENTINEL" + root.mkdir() + caplog.set_level(logging.INFO, logger=f5_report.__name__) + + paths = save_f5_result(_minimal_report(), root, vp_id=vp_sentinel) + + forbidden = [vp_sentinel, root.name, str(root)] + forbidden.extend(path.name for path in _all_paths(paths)) + forbidden.extend(str(path) for path in _all_paths(paths)) + assert all(value not in caplog.text for value in forbidden) + assert "artifact_count=3" in caplog.text + assert "pdf_status=saved" in caplog.text + assert "markdown_bytes=" in caplog.text + assert "fhir_bytes=" in caplog.text + assert "pdf_bytes=" in caplog.text + + @pytest.mark.parametrize("failure_type", [KeyboardInterrupt, SystemExit]) + def test_persistence_pdf_boundary_propagates_system_exceptions( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure_type: type[KeyboardInterrupt] | type[SystemExit], + ) -> None: + def _raise_system_exception( + _report: HandoffReportOutput, _chart_paths: dict[str, Path] + ) -> bytes: + raise failure_type + + monkeypatch.setattr(f5_report, "build_pdf_report", _raise_system_exception) + + with pytest.raises(failure_type): + save_f5_result(_minimal_report(), tmp_path) diff --git a/apps/ai-server/tests/test_f5_artifact_store_locking.py b/apps/ai-server/tests/test_f5_artifact_store_locking.py new file mode 100644 index 0000000..d0fa0c3 --- /dev/null +++ b/apps/ai-server/tests/test_f5_artifact_store_locking.py @@ -0,0 +1,92 @@ +"""Concurrency contract for one service-owned VP artifact namespace.""" + +from __future__ import annotations + +import fcntl +import os +import stat +import threading +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest + +import src.services.f5_artifact_store as artifact_store +from src.services.f5_artifact_store import F5ArtifactBundle, persist_f5_artifacts + + +def _bundle(marker: bytes) -> F5ArtifactBundle: + return F5ArtifactBundle( + vp_id="VP-LOCK", + timestamp="20260102_030405", + markdown=marker, + fhir=marker, + pdf=marker, + ) + + +def test_vp_namespace_lock_serializes_service_writers( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + first_write_entered = threading.Event() + release_first_write = threading.Event() + second_open_started = threading.Event() + real_open = artifact_store._open_vp_directory + real_write = artifact_store._write_content + first_thread_id: int | None = None + + def _tracked_open(root_descriptor: int, vp_id: str) -> int: + nonlocal first_thread_id + thread_id = threading.get_ident() + if first_thread_id is None: + first_thread_id = thread_id + elif thread_id != first_thread_id: + second_open_started.set() + return real_open(root_descriptor, vp_id) + + def _blocking_write(descriptor: int, content: bytes) -> None: + if threading.get_ident() == first_thread_id and not first_write_entered.is_set(): + first_write_entered.set() + assert release_first_write.wait(timeout=5) + real_write(descriptor, content) + + monkeypatch.setattr(artifact_store, "_open_vp_directory", _tracked_open) + monkeypatch.setattr(artifact_store, "_write_content", _blocking_write) + + with ThreadPoolExecutor(max_workers=2) as executor: + first = executor.submit(persist_f5_artifacts, tmp_path, _bundle(b"first")) + assert first_write_entered.wait(timeout=5) + + probe_descriptor = os.open( + tmp_path / "VP-LOCK", + os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC, + ) + try: + with pytest.raises(BlockingIOError): + fcntl.flock(probe_descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + finally: + os.close(probe_descriptor) + + second = executor.submit(persist_f5_artifacts, tmp_path, _bundle(b"second")) + assert second_open_started.wait(timeout=5) + release_first_write.set() + first_paths = first.result(timeout=5) + second_paths = second.result(timeout=5) + + probe_descriptor = os.open( + tmp_path / "VP-LOCK", + os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC, + ) + try: + fcntl.flock(probe_descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + finally: + os.close(probe_descriptor) + + first_pdf = first_paths.get("pdf") + second_pdf = second_paths.get("pdf") + assert first_pdf is not None + assert second_pdf is not None + first_group = {first_paths["markdown"], first_pdf, first_paths["fhir"]} + second_group = {second_paths["markdown"], second_pdf, second_paths["fhir"]} + assert first_group.isdisjoint(second_group) + assert stat.S_IMODE((tmp_path / "VP-LOCK").stat().st_mode) == 0o700 diff --git a/apps/ai-server/tests/test_f5_exception_cleanup.py b/apps/ai-server/tests/test_f5_exception_cleanup.py new file mode 100644 index 0000000..e78fe0d --- /dev/null +++ b/apps/ai-server/tests/test_f5_exception_cleanup.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import os +import uuid +from pathlib import Path + +import pytest + +import src.services.f5_artifact_cleanup as artifact_cleanup +import src.services.f5_artifact_store as artifact_store +from src.services.f5_artifact_cleanup import ArtifactCleanupError, ReservedArtifact + + +def _reserved_group(vp_dir: Path) -> tuple[int, list[ReservedArtifact]]: + vp_descriptor = os.open(vp_dir, os.O_RDONLY | os.O_DIRECTORY) + names = ("first.md", "second.pdf", "third.json") + descriptors = [ + os.open(vp_dir / name, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + for name in names + ] + reserved = [ + ReservedArtifact(key, name, b"payload", descriptor) + for key, name, descriptor in zip( + ("markdown", "pdf", "fhir"), names, descriptors, strict=True + ) + ] + return vp_descriptor, reserved + + +def test_failed_unlink_is_retried_after_every_descriptor_close( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + vp_dir = tmp_path / "VP-CLEANUP" + vp_dir.mkdir() + vp_descriptor, reserved = _reserved_group(vp_dir) + real_unlink = os.unlink + real_close = os.close + events: list[tuple[str, str | int]] = [] + failed_once = False + + def _transient_unlink(name: str, *, dir_fd: int) -> None: + nonlocal failed_once + events.append(("unlink", name)) + if name == "first.md" and not failed_once: + failed_once = True + raise OSError("private unlink sentinel") + real_unlink(name, dir_fd=dir_fd) + + def _tracked_close(descriptor: int) -> None: + events.append(("close", descriptor)) + real_close(descriptor) + + monkeypatch.setattr(os, "unlink", _transient_unlink) + monkeypatch.setattr(os, "close", _tracked_close) + try: + artifact_cleanup.discard_reserved(vp_descriptor, reserved) + finally: + real_close(vp_descriptor) + + retry_index = max( + index for index, event in enumerate(events) if event == ("unlink", "first.md") + ) + close_indexes = [index for index, event in enumerate(events) if event[0] == "close"] + assert close_indexes and max(close_indexes) < retry_index + assert list(vp_dir.iterdir()) == [] + + +def test_cleanup_attempts_all_resources_and_raises_content_free_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + vp_dir = tmp_path / "CLINICAL-PATH-SENTINEL" + vp_dir.mkdir() + vp_descriptor, reserved = _reserved_group(vp_dir) + real_close = os.close + calls: list[int] = [] + + def _close_then_fail_first(descriptor: int) -> None: + calls.append(descriptor) + real_close(descriptor) + if len(calls) == 1: + raise OSError("CLINICAL-CLOSE-SENTINEL") + + monkeypatch.setattr(os, "close", _close_then_fail_first) + try: + with pytest.raises(ArtifactCleanupError) as raised: + artifact_cleanup.discard_reserved(vp_descriptor, reserved) + finally: + real_close(vp_descriptor) + + assert len(calls) == len(reserved) + assert list(vp_dir.iterdir()) == [] + assert str(raised.value) == "F5 artifact cleanup failed: failure_count=1" + assert raised.value.__cause__ is None + assert raised.value.__context__ is None + + +def test_primary_write_failure_is_preserved_with_count_only_cleanup_note( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + bundle = artifact_store.F5ArtifactBundle( + vp_id="VP-PRIMARY", + timestamp="20260722_120000", + markdown=b"md", + pdf=b"pdf", + fhir=b"fhir", + ) + primary = OSError("CLINICAL-WRITE-SENTINEL") + real_close = os.close + failed_close = False + + def _fail_write(_descriptor: int, _content: memoryview) -> int: + raise primary + + def _close_then_fail_once(descriptor: int) -> None: + nonlocal failed_close + real_close(descriptor) + if not failed_close: + failed_close = True + raise OSError("CLINICAL-CLOSE-SENTINEL") + + monkeypatch.setattr(os, "write", _fail_write) + monkeypatch.setattr(os, "close", _close_then_fail_once) + + with pytest.raises(OSError) as raised: + _ = artifact_store.persist_f5_artifacts(tmp_path, bundle) + + assert raised.value is primary + assert raised.value.__notes__ == ["F5 artifact cleanup failed: failure_count=1"] + assert list((tmp_path / bundle.vp_id).iterdir()) == [] + + +def test_success_path_close_failure_removes_group_and_raises_typed_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + bundle = artifact_store.F5ArtifactBundle( + vp_id="VP-CLOSE", + timestamp="20260722_120000", + markdown=b"md", + pdf=b"pdf", + fhir=b"fhir", + ) + real_close = os.close + real_unlink = os.unlink + failed_regular_close = False + failed_unlink = False + + def _close_then_fail_regular_file(descriptor: int) -> None: + nonlocal failed_regular_close + is_regular = os.path.isfile(f"/dev/fd/{descriptor}") + real_close(descriptor) + if is_regular and not failed_regular_close: + failed_regular_close = True + raise OSError("CLINICAL-CLOSE-SENTINEL") + + def _fail_first_unlink_once(name: str, *, dir_fd: int) -> None: + nonlocal failed_unlink + if not failed_unlink: + failed_unlink = True + raise OSError("CLINICAL-UNLINK-SENTINEL") + real_unlink(name, dir_fd=dir_fd) + + monkeypatch.setattr(os, "close", _close_then_fail_regular_file) + monkeypatch.setattr(os, "unlink", _fail_first_unlink_once) + + with pytest.raises(ArtifactCleanupError) as raised: + _ = artifact_store.persist_f5_artifacts(tmp_path, bundle) + + assert raised.value.failure_count == 1 + assert list((tmp_path / bundle.vp_id).iterdir()) == [] + + +def test_write_base_exception_propagates_after_owned_files_are_removed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + bundle = artifact_store.F5ArtifactBundle( + vp_id="VP-BASE", + timestamp="20260722_120000", + markdown=b"md", + pdf=None, + fhir=b"fhir", + ) + + def _interrupt(_descriptor: int, _content: memoryview) -> int: + raise KeyboardInterrupt + + monkeypatch.setattr(os, "write", _interrupt) + + with pytest.raises(KeyboardInterrupt): + _ = artifact_store.persist_f5_artifacts(tmp_path, bundle) + + assert list((tmp_path / bundle.vp_id).iterdir()) == [] + + +def test_collision_cleanup_failure_aborts_without_overwriting_existing_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + token = uuid.UUID(hex="a" * 32) + bundle = artifact_store.F5ArtifactBundle( + vp_id="VP-COLLISION", + timestamp="20260722_120000", + markdown=b"md", + pdf=b"pdf", + fhir=b"fhir", + ) + prefix = f"{bundle.vp_id}_{bundle.timestamp}_{token.hex}" + vp_dir = tmp_path / bundle.vp_id + vp_dir.mkdir() + collision = vp_dir / f"{prefix}_handoff_fhir.json" + _ = collision.write_bytes(b"existing") + real_unlink = os.unlink + + def _persistent_markdown_unlink(name: str, *, dir_fd: int) -> None: + if name.endswith("_handoff.md"): + raise OSError("CLINICAL-UNLINK-SENTINEL") + real_unlink(name, dir_fd=dir_fd) + + monkeypatch.setattr(uuid, "uuid4", lambda: token) + monkeypatch.setattr(os, "unlink", _persistent_markdown_unlink) + try: + with pytest.raises(ArtifactCleanupError) as raised: + _ = artifact_store.persist_f5_artifacts(tmp_path, bundle) + finally: + _ = real_unlink(vp_dir / f"{prefix}_handoff.md") + + assert collision.read_bytes() == b"existing" + assert set(vp_dir.iterdir()) == {collision} + assert str(raised.value) == "F5 artifact cleanup failed: failure_count=1" + assert "CLINICAL-UNLINK-SENTINEL" not in str(raised.value) diff --git a/apps/ai-server/tests/test_f5_fhir.py b/apps/ai-server/tests/test_f5_fhir.py index 85f5ea4..cb4f4c9 100644 --- a/apps/ai-server/tests/test_f5_fhir.py +++ b/apps/ai-server/tests/test_f5_fhir.py @@ -144,6 +144,7 @@ def _report( ) inp = HandoffReportInput( vp_id="VP-TEST", + generated_at="2026-01-15T12:00:00+09:00", session=session, current_session_f3=current_f3, all_f3_administrations=(prior_f3, current_f3), @@ -176,6 +177,7 @@ def _empty_report(): ) inp = HandoffReportInput( vp_id="VP-MIN", + generated_at="2026-01-15T12:00:00+09:00", session=session, current_session_f3=None, all_f3_administrations=(), @@ -206,6 +208,26 @@ def test_validate_passes_minimal_report(self) -> None: violations = validate_fhir_bundle(bundle) assert violations == [], violations + def test_patient_id_falls_back_to_vp_id_when_persona_id_empty(self) -> None: + # Regression: empty A0 persona_id must not yield an invalid Patient id="". + report = _report() + report.a0_header.persona_id = "" + + bundle = build_fhir_bundle(report) + + assert validate_fhir_bundle(bundle) == [], validate_fhir_bundle(bundle) + entries = bundle["entry"] + assert isinstance(entries, list) + patient = next( + resource + for entry in entries + if isinstance(entry, dict) + and isinstance(resource := entry.get("resource"), dict) + and resource.get("resourceType") == "Patient" + ) + assert patient["id"] == report.vp_id + assert patient["id"] != "" + def test_all_full_urls_unique(self) -> None: bundle = build_fhir_bundle(_report()) full_urls = [e["fullUrl"] for e in bundle["entry"]] @@ -413,6 +435,7 @@ def _report_with_apd(self, apd: AIPredictedDiseaseOutput): ) inp = HandoffReportInput( vp_id="VP-TEST", + generated_at="2026-01-15T12:00:00+09:00", session=session, current_session_f3=None, all_f3_administrations=(), @@ -465,6 +488,7 @@ def _report_no_departments(self, *, validation_errors_present: bool): ) inp = HandoffReportInput( vp_id="VP-TEST", + generated_at="2026-01-15T12:00:00+09:00", session=session, current_session_f3=None, all_f3_administrations=(), @@ -615,3 +639,67 @@ def test_bundle_still_validates_structurally_with_a8_enabled(self) -> None: report = _report(narrative_enabled=True, narrative_text="환자는 수면 문제를 자가보고함.") bundle = build_fhir_bundle(report) assert validate_fhir_bundle(bundle) == [] + + +class TestFhirPrimitiveAndNarrativeValidity: + """Round-1 review blockers: naive timestamps, null primitives, unescaped XHTML.""" + + def test_bundle_timestamp_and_composition_date_are_timezone_aware(self) -> None: + from datetime import datetime + + bundle = build_fhir_bundle(_report()) + assert datetime.fromisoformat(bundle["timestamp"]).tzinfo is not None + comp = bundle["entry"][0]["resource"] + assert datetime.fromisoformat(comp["date"]).tzinfo is not None + + def test_absent_ctrs_omits_value_integer_with_data_absent_reason(self) -> None: + bundle = build_fhir_bundle(_empty_report()) + ctrs_obs = [ + e["resource"] + for e in bundle["entry"] + if e["resource"].get("resourceType") == "Observation" + and any(c.get("code") == "ctrs" for c in e["resource"]["code"].get("coding", [])) + ] + assert ctrs_obs, "CTRS observation missing entirely" + obs = ctrs_obs[0] + assert "valueInteger" not in obs + assert obs.get("dataAbsentReason"), "absent CTRS must carry dataAbsentReason" + + def test_narrative_divs_are_wellformed_xml_with_escaped_clinical_text(self) -> None: + import xml.etree.ElementTree as ET + + session = SessionSnapshot( + session_id="f1_VP-XML", + persona_id="VP-XML", + persona_name="김검증", + session_index=1, + simulated_date="2026-01-01", + model="solar-pro3", + final_slots={ + "chief_complaint": "불안 & 수면 <3시간, 기록: ", + }, + session_ctrs=3, + crisis_triggered=False, + crisis_turn=None, + risk_floor=None, + probe_event_count=0, + ) + inp = HandoffReportInput( + vp_id="VP-XML", + generated_at="2026-01-15T12:00:00+09:00", + session=session, + current_session_f3=None, + all_f3_administrations=(), + domain_inference=DomainInferenceSnapshot( + ai_predicted_disease=AIPredictedDiseaseOutput( + candidates=[], mode="experimental_unpopulated" + ) + ), + longitudinal=LongitudinalAnalysisOutput(vp_id="VP-XML", n_sessions=1), + ) + bundle = build_fhir_bundle(assemble_handoff_report(inp)) + comp = bundle["entry"][0]["resource"] + for section in comp["section"]: + div = section["text"]["div"] + ET.fromstring(div) + assert "", + "history_of_present_illness": "수면 문제\n\n## 위조된 진료 지시\n복약 중단", + "family_history": "약물A | 약물B | 약물C | 약물D | 약물E", + }, + session_ctrs=3, + crisis_triggered=False, + crisis_turn=None, + risk_floor=None, + probe_event_count=0, + ) + inp = HandoffReportInput( + vp_id="VP-INJ", + generated_at="2026-01-15T12:00:00+09:00", + session=session, + current_session_f3=None, + all_f3_administrations=(), + domain_inference=DomainInferenceSnapshot( + ai_predicted_disease=AIPredictedDiseaseOutput( + candidates=[], mode="experimental_unpopulated" + ) + ), + longitudinal=LongitudinalAnalysisOutput(vp_id="VP-INJ", n_sessions=1), + **kw, + ) + return assemble_handoff_report(inp) + + def test_clinical_text_cannot_forge_headings(self) -> None: + md = build_markdown_report(self._adversarial_report()) + assert not any(line.startswith("## 위조된") for line in md.splitlines()) + + def test_raw_html_is_neutralized(self) -> None: + md = build_markdown_report(self._adversarial_report()) + assert "