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"")
@@ -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 "