vignette/docs/guides/architecture.md
2026-06-27 19:27:34 +09:00

560 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 아키텍처 가이드
Vignette(AI 심리상담 시뮬레이션 훈련 플랫폼)의 시스템 아키텍처를 실제 코드 기준으로 정리한 문서다.
모든 서술은 저장소의 실제 파일을 근거로 하며, 인용 경로는 저장소 루트(`D:/workspace/vignette`) 기준 상대경로다.
> 대상 독자: 백엔드/프론트 기여자, 신규 합류자, 운영자.
> 같이 보면 좋은 문서: `docs/MASTERPLAN.md`, `docs/MEMORY_KNOWLEDGE_PERSONA_DESIGN.md`, DB DDL `infra/db/init/*.sql`.
---
## 1. 한눈에 보기
학습자(상담수련생)가 AI 가상내담자 페르소나와 상담 회기를 진행하고, 회기 종료 후 평가 AI가
만든 리뷰 피드백을 받는다. 시스템은 **3종 AI 역할**을 분리한다.
- **내담자 AI(client)** — 페르소나를 연기. 내부 설정(CCD·진단 차원·상태 수치)은 *행동으로만* 드러냄.
- **상담사 AI(counselor)** — 데모/self-play용(스키마·프로필 존재, 런타임 핵심 경로는 인간 학습자가 상담사 역할).
- **평가 AI(evaluator)** — 슈퍼바이저. 학습자 발화를 4차원 태깅(fast-loop) + 회기말 정밀평가(deep-loop).
핵심 설계 원칙:
- **결정론과 LLM의 분리**: 단계 전이·개방도(openness)·저항 수치는 백엔드가 결정론으로 소유하고,
LLM이 절대 만지지 않는다(`services/state_machine.py`). LLM은 "연기"와 "평가"만 한다.
- **하드 안전 게이트**: PII 마스킹 후에만 외부 LLM 경로로 텍스트가 나가고(`services/guardrail.py`),
자살 수단/방법 정보는 출력 가드레일이 차단한다.
- **주입형(hook) 경계**: 오케스트레이터는 평가/로깅 함수를 *주입*받는다. `services/orchestrator.py`
`services/evaluator.py`를 import 하지 않는다(소유권 분리).
- **degraded 폴백**: DB(단일 SoR)가 없어도 `app/store.py` in-memory 미러로 1턴이 돈다.
### 1.1 모노레포 레이아웃
```
vignette/
├─ apps/
│ ├─ api/ FastAPI 백엔드 (Python)
│ │ ├─ app/ 라우터·서비스·스토어·DB·인증
│ │ └─ engine_gateway/ 별도 서비스: claude -p 상주 풀 / provider 어댑터
│ └─ web/ React 19 + Vite + Playwright 프론트
├─ infra/ docker-compose(db+api), DB 초기화 SQL
├─ docs/ 설계·운영 문서 (이 파일 포함)
└─ scripts/ 운영 스크립트(PowerShell 등)
```
### 1.2 런타임 토폴로지
```
┌──────────────────────────────────────────┐
브라우저 │ apps/web (Vite dev :5173) │
(학습자/교수/ │ /learn /teach /admin /settings │
관리자) │ api.ts: fetch(credentials:include) + SSE │
└───────────────┬──────────────────────────┘
│ /api → 프록시(프리픽스 제거)
┌──────────────────────────────────────────┐
│ apps/api app.main:app (uvicorn :8000) │
│ routes/ → services/ → engine_client │
└───┬───────────────┬───────────────┬───────┘
│ HTTP(ENGINE_URL)│ asyncpg 풀 │ httpx(OpenAI)
▼ ▼ ▼
engine_gateway Postgres16 OpenAI
(:9099 등 host) + pgvector STT/TTS
claude -p 상주풀 (app/audit/kb/ds) (voice 캐스케이드)
▼ subprocess
claude CLI (Opus 4.8) — ENGINE_MODE=claude_cli
```
- 프론트는 `apps/web/src/lib/api.ts`에서 모든 요청에 `credentials:"include"`를 붙여 BFF
HttpOnly 쿠키(`__Host-vignette_sid`)를 전송한다. SSE는 `EventSource`가 아니라
`fetch` 스트림으로 직접 파싱한다(`openSessionStream`).
- 백엔드는 엔진 게이트웨이를 `ENGINE_URL`로 HTTP 호출만 한다(`app/engine_client.py`).
게이트웨이가 provider 라우팅·캐싱·상주 프로세스 풀을 흡수한다.
---
## 2. 백엔드(apps/api/app)
### 2.1 앱 엔트리포인트 — `app/main.py`
`lifespan`(startup/shutdown)에서:
1. `init_pool()` → DB 풀 생성, `ensure_runtime_tables()` / `ensure_review_tables()` 보장.
2. `settings.auto_seed_personas``materialize_seed_personas()`로 시드 P1~P3과 저장소 `data/personas/P4~P7.json``app.persona_card`에 upsert.
3. `engine_client.startup()` / `voice_service.startup()`로 httpx 클라이언트 준비.
4. **DB 초기화 실패 시** `environment == "dev"`이면 예외를 삼키고 경고만 남긴 채 degraded 기동한다
(그 외 환경은 raise). `app/main.py:47-53`.
등록 라우터(`app/routes/`): `auth, admin, personas, sessions, teacher, users, eval, voice, kb`.
`GET /health`는 liveness + DB readiness(`db.healthcheck()`) + 엔진 게이트웨이 readiness
(`engine_client.health_detail()`)를 합쳐 `{"status": "ok|degraded", db, engine, engine_mode, ...}`를 반환한다.
### 2.2 턴 오케스트레이터 — `app/services/orchestrator.py`
상담 1턴 파이프라인을 1~8단계로 조립한다. 모듈 상단 docstring에 단계가 그대로 명시돼 있다.
| 단계 | 내용 | 소유 |
|---|---|---|
| 1 | 입력 가드레일 — PII 마스킹 + 위기분류 | `guardrail` |
| 2 | 상태머신 — `effective_openness` 결정론 계산 + 단계 전이 | `state_machine` |
| 3 | 페르소나 컨텍스트 — L0~L6 messages 조립 | `persona` |
| 4 | 내담자 AI 생성(CCD 비노출) | `engine_client` |
| 5 | 출력 가드레일 — 자살수단 차단, ideation 상한 | `guardrail` |
| 6 | 평가 훅(주입형) | `eval_hook` |
| 7 | 상태 갱신(체크포인트는 호출부가 DB/store에 반영) | 호출부 |
| 8 | 로깅 훅(주입형, turns insert/임베딩) | `log_hook` |
함수 경계:
- **`prepare_turn(...)`** (1~3단계, 순수함수, IO/LLM 없음): `TurnContext`를 만든다.
- `guardrail.mask_pii(learner_text)`로 마스킹 → `ctx.learner_text_masked`.
- `guardrail.classify_crisis(...)`로 위기 분류 → `ctx.crisis`.
- 라포 신호는 `eval_rapport_signal`(평가 AI 신호)이 있으면 그것을, 없으면
`state_machine.estimate_rapport_signal(...)` 휴리스틱을 쓴다.
- `state_machine.evolve(...)`로 새 상태 산출 → `ctx.state_after`.
- `persona.build_turn_messages(...)`로 L0~L6 `EngineMessage[]` 조립.
- 회상/핀/직전 턴 등 *주입 텍스트도 전부 다시 마스킹*한다(`_mask_optional_text` 등).
- **`run_turn_generate(ctx, engine, *, eval_hook=None, log_hook=None)`** (4~8, 동기/폴백/테스트 경로):
`engine.generate()`로 응답 한 번에 수신 → `guardrail.sanitize_client_reply()` → 수단정보 누출 시
안전 대체 응답("…(말을 잇지 못하고 잠시 침묵한다)")으로 치환 → eval/log 훅 순차 적용 → `TurnResult` 반환.
훅 예외는 모두 비치명적으로 흡수(상담 루프를 막지 않음).
- **`run_turn_stream(ctx, engine, *, log_hook=None)`** (4~8, 기본 UX 경로):
게이트웨이 SSE 원시 라인을 받아 `token | done | safety | error`로 재방출.
출력 가드레일은 *누적 텍스트* 기준으로 수단정보를 스캔하고, 발견 시 `safety` 이벤트 + 안전 대체로 종결한다.
`TurnContext`/`TurnResult`/`StreamEvent` dataclass가 파이프라인을 관통한다. `EvalHook`/`LogHook`
`Callable[[TurnContext, str], Awaitable[...]]` 타입으로 주입된다.
### 2.3 페르소나 메시지 빌더 — `app/services/persona.py` (L0~L6)
`build_turn_messages(card, state, learner_text_masked, ...)`가 한 턴의 `EngineMessage[]`를 조립한다.
레이어 구조(논리 레이어, docstring):
| 레이어 | 내용 | cache_control | 비고 |
|---|---|---|---|
| L0 | 역할 + 안전 가드레일 + 도식노출금지(`L0_SAFETY`) | ✅ | 전 페르소나 공통, 캐시 대상 |
| L1 | 페르소나 카드(정적, CCD 포함) | ✅ | `build_persona_system_text()`로 L0와 합쳐 1개 system |
| L2 | RAG 임상청크 / 회상 + KB 행동단서 | ✅ | 회기 내 1회 로드(캐시 친화) |
| L3 | 상태머신 주입(stage, openness, resistance, ideation) | ❌ | 수치는 내부용, 발화 표면화 금지 |
| L4 | 메모리 버퍼(pinned fact hard-pin) | ❌ | "자기 기억"으로만 표현 |
| L6 | 직전 K턴 맥락(히스토리) + 이번 발화(L5) | ❌ | 상담자=user, 내담자(자기)=assistant 매핑 |
메시지 순서: `system(L0+L1, cache)``system(L2, cache)``system(L3)``system(L4)`
assistant/user 히스토리(L6) → `user(이번 마스킹 발화, L5)`. 게이트웨이는 마지막 user를 stdin으로 주입한다.
**안전 불변식**(`L0_SAFETY`, docstring R4/R5/M6):
- CCD(core_belief/automatic_thought/coping)·DSM 차원·정답 라벨은 *행동으로만* 드러낸다.
"제 핵심신념은…" 같은 메타 발화 절대 금지(추론 훈련 무력화 차단).
- 자살·자해의 구체적 '방법/수단'은 절대 발화하지 않는다.
- `_format_openness_directive()``effective_openness` 수치를 연기 강도 지시문으로 환산한다(수치 자체는 비노출).
시드 페르소나(개발 부트스트랩): `SEED_PERSONAS = {P1, P2, P3}`와 저장소 `data/personas/P4~P7.json``persona_repository.built_in_personas()`.
| 코드 | 인물 | 난이도 | 이론타깃 | 특징 |
|---|---|---|---|---|
| P1 | 서연(고2) | hard | humanistic | 우울/자살사고(ideation_stage=2), 비자발·고저항(base 0.7) |
| P2 | 민재(32) | moderate | cbt | 범불안/신체화, 자살사고 없음(ideation 1) |
| P3 | 지우(28) | moderate | humanistic | 미혼모 역할부담·소진, 라포/무조건적 존중 연습용 |
| P4 | 하늘(고2) | easy | cbt/humanistic | 학업/시험 불안, 완벽주의·자동사고 탐색 연습용 |
| P5 | 도윤(중3) | moderate | humanistic/cbt | 또래관계 갈등·소외감, 거절민감성·라포 형성 연습용 |
| P6 | 하린(고3) | moderate | humanistic/cbt | 진로갈등(부모기대 vs 본인욕구), 가치 탐색·인지왜곡 탐색 연습용 |
| P7 | 도현(고3) | hard | humanistic/cbt | 입시 번아웃·무기력, 저항 높은 내담자 라포와 무망감 인지 다루기 연습용 |
### 2.4 결정론 상태머신 — `app/services/state_machine.py`
LLM 아님. 순수함수 + 작은 dataclass `SessionState`.
- 단계(`Stage`): `라포 → 탐색 → 개입 → 정리` (선형, 역행 없음). `STAGE_ORDER`.
- 단계 전이(`next_stage`): **최소 체류 턴(`STAGE_MIN_TURNS`) + 누적 라포 임계(`STAGE_ADVANCE_RAPPORT`)**
동시 충족 시 다음 단계. `CLOSE`는 종착(명시 종료).
- 개방도 공식(`compute_effective_openness`):
`effective_openness = clamp(stage_base + rapport_credit*unlock_rate resistance*decay, 0, 1)`.
- 라포 신호 휴리스틱(`estimate_rapport_signal`): 공감/반영/타당화/개방질문 키워드는 +,
조언점프/평가/유도/당위는 . **경량 추정**일 뿐, 정밀 4차원 채점은 평가 AI 소유.
- `evolve(state, rapport_signal, unlock_rate, decay_floor, ideation_observed=None)`:
① rapport_credit 누적(부정 신호는 더 크게 차감 → "닫힘" 재현) ② resistance 완화/강화
③ 개방도 재계산 ④ 단계 전이 판정 ⑤ **ideation_stage 보수적 유지(절대 내려가지 않음, 안전 R5)**.
- `init_state(..., carry=None)`: 이전 회기 `end_state`가 있으면 결정론 carry-over
(라포 ×0.7 이월, resistance drift, ideation 보수적 유지).
수치는 무손실로 carry-over 된다(`SessionState.snapshot()`). `app.session_state` DDL의 제약
(`effective_openness 0~1`, `ideation_stage 1~5`)과 정합한다.
### 2.5 가드레일 — `app/services/guardrail.py`
세 가지 순수 함수 경계:
1. **입력 PII 마스킹** `mask_pii(text) -> MaskResult`: Presidio가 설치돼 있으면 우선 사용,
미설치면 정규식 폴백(`_PII_PATTERNS`: 주민번호/휴대폰/전화/이메일/장문 숫자열). 마스킹본만
저장·외부 LLM 전송에 쓴다(하드 게이트, F-03). Presidio는 지연 로드 캐시(`_try_load_presidio`).
2. **위기 분류** `classify_crisis(text, speaker_is_persona_context=True) -> CrisisResult`:
가상내담자의 자살사고 *연기*는 시뮬레이션 정상(`PERSONA_PLAY`, escalate=False). 그러나
1인칭 실제 단서(`_FIRST_PERSON_NOW`)가 강하면 **수련생 본인의 실제 위기**(`LEARNER_REAL`,
escalate=True)로 보수적 승격.
3. **출력 가드레일** `sanitize_client_reply(text, ideation_stage) -> OutputGuardResult`:
자살/자해 수단·방법 패턴(`_MEANS_TERMS`)이 있으면 `needs_regeneration=True`(차단·재생성 신호).
`ideation_stage > IDEATION_STAGE_CAP(3)`이면 상한 위반 기록. `clamp_ideation()`로 안전 상한 강제.
> 위기분류 에스컬레이션은 DB `app.safety_events`(trigger_type/ko_risk_level/escalated)와 매핑된다.
> 정밀화(Presidio MedicalNER, 한국어 자살콘텐츠 분류기)는 docstring의 Phase 2 TODO로 표기돼 있다.
### 2.6 평가 AI — `app/services/evaluator.py` (deep/fast-loop + make_eval_hook)
평가 AI는 *전부 봐도 된다*(CCD/정답/상태 수치 포함). 비노출은 client AI 책임이고, 학습자에겐
RBAC×AIView로 차단된다. 이 모듈은 평가 신호만 산출한다.
- **fast-loop** `evaluate_turn(ctx, client_reply, engine) -> TurnEvaluation`: 턴 직후 경량 4차원
① technique[] ② client_state_read[] ③ appropriateness(pos/warn/neutral) ④ intent_deviation
(`{dimension, expected, actual, severity}`, "의도와 다른 부분" 1급 시민). `rapport_signal`도 함께
산출해 상태머신에 주입 가능. 게이트웨이 호출에는 `structured_schema=_fast_schema()`를 사용한다.
- **deep-loop** `evaluate_session(...) -> SessionEvaluation`: 단계전환/회기말 정밀평가. 기법 분포
(`aggregate_distribution`, 코드 결정론 집계) + strengths + improvements(최대 3) +
supervisor rationale/critique + alternative_utterances. `_deep_schema()`.
- 정답 라벨 enum은 `app/taxonomy.py`가 단일 원천(SoT). LLM 출력은 enum으로 안전 파싱(미지값 폐기,
`_parse_technique`/`_parse_client_state`). 게이트웨이 structured 우선, 없으면 text에서 JSON 추출
(`_structured_payload`, 코드펜스 관용).
- **엔진/파싱 실패는 비치명적** — `error` 필드에 사유만 남기고 절대 raise 하지 않는다.
- **주입 어댑터** `make_eval_hook(engine)`: orchestrator의 `EvalHook` 시그니처에 맞춘 클로저를 반환.
세션 라우트가 `run_turn_generate(ctx, engine, eval_hook=make_eval_hook(engine_client))` 식으로 주입한다.
⚠️ 의존 방향: evaluator는 `taxonomy/engine_client/orchestrator/state_machine/persona`를 *읽기 전용*으로만
의존하고, orchestrator는 evaluator를 import 하지 않는다(단방향).
### 2.7 회기 메모리 — `app/services/memory.py`
회기 라이프사이클 메모리(4계층 매핑: ① working / ② episodic / ③ summary / ④ semantic).
- 회기 시작: `(persona_id, learner_id)` 안정 `case_profile`을 확보한 뒤
`build_recall_context(...) -> RecallContext`를 조립한다. 현재 동기 seed는 직전
`session_summary` 기반이고, episodic 단편/KB 단서는 백그라운드 warm cache로 붙는다.
**회상 요약엔 CCD·정답·평가가 절대 들어가지 않는다**(내담자 뷰, M6).
DB/RAG가 없으면 인자 None → 빈 RecallContext(첫 회기/in-proc 폴백).
- 회기 종료: `make_carry_over(state, ...) -> CarryOver` — (A) `end_state = state.snapshot()`
무손실 코드 복사(P4) (B) `rapport_delta` (C) 서사 digest는 `CompressionJob`으로 큐잉(LLM 비동기,
여기선 페이로드만). 실제 LLM 호출은 호출부/백그라운드가 수행.
### 2.8 음성 캐스케이드 — `app/services/voice.py` + `app/routes/voice.py`
OpenAI STT/TTS 어댑터(순수 변환 + voice preset 매핑). 상담 로직은 라우트가 조립한다.
- STT: `/audio/transcriptions` (gpt-4o-transcribe → 404 시 whisper-1 폴백), 언어 힌트 `ko`.
- TTS: `/audio/speech` (gpt-4o-mini-tts → tts-1 폴백). 페르소나 preset → OpenAI voice 매핑
(`PRESET_TO_OPENAI_VOICE`, P1=coral / P2=ash / P3=shimmer). 스트림 청크마다 립싱크 RMS 힌트
(`estimate_chunk_rms`, PCM 디코딩 없이 바이트 에너지 근사) 동봉.
- 키 없으면 명확히 degraded(`is_available()=False`, `VoiceUnavailable`). 라우트가 503/WS close로 변환.
`/voice/ws` WebSocket 캐스케이드(`routes/voice.py`):
```
client: audio_start → [binary chunks] → audio_end
server: ready → state(listening) → state(thinking) → transcript → reply
→ state(speaking) → tts_chunk(메타) + [binary audio] → tts_end → state(idle)
```
- 인증: REST와 동일한 서버측 세션 쿠키를 WebSocket 쿠키에서 복원(`_principal_from_websocket`),
learner만 허용.
- 턴 실행은 동일하게 `orchestrator.prepare_turn``run_turn_generate`를 거치고, 응답 생성 *후에만*
발화를 영속화한다(실패한 AI 턴이 학습자 단독 축어록을 남기지 않도록).
- `text_turn` 경로는 접근성/결정론 테스트용 텍스트 전용 경로.
### 2.9 세션 라우트 — `app/routes/sessions.py` (실제 데이터 흐름)
학습자 전용. 모든 세션 작업은 소유권(learner_id)을 검사한다(`_load_session_or_404`).
핵심 엔드포인트:
- `POST /sessions``get_catalog_persona(code)`로 승인 카드 조회 → `case_profile` upsert →
직전 `session_summary` seed recall → `state_machine.init_state(...)`
`session_persistence.create_session(...)`. DB가 없으면
`runtime_fallback_allowed()` 확인 후 `store.create(...)`로 in-proc 생성.
- `POST /sessions/{id}/turn` — 동기 경로. `prepare_turn``run_turn_generate`
학습자 발화 turn(speaker=`counselor`, 원문 `text` + `text_masked`) + 내담자 응답 turn(speaker=`client`)
순차 append → 상태 갱신. `EngineError`는 503으로 변환.
- `POST /sessions/{id}/stream` — SSE 경로(`run_turn_stream`). token/done/ping/error를 흘리고,
done 시점에 학습자 발화 + 누적 내담자 응답을 영속화. `sse_heartbeat_seconds`마다 ping(Cloudflare 타임아웃 회피).
- `POST /sessions/{id}/end``memory.make_carry_over(...)` → 세션 종료 + carry 준비 →
`_schedule_session_evaluation(sess)`로 deep-loop 평가를 비동기 태스크로 발사.
- `GET /sessions/{id}/review` — 저장된 축어록 + 평가 AI 산출물로 학습자-안전 리뷰 구성
(`_rubric_from_evaluation`, `_ai_review_points` 등). 리뷰 조회 시 발화별 fast-loop 평가는
`app.feedback_scores`/라벨 조인 테이블에서 `TurnRecord.evaluation` 형태로 hydrate한다.
평가 미완이면 `degraded/reviewReady=false`로 표기. 학습자가 저장한 사례개념화 워크시트가 있으면
자동 초안보다 `saved_by_learner` 저장본을 우선 반환한다.
- `PUT /sessions/{id}/review/worksheet` — 학습자가 수정한 사례개념화 워크시트를 저장한다.
저장본은 `app.case_worksheet`에 session 단위로 upsert되며, owner learner 또는 teacher/admin RLS 범위에서만 읽힌다.
발화 가시성: 학습자에게는 `visible_to``counselor`가 포함된 턴만 보여준다
(`_learner_visible_turns``_LEARNER_VISIBLE_AI_ROLE="counselor"`). 평가 전용 데이터는 노출되지 않는다.
### 2.10 엔진 클라이언트 — `app/engine_client.py`
게이트웨이 HTTP 클라이언트(호출부만). 계약:
```
POST {ENGINE_URL}/v1/generate — 단발 생성(평가 deep-loop, 회기종료 압축 등)
POST {ENGINE_URL}/v1/stream — SSE 토큰 스트림(내담자 AI 응답)
GET {ENGINE_URL}/ready|/health — readiness/liveness
```
- `EngineMessage(role, content, cache)``cache`는 L0~L2 cache_control 힌트(게이트웨이가 해석).
- `GenerateRequest(ai_role, messages, tier, model, max_tokens, temperature, structured_schema, session_id, metadata)`
`tier`: `client`(Sonnet/Solar) / `feedback`(Opus) / `fast`(Haiku) 라우팅 힌트.
- `session_id`를 넘기면 게이트웨이가 상주 풀을 재사용해 멀티턴 prompt caching 이점을 살린다.
- 장애는 `EngineError`로 전파 → 라우트가 503/SSE error 프레임으로 변환.
- 앱 전역 싱글톤 `engine_client`(lifespan에서 startup/shutdown).
### 2.11 in-memory 스토어 — `app/store.py` (degraded 폴백)
`app.sessions / app.session_state / app.turns`의 최소 in-proc 미러. DB가 붙으면 라우트가 DB 경로로 전환한다.
- `InProcSession`(working + 메타 + 페르소나 핀), `TurnRecord`(append-only 발화, `visible_to` 기본
`(client, counselor, evaluator)`).
- `recent_turns(k, visible_to)`(L6 버퍼), `masked_turns()`(평가/압축 입력)는 항상 마스킹본을 쓴다.
- 단일 프로세스(uvicorn) 가정의 단순 dict. 멀티워커에선 DB가 SoR이므로 무방.
- 영속/폴백 분기는 `app/runtime_policy.py`(`runtime_fallback_allowed` / `require_runtime_fallback_allowed`)가
게이트. 라우트는 `session_persistence`(DB) → 실패 시 store(in-proc) 순으로 시도한다.
### 2.12 페르소나 카탈로그 — `app/persona_repository.py`
DB `app.persona_card`가 승인 페르소나의 SoR. 이 모듈이 in-proc `PersonaCard`와 DB 행을 잇는 경계다.
- `load_file_personas()` / `built_in_personas()` — in-code P1~P3과 저장소 `data/personas/P4~P7.json`을 deterministic catalog로 합친다.
- `materialize_seed_personas()` — built-in P1~P7을 `status='approved'`로 upsert(admin 롤).
- `get_approved_persona(code)` / `list_approved_personas()` — 승인된 최신 버전 조회(AI 컨텍스트).
- `get_catalog_persona(code)` — 승인 카드 조회, 실패 시 `settings.allow_seed_persona_fallback`이면
`seed_fallback_persona`(degraded=True)로 폴백.
- 교수 검수: `list_persona_review_queue(role)` / `update_persona_review_status(action=approve|reject)`
— 승인/반려 시 `audit.audit_log`에 감사 기록.
---
## 3. 엔진 게이트웨이 — `apps/api/engine_gateway/gateway.py`
컨테이너 밖(호스트)에서 도는 **별도 서비스**. `claude -p`(Opus 4.8) 상주 멀티턴 풀을 흡수한다.
회기당 1 `EngineSession` = `claude -p` 프로세스 1개 상주 → 페르소나 system 프롬프트 고정 + 발화마다
stdin 주입(턴 간 컨텍스트 유지 + prompt caching 재사용).
실행:
```
cd apps/api && uvicorn engine_gateway.gateway:app --host 0.0.0.0 --port 9099
```
엔드포인트 2계열:
- **상주 풀** `/session`, `/session/{sid}/turn`, `/session/{sid}`(DELETE) — 회기 단위 프로세스.
- **stateless 어댑터**(engine_client 계약과 정합):
- `POST /v1/generate``EngineMessage[]``_split_messages()`로 (system_prompt, last_user) 분리
`--append-system-prompt`로 주입, 마지막 user를 stdin content로. structured_schema는
`_inject_schema()`로 system에 JSON 준수 지시 주입(claude -p는 response_format 미지원이라 차선).
- `POST /v1/stream``turn_stream()`이 assistant 텍스트 델타를 즉시 yield → SSE
`event: token` / `event: done`(provider/model/cost 메타) / `event: error` 프레이밍.
- `session_id`가 살아있으면 풀 재사용, 아니면 1회성(ephemeral) 세션 생성 후 close(`_resolve_session`).
- `GET /ready``/health`(얕은 프로세스 liveness)와 달리 실제 1턴 생성을 시도해
"설치됐지만 미인증" CLI 상태를 학습자 도달 전에 잡는다(TTL 캐시).
provider 라우팅 모드는 `ENGINE_MODE`(`app/config.py`)로 선택: `claude_api`(기본, Anthropic Messages API
직결) / `claude_cli`(로컬 상주 풀) / `openai`(폴백/평가 보조) / `solar`(국내, PII 민감구간 inference_geo:kr).
> 포트 주의: 게이트웨이 docstring 예시는 `:9099`이고, `app/config.py`의 `engine_url` 기본값은
> compose 서비스명 기반 `http://engine:8100`이다. 로컬에서는 `ENGINE_URL`을 게이트웨이 실제 포트
> (예: `http://127.0.0.1:9099`)로 맞춰 주입한다.
---
## 4. 프론트엔드(apps/web)
React 19 + Vite. 라우팅은 `apps/web/src/App.tsx`(react-router-dom).
| 경로 | 화면 | 역할 가드 |
|---|---|---|
| `/login` | Login (dev-login 경로 포함) | 공개 |
| `/learn` | LearnerHome | learner |
| `/learn/session/:sessionId` | Session(상담 화면) | learner |
| `/learn/session/:sessionId/review` | SessionReview(회기 리뷰) | learner |
| `/learn/avatar-expressions` | AvatarExpressionLab | learner |
| `/teach` | Professor(교수자 대시보드) | teacher |
| `/admin` | Admin | admin |
| `/settings` | Settings | 3역할 공통 |
- `RequireAuth``lib/auth`의 AuthContext로 가드하고, 권한 불일치 시 역할 홈(`roleHomePath`)으로 보낸다.
- API 클라이언트 `src/lib/api.ts`:
- `apiFetch`/`api`(get/post/put/del) — 표준 JSON, 에러는 `ApiError`로 정규화, 항상 `credentials:"include"`.
- SSE: `openSessionStream(sessionId, text, handlers)``fetch` 스트림을 직접 라인 파싱해
`token | done | safety | ping | error` 이벤트를 콜백으로 전달.
- 도메인 헬퍼: `sessionApi`(list/get/start/turn/end/review/stream), `personaApi`, `personaReviewApi`,
`adminApi`, `adminEngineApi`, `teacherApi`, `userApi`. 주요 응답 타입은 FastAPI OpenAPI에서 생성한
`src/lib/api.gen.ts``ApiSchema<...>` alias를 사용하고, generated optional 배열은 화면 렌더링 계층에서
빈 배열 fallback으로 흡수한다. 세션 시작/상세/턴 응답의 stage는 backend `StageLabel`
enum(`라포|탐색|개입|정리`)에서 생성한 union을 `SessionStage`로 사용한다.
- dev 환경: `vite.config``/api``http://127.0.0.1:8000` 프록시(`/api` 프리픽스 제거).
배포 호스트(`vignette.chanpaca.net`, `*.pages.dev`)에서는 `api-vignette.chanpaca.net`을 직접 가리킨다.
세션 화면 흐름(요약): 학습자 발화 입력 → `sessionApi.stream(id, text)`(SSE) 또는 `turn`(동기) →
토큰 누적 표시 → 회기 종료(`end`) → `/review`에서 `sessionApi.review(id)`로 리뷰 표시.
---
## 5. 데이터베이스 스키마 개요
DB는 PostgreSQL 16 + pgvector(단일 SoR). 초기화 SQL은 `infra/db/init/`에 순서대로 적용된다
(`01_extensions → 02_schema → 03_kb → 04_audit_eval_rls → 05_runtime_auth → 06_session_evaluation`).
스키마는 **4분할**: `app` / `kb` / `audit` / `ds`.
### 5.1 app 스키마 (`infra/db/init/02_schema.sql`)
- **인적 주체** `app.app_user` — RBAC 앵커(role: learner/instructor/admin, cohort, consent_at).
- **페르소나** `app.persona_card` — 불변 버전드 카드(`UNIQUE(code, version)`, status draft→review→approved→archived,
ccd/dsm5_dimensional/affect_baseline는 JSONB). `app.persona_voice_map`(provider-agnostic 음성),
`app.counselor_profile`(상담사 AI).
- **라벨 코드테이블**(taxonomy 3축) — `stage_def`, `technique_label_def`, `client_state_def`, `scale_def`.
- **세션** `app.sessions` — case_id/persona_id/persona_version 핀, stage_path. `case_id`
(persona_id, learner_id) 복합 인스턴스 식별.
- **발화** `app.turns` — ② EPISODIC append-only. `text`(마스킹 원문)/`text_masked`(외부전송용),
`visible_to TEXT[]`(기본 `{client,counselor,evaluator}`), salience/is_pinned/contradicts,
음성 paralinguistic(audio_ref/silence_ms/speech_rate/barge_in). `turn_technique`/`turn_client_state`
다대다, `supervisor_comment`(rationale/critique + intent_deviation JSONB), `safety_events`.
- **사례개념화 제출물** `app.case_worksheet` — 회기 리뷰의 축어록 기반 자동 초안을 학습자가 편집해 저장한 JSONB.
`session_id` 단위 upsert이며, `GET /review`에서 자동 초안보다 우선된다.
- **메모리 4계층**:
- ① WORKING `app.session_state` — 상태머신 수치 체크포인트(매 턴 UPSERT, openness/ideation CHECK 제약).
- ② EPISODIC 임베딩 `app.turn_embedding` — BGE-M3 dense `vector(1024)` + sparse, HNSW 인덱스.
- ③ SUMMARY `app.session_summary` — (A) end_state 무손실 carry-over + (B) digest narrative.
- ④ SEMANTIC `app.case_profile`(evolving, `UNIQUE(persona_id, learner_id)`) + `app.pinned_fact`
(+`pinned_fact_history` append-only).
### 5.2 audit 스키마 + app 평가/종단 (`infra/db/init/04_audit_eval_rls.sql`)
- 평가: `app.feedback_scores`(발화별 점수·rationale, `visible_to='{evaluator}'`, loop fast/deep),
`app.turn_technique`/`app.turn_client_state`(fast-loop 라벨 정규화),
`app.supervisor_comment`(intent_deviation). `app.alternative_utterance`는 deep-loop 대안발화 정규화 대상이다.
`session_persistence.append_turn``app.turns INSERT ... RETURNING id`를 확인한 뒤 같은 트랜잭션에서
evaluator 컨텍스트로 fast-loop 평가 rows를 적재한다. 리뷰 경로만 evaluator 컨텍스트로 hydrate한다.
- 종단: `app.learner_profile`(dim_ewma/dim_slope/persistent_gaps — 회기 거듭하며 나아지는지).
- 감사(append-only): `audit.persona_drift_log`(임베딩 일관성/CCD 누출), `audit.audit_log`(인간 열람 추적),
`audit.llm_call_log`(비용·inference_geo·latency metadata-only; prompt/completion 본문 미저장).
### 5.3 ds 스키마 — 재귀학습 파이프라인 (`infra/db/init/04_audit_eval_rls.sql` §4)
AI자동(1R) → 인간검수(2R) → IAA 게이트(κ≥0.6, ICC≥0.75) → 골든셋 → JSONL.
`ds.dataset``ds.dataset_item``ds.annotation_round`(ai_auto/human_review) → `ds.annotation`
`ds.export_manifest`(iaa_kappa/iaa_icc/jsonl_path).
### 5.4 정보비대칭 2-레이어 + RLS
DB 레벨 이중강제(`04_audit_eval_rls.sql` §5, `app/db.py` `acquire()`):
- **레이어1 (AI 정보비대칭)**: `app.ai_context='1'` + `app.current_ai_view`(client/counselor/evaluator)
+ `app.current_sens_max``turns`/`kb.chunk`/`pinned_fact``visible_to[]`/`sensitivity` WHERE 강제.
AIView별 sensitivity 상한 기본값: client=1, counselor=0, evaluator=2(`db._default_sensitivity_max`).
- **레이어2 (인간 RBAC×cohort)**: `app.current_role` + `app.current_uid` + `app.current_cohort`
RLS 정책(학습자=본인 / 교수자=담당 코호트 / 관리자=전부). `feedback_scores`는 학습자 직접열람 차단.
세션 변수는 트랜잭션 내 `SELECT set_config(..., true)`(SET LOCAL 의미)로 주입해 풀 재사용 누수를 막는다
(`app/db.py:94-137`). 애플리케이션 측 RBAC는 `app/deps.py`(`Role`, `AIView`, `Principal`,
`get_current_principal`, `require_role`, `db_for_human`, `db_for_ai_view`)가 담당한다.
---
## 6. 인증/OAuth 경계
- 브라우저는 BFF API에만 로그인한다. Google OAuth는 Authorization Code + PKCE를 사용하고, 완료 후
서버가 opaque HttpOnly 쿠키(`__Host-vignette_sid`)를 발급한다. id/access token은 브라우저에 저장하지 않는다.
- OAuth `state`는 서버 메모리 `_oauth_states`에 호환용으로 보관하지만, 동시에 HMAC 서명 토큰 자체로
`next`, 발급시각, nonce를 검증하고 PKCE verifier를 재계산한다. 시작 응답은 같은 state를 HttpOnly
`__Host-vignette_oauth_state` 쿠키로도 내려주며, callback은 URL state와 쿠키 state가 일치할 때만 복구한다.
그래서 public API가 로그인 시작과 콜백 사이에 재시작돼도 callback은 `invalid_state`로 실패하지 않고
Google token exchange 단계까지 가며, 쿠키 없는 외부 callback 주입은 차단된다.
- OAuth callback 실패는 authorization code, token, raw email을 남기지 않고 reason/status/도메인 수준 정보만
서버 로그에 남긴다. 프론트는 `token_exchange_failed`, `invalid_state`, provider error(`access_denied`/`provider_error`),
identity claim 실패를 구분하고 실패 reason code를 화면에 함께 표시한다.
- 역할은 `AUTH_TEACHER_EMAILS`/`AUTH_ADMIN_EMAILS` email allowlist로 1차 판정한다. 코호트는
`AUTH_EMAIL_COHORT_MAP``AUTH_DOMAIN_COHORT_MAP` 설정, SAML fixture의 `cohort` claim을 합쳐
`cohort_ids`로 세션에 저장한다. 관리 사용자 `app_user.external_id`는 provider subject 기반
(`google:`/`saml:`/`dev:`)으로 저장해 email 변경 리스크를 줄인다.
- 로컬/Tailnet dev는 dev-login을 사용한다. 공개 `OAUTH_REDIRECT_URI`가 로컬/Tailnet 세션이 아니라 public API
세션으로 돌아가는 혼선을 막기 위해 dev-origin의 Google 직접 시작은 `local_oauth_unavailable`로 차단한다.
---
## 7. degraded / 폴백 정책
| 미가용 대상 | 증상 | 동작 |
|---|---|---|
| DB(Postgres) | `/health` `db:false`, status `degraded` | `app/main.py` lifespan이 dev에서 예외 흡수, `store.py` in-proc로 1턴 동작 |
| 엔진 게이트웨이 | `/health` `engine:false` | 턴 생성 실패(503/SSE error). UI/로그인/페르소나/세션생성(in-memory)은 동작 |
| OpenAI 키 미설정 | `/voice/health` `degraded` | 음성 비활성, WS는 degraded 통지 후 정상 close |
| Presidio 미설치 | — | 정규식 PII 마스킹 폴백 |
| 승인 페르소나 조회 실패 | persona `degraded=true` | `ALLOW_SEED_PERSONA_FALLBACK=true`면 시드 카드로 폴백 |
폴백 허용 여부는 `app/runtime_policy.py`가 게이트한다(무분별한 in-proc 사용 방지).
---
## 8. 로컬 실행 (Windows 11 + PowerShell 기준)
> 한글/공백 경로 주의. `apps/api/.env`는 pydantic-settings가 자동 로드한다.
### 7.1 API (DB 없이 degraded 기동 가능)
```powershell
cd D:\workspace\vignette\apps\api
# 시드 페르소나를 in-proc로 쓰려면(개발용):
$env:AUTO_SEED_PERSONAS = "true"; $env:ALLOW_SEED_PERSONA_FALLBACK = "true"
python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload
# 확인: GET http://127.0.0.1:8000/health → {"status":"degraded","db":false,...} (DB 없을 때)
```
dev 인증(`apps/api/.env``AUTH_DEV_LOGIN_ENABLED=true`, `ENVIRONMENT=dev`):
```
POST /auth/dev-login {email, role: learner|teacher|admin, display_name}
→ __Host-vignette_sid 쿠키 발급(웹은 Login.tsx에 dev-login 경로 존재)
```
### 7.2 엔진 게이트웨이 (AI 턴 생성, ENGINE_MODE=claude_cli)
```powershell
cd D:\workspace\vignette\apps\api
python -m uvicorn engine_gateway.gateway:app --host 0.0.0.0 --port 9099
# API의 ENGINE_URL을 게이트웨이 포트로 맞춘다(예: http://127.0.0.1:9099)
```
게이트웨이가 없으면 `/health``engine:false`이고 턴 생성은 실패하지만, UI/네비/로그인/페르소나/
세션생성(in-memory)은 동작한다.
### 7.3 웹
```powershell
cd D:\workspace\vignette\apps\web
npm run dev # http://localhost:5173, /api → 127.0.0.1:8000 프록시
```
### 7.4 테스트
```powershell
# 백엔드
cd D:\workspace\vignette\apps\api
python -m pytest app/ -q # 앱 단위 테스트
python -m pytest engine_gateway/ -q # 게이트웨이 테스트
# 웹
cd D:\workspace\vignette\apps\web
npm run typecheck
npm run build # vite
npm run e2e # Playwright (web+api+DB 스택 필요)
```
### 7.5 Docker compose (DB + API)
`infra/docker-compose.yml`(db: pgvector pg16 + api). Docker Desktop 필요. DB 컨테이너 기동 시
`infra/db/init/*.sql`이 순서대로 적용된다.
---
## 9. 안전 불변식 요약(설계 보증)
- 마스킹되지 않은 원문은 외부 LLM 경로로 절대 나가지 않는다(입력 가드레일 하드 게이트, F-03).
- 내담자 AI는 CCD·진단 차원·정답 라벨·상태 수치를 *말로 설명하지 않는다*(L0 + 출력 가드레일 이중방어, R4).
- 자살·자해 수단/방법 정보는 출력 가드레일이 차단·치환한다. `ideation_stage` 안전 상한은 3(R5).
- 상태머신 수치(stage/openness/resistance/ideation)는 결정론 코드만 만지고 LLM에 위임하지 않으며,
무손실 carry-over 된다(P2/P4).
- 평가/정답/평가 전용 데이터는 RBAC×AIView(레이어1 `visible_to`) + RLS(레이어2)로 학습자 직접 조회에서 차단된다.
단, 서버 리뷰 응답은 소유 세션 확인 후 evaluator 컨텍스트로 필요한 평가 rows만 hydrate해 학습자-안전 형태로 가공한다.
- 평가/로깅 훅 실패는 비치명적으로 흡수되어 상담 루프를 멈추지 않는다.
```