Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c7270fb
fix(f5): round-1 review remediation — risk-cap max, strict RiskEvent,…
MoerAI Jul 21, 2026
0d4695a
fix(f5): address codex review round-2 — F4/F5 same-ledger-snapshot co…
MoerAI Jul 21, 2026
2115f97
fix(f5): address codex review round-3 — F5 stale-longitudinal gate, c…
MoerAI Jul 21, 2026
20c77d1
fix(f5): codex review round-4 (part A) — partial-export exit code + s…
MoerAI Jul 21, 2026
44d22c1
fix(f5): codex review round-4 (part B) — split PDF vs Markdown saniti…
MoerAI Jul 21, 2026
1d188fa
fix(f5): codex review round-5 — unique per-run F5 prefix + RiskEvent …
MoerAI Jul 21, 2026
2c355f6
fix(f5): codex review round-6 — token-boundary A8 leak guard + RiskEv…
MoerAI Jul 21, 2026
251fb60
fix(f5): codex review round-7 — strip A8 leak-guard candidate whitespace
MoerAI Jul 21, 2026
0c09f7d
fix(handoff): make risk event omission type-safe
MoerAI Jul 21, 2026
132f523
fix(f5): bind reports to the exact F4 artifact
MoerAI Jul 21, 2026
32bd589
fix(f5): harden narrative isolation and markdown literals
MoerAI Jul 21, 2026
f43a297
fix(handoff): honor shared generate contract and gate report narrative
MoerAI Jul 21, 2026
235e57c
fix(f5): secure exclusive artifact persistence
MoerAI Jul 21, 2026
bcbd713
test(f5): align isolation checks with literal markdown
MoerAI Jul 21, 2026
3f1f396
fix(types): preserve typed handoff boundaries
MoerAI Jul 21, 2026
3e6028d
fix(handoff): enforce grounded contract across provider tiers
MoerAI Jul 21, 2026
758ae34
test(handoff): align risk validation with the official request
MoerAI Jul 21, 2026
ae56aff
fix(f5): inject report generation timestamp
MoerAI Jul 21, 2026
ba43eed
refactor(f5): extract typed safety boundaries
MoerAI Jul 21, 2026
236fe81
fix(f5): centralize safe PDF failure handling
MoerAI Jul 21, 2026
0667d8f
docs(handoff): reconcile live contracts and review policy
MoerAI Jul 21, 2026
05dd774
fix(types): tighten risk input boundaries
MoerAI Jul 21, 2026
c921a35
fix(handoff): enforce extractive provider safety
MoerAI Jul 21, 2026
efea8f9
fix(f5): confusable/XML-safe report boundaries and exhaustive cleanup
MoerAI Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -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/`

## 통신 아키텍처
Expand All @@ -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 표나 완료 표시는 해당 문서에 기록된 증거 범위 안에서만 해석한다.
36 changes: 22 additions & 14 deletions apps/ai-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
|-----------|-----|-----------|
Expand All @@ -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` | 감정 분석 | 모델 설정에 따름 |

## 본 폴더에서 하지 않는 것

Expand All @@ -25,32 +32,33 @@
- 환자 식별정보 처리 — 가명처리된 텍스트만 받는다고 가정
- 감사 로그 작성 — 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
```

## 외부 의존성

- 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)의 현재 구현·검증 상태를 따른다.

## 배포

Expand Down
197 changes: 197 additions & 0 deletions apps/ai-server/src/agents/handoff_contract_generator.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading