vignette/docs/guides/architecture.md
2026-08-29 16:12:24 +09:00

1162 lines
102 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 하지 않는다(소유권 분리).
- **LLM 감사 경계**: evaluator/orchestrator의 생성 시간·토큰·비용 기록은 `services/llm_audit.py`
`generate_with_audit()`가 소유한다. 생성 성공을 감사 저장 성공으로 오인하지 않고 호출자가 degraded 의미를 결정한다.
- **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+web+proxy), 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 풀 │ HTTPS/WSS voice providers
▼ ▼ ▼
engine_gateway Postgres16 Deepgram/OpenAI/Higgs
(:9099 등 host) + pgvector streaming 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 라우팅·캐싱·상주 프로세스 풀을 흡수한다.
`ENGINE_GATEWAY_SHARED_SECRET`이 설정되면 모든 호출에 `X-Vignette-Engine-Token`을 붙인다.
---
## 2. 백엔드(apps/api/app)
### 2.1 앱 엔트리포인트 — `app/main.py`
`lifespan`(startup/shutdown)에서:
1. `init_pool()` → DB 풀 생성, 런타임 계약 readiness 확인. 개선관리 계약은
`infra/db/init/17_improvement_workbook_contracts.sql`을 owner가 선적용해야 하며,
`protocol_registry.ensure_protocol_tables()``SELECT`로 준비 상태만 확인하고 애플리케이션 역할로 DDL을 실행하지 않는다.
2. `settings.auto_seed_personas``materialize_seed_personas()`로 시스템 페르소나 P1~P3과 저장소 `data/personas/P4~P7.json``app.persona_card`에 누락분만 물리화한다. 기존 DB 저작본/보관본은 덮어쓰거나 되살리지 않는다. 운영자는 `scripts/materialize-persona-seeds.py` dry-run으로 같은 seed/version manifest를 확인하고, 명시적 `--apply`에서만 DB pool을 초기화해 이 materializer를 호출할 수 있다.
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()`) + 기본 엔진 조합과 전용 live-client 공급자의
readiness(`engine_client.health_detail()`)를 합쳐 `{"status": "ok|degraded", db, engine, engine_mode, ...}`를 반환한다.
기본 evaluator 엔진이 준비됐어도 live-client 공급자가 준비되지 않으면 `status=degraded`, `engine=false`다.
DB readiness는 auth/admin 테이블뿐 아니라 세션 read-model 핵심 테이블·컬럼
(`app.sessions`, `app.turns` 음성 메타 컬럼, `app.session_review_status` worksheet 컬럼)을 함께 확인한다.
### 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` 이벤트 + 안전 대체로 종결한다.
세션 라우트는 client 응답과 결정론 상태를 먼저 영속화하고 `done`을 방출한다. fast-loop evaluator는
백그라운드 태스크로 실행해 같은 learner turn의 normalized 평가 row를 교체 저장하며, 평가 기반 코칭 충전도
이 태스크가 한 번만 적용한다. 따라서 보이지 않는 평가 지연이 다음 학습자 전송을 잠그지 않는다.
`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)`. 현재 Python gateway의
`_split_messages()` 경계는 system 묶음과 마지막 user payload만 소비한다. L6의 system 외
history를 실제 프롬프트에 직렬화하는 변경은 별도 프롬프트 동작 패치로 다룬다.
**안전 불변식**(`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()`.
초기 DB 카탈로그를 채우기 위한 원천일 뿐, 승인 이후 편집/보관 결정은 `app.persona_card`가 SSOT다.
| 코드 | 인물 | 난이도 | 이론타깃 | 특징 |
|---|---|---|---|---|
| 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`).
`mask_role_identities()`는 현재 회기의 상담자/학습자 이름을 `[COUNSELOR]`, 페르소나/내담자 이름을
`[CLIENT]`, 그 밖의 이름을 `[NAME]`으로 정규화한다. evaluator·live-coach 입력 전과 구조화 결과 반환 전
모두 적용하며, 선택적 identity 필드는 `getattr(..., None)`으로 안전하게 처리한다.
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_from_response()`, 코드펜스 관용).
- evaluator structured 결과는 `evaluate_turn()`/`evaluate_session()` 경계에서 canonical
`GenerateRequest` SHA-256 키의 인메모리 semantic cache로 재사용할 수 있다. `/admin/usage`
cache key·prompt·completion 없이 enabled/entries/hits/misses/stores/evictions/requests/hit_rate와
일별 `daily_cost` bucket(day, turns, tokens, cost)을 관리자 관측값으로 반환한다. 비용은
공급자가 반환한 저장값을 우선하되 Claude CLI는 SDK 추정치(`provider_estimate`)로 명시하고,
저장 비용이 없는 Agy/Gemini·Codex·Claude API는 `app.services.llm_pricing`의 버전 고정 공식 참조단가(`reference_rate`)로
입력·캐시 입력·출력 토큰을 환산한다. 기존 DB에 비용 0으로 저장된 행도 조회 시 같은 단가로
보정하며, 단가가 없는 모델은 0달러로 위장하지 않고 `unavailable`로 표시한다.
응답은 `recorded_cost_usd`, `estimated_cost_usd`, provider/model별 `cost_basis`,
`rate_label`, `rate_source_url`을 함께 반환하고 예산 상태는 두 비용을 합친 유효 비용을 사용한다.
`EVALUATOR_SEMANTIC_CACHE_ENABLED`, `EVALUATOR_SEMANTIC_CACHE_TTL_SECONDS`,
`EVALUATOR_SEMANTIC_CACHE_MAX_ENTRIES`로 제한하며, 원문 prompt/completion은 캐시에 저장하지 않는다.
cache hit은 `engine.generate()`와 metadata-only `audit.llm_call_log` 기록을 건너뛰고,
engine error·파싱 실패는 캐시하지 않는다.
- **엔진/파싱 실패는 비치명적** — `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.6.1 라이브 코칭 AI — `app/services/live_coach.py`
라이브 코칭은 내담자 생성 루프에 끼워 넣지 않는 별도 슈퍼비전 경로다. `POST /sessions/{id}/stream`
또는 음성 턴이 끝난 뒤, 프론트가 `POST /sessions/{id}/live-coach`를 호출해 "다음 한 문장" 중심의
짧은 코칭을 받는다. 엔진/RAG 장애는 상담 흐름을 막지 않고 워크북 기반 규칙 코칭으로 degrade한다.
- 입력은 `guardrail.mask_pii()` 후 evaluator 역할로 전송한다. 페르소나 CCD·상태 수치·정답키는 학습자에게
노출하지 않는다.
- 기본 근거는 `data/kb/live_coaching_workbook_0615.json``data/kb/live_coaching_sources/*.json`
허가된 0615 워크북·DSM·공식 지침 요약 청크다. 추가 RAG는 evaluator view로
`theory/technique/supervisor_pattern/microskill/taxonomy` 근거만 회수한다.
- 관리자 `POST /kb/live-coach/source-packs/sync`는 같은 source pack을 `kb.source``kb.chunk`
content_hash 기반으로 증분 색인한다. `app.services.source_pack_sync`가 active `kb.document`
hash/version을 먼저 읽고, hash 변경 시 새 document version을 최신+1로 계산한다. 기본 visibility는
evaluator 전용이고, C/D·diagnostic 자료는 sensitivity 2로 고정한다.
- 출력은 `LiveCoachSuggestion` 구조화 JSON(`tone/focus/title/message/next_utterance/rationale/sources/quota/credit_events`)
형태가 아니라 `quota``credit_events`를 포함한 짧은 카드 계약이다. UI는 코칭 아바타 말풍선,
근거 모달, 발화별 코칭 이력 오버레이로 표시한다.
- 회기별 코칭 기회는 기본/최대 3개다. `POST /sessions/{id}/live-coach` 사용 시 `use/-1` 이벤트를 남기고,
충전은 2026-07-14 재설계된 게이트를 쓴다(`turn_runtime.should_recharge_live_coach_credit`):
① 성과 충전 — `appropriateness=pos` + `rapport_signal>=0.15` + (단계 전환 또는 개방도 +0.01),
또는 `neutral` + `rapport_signal>=0.35` + 개방도 상승 ② 페이싱 충전 — 평가 신호와 무관하게
6턴마다 1개(평가 실패여도 충전, 순감 구조 방지). 사용권 없음은 409로 막는다.
- 프론트 트리거: 코칭 화면(`feedbackMode=coached`)이 아닐 때 완료된 턴은 기회를 소모하지 않고
`pendingCoachTurn`으로 대기하며, 컨트롤바 위 컨텍스추얼 넛지 칩("방금 발화에 코치 제안이 있어요")으로
안내한다. 코칭 화면을 열면 대기 턴에 대한 코칭을 즉시 요청한다.
- 코칭 프롬프트에는 이번 회기 목표(`goal_stages`)와 직전 코칭 2건(title/focus)을 주입해
목표 정렬·조언 반복 방지를 유도하고, 규칙 폴백도 단계별 기본 다음 발화를 쓴다.
- `record_llm_call_audit`는 dev degraded(무DB) 기동을 "기록 실패"로 보지 않는다
(`runtime_fallback_allowed()`면 True). durable 환경의 실제 기록 실패만 코칭을 degraded로 강등한다.
- 전달된 코칭과 충전/사용 기록은 `app.live_coach_events`에 저장된다. 원문 축어록을 중복 저장하지 않고
PII 마스킹된 짧은 learner/client excerpt, 코칭 payload, `event_type/credit_delta/credit_balance/reason`
저장한다. DB 미가용 dev에서는 런타임 캐시로 폴백한다.
- DSM/공식 지침/논문은 사용 허가된 source pack으로 투입할 수 있다. 다만 코칭 프롬프트와 UI에는
장문 원문이나 공식 문항을 재현하지 않고, chunk summary + version/citation + 출처 식별자로 노출한다.
### 2.7 회기 메모리 — `app/services/memory.py`
회기 라이프사이클 메모리(4계층 매핑: ① working / ② episodic / ③ summary / ④ semantic).
- 회기 시작: `(persona_id, learner_id)` 안정 `case_profile`을 확보한 뒤
`build_recall_context(...) -> RecallContext`를 조립한다. 동기 seed는
`case_profile.case_digest` + 직전 `session_summary` + client-visible `pinned_fact`
기반이고, episodic 단편/KB 단서는 백그라운드 warm cache로 붙는다.
- 회기 종료: 마스킹된 client-visible 축어록으로 fallback `session_summary.digest`를 만들고,
같은 트랜잭션에서 `case_profile.case_digest`, `rapport_trajectory`, `alliance_level`
갱신한다. 동시에 마스킹된 client-visible 발화에서 `[NAME]`/`[ORG]` identity와 명시적 상담 약속만
보수적으로 `app.pinned_fact`에 upsert한다. pinned fact 삽입 또는 값 변경은
`app.pinned_fact_history`에 append-only 이력을 남기며, 같은 값 재확인과 `locked` fact는
history를 늘리지 않는다. 명시적 상담 약속 철회/부정은 기존 non-locked
`agreement:counseling` fact만 `status='contradicted'`로 격리하고 history reason
`contradiction`을 남긴다. 새 contradicted fact를 임의 생성하거나 관계갈등·위기·임상 추론을
자동 모순 처리하지 않는다. 세션 종료 저장 성공 뒤에는 마스킹된 client-visible 내담자 발화만
background task가 `app.turn_embedding`에 BGE-M3 dense/sparse로 idempotent 색인한다.
LLM digest 압축은 별도 후속이다.
**회상 요약엔 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`
STT/TTS 어댑터(순수 변환 + voice preset 매핑). 상담 로직은 라우트가 조립한다.
- STT provider는 `VIGNETTE_VOICE_STT_PROVIDER=openai|deepgram`이다. OpenAI 경로는 batch
`/audio/transcriptions`(gpt-4o-transcribe → 404 시 whisper-1 폴백), 언어 힌트 `ko`를 사용한다.
Deepgram 경로는 `/v1/listen` WebSocket 한 세션을 발화 중 유지하며 `interim_results`, `Results`,
`speech_final`, word start/end와 allowlisted provider event를 순차 반영한다. 기본 model은 `nova-3`, 언어는
`ko`이며 endpointing/utterance end/keepalive/finalize timeout/MIP opt-out을 env로 조정한다.
Deepgram 선택 상태에서 key가 없고 OpenAI key가 있으면 `openai-batch-fallback`으로 명시 전환한다.
- TTS: `/audio/speech` (gpt-4o-mini-tts → tts-1 폴백). 음성 선택 우선순위는 명시 query preset →
`app.persona_voice_map`의 OpenAI row(`base_params.preset/openai_voice/rate/instructions`) →
페르소나 code 기본 preset(`PRESET_TO_OPENAI_VOICE`, P1=coral / P2=ash / P3=shimmer)이다.
OpenAI가 아닌 provider row는 현재 live OpenAI TTS로 보내지 않고 기존 fallback을 사용한다.
dev 런타임 스키마 보강은 기존 DB의 `app.persona_voice_map` 누락도 복구해 seed materializer와
`/voice/ws` 바인딩이 같은 테이블을 사용하게 한다.
- 로컬 개발에서 `VIGNETTE_VOICE_TTS_PROVIDER=higgs`이면 P1만 loopback Higgs 서버로 합성한다.
`scripts/higgs-tts-server.py`는 설치된 `higgs-audio-v3-tts-4b`를 다운로드 없이 한 번만 GPU에 올리고,
저장소의 무참조 synthetic seed만 reference로 쓴다. `/health`는 model/load time/reference policy를,
`/tts`는 24kHz WAV와 provider/model 헤더를 반환한다. 이 경로는 dev 전용이며 non-dev 설정은 fail-closed다.
프론트는 마이크 클릭과 텍스트 발화 전송 시 `AudioContext`를 먼저 resume해 재생 권한을 확보하고,
TTS Blob을 Web Audio buffer source로 재생하면서 `AnalyserNode`로 립싱크 RMS를 산출한다. 디코딩 실패 시
`<audio>` 재생으로 fallback한다.
- 텍스트 턴의 AI 내담자 응답은 인증된 `POST /voice/speech``session_id`/`turn_seq`로 소유 회기를 다시
로드하고, 이미 저장된 client-visible 내담자 응답만 선택 provider의 오디오로 합성한다. 브라우저가 임의
문장을 보내는 TTS 프록시가 아니며, 마이크 WebSocket과 같은 voice map/TTS 어댑터를 공유한다.
- 사용 가능한 STT/TTS credential이 없으면 명확히 degraded(`VoiceUnavailable`)하고 라우트가 503/WS close로
변환한다. fallback은 `ready.stt_provider/model`에 실제 provider/model을 노출하므로 expected-provider
public preflight를 통과할 수 없다.
`/voice/ws` WebSocket 캐스케이드(`routes/voice.py`):
```
client: audio_start → [binary chunks] → audio_end
server: ready → state(listening) → state(thinking) → transcript → reply
→ state(speaking) → [binary audio] → tts_end → state(idle)
```
- 인증: REST와 동일한 서버측 세션 쿠키를 WebSocket 쿠키에서 복원(`_principal_from_websocket`),
learner만 허용.
- `ready`는 실제 `stt_provider`, `stt_model`, `tts_provider`, `tts_model`과 batch fallback 가능 여부를 반환한다.
public preflight는 마이크 없이 이 네 값을 배포 기대값과 정확히 비교한다.
- Deepgram streaming 중에는 동의 원장을 1초마다 재검사한다. 철회·미동의가 확인되면 streaming session을
abort하고 추가 오디오 provider 전송과 후속 파생 저장을 fail-closed한다.
- 턴 실행은 동일하게 `orchestrator.prepare_turn``run_turn_generate`를 거치고, 응답 생성 *후에만*
발화를 영속화한다(실패한 AI 턴이 학습자 단독 축어록을 남기지 않도록).
- `audio_end.provider_events`와 STT provider 이벤트는 allowlist·size limit 후
`TurnRecord.provider_events`/`app.turns.provider_events`에 보존한다. 저장 전 sanitizer는
내부 taxonomy `event_type`/`category`를 붙이고, raw transcript/text payload는 보존하지 않는다.
인증된 회기 리뷰 API는 정규화 taxonomy 중 일부를 `nonverbal` 칩으로만 파생 노출한다.
provider/source/raw type/text/transcript는 응답하지 않고, 공개 공유 카드는 축어록과 provider raw를 싣지 않는다.
- 텍스트 입력 경로도 턴 저장 완료 뒤 `/voice/speech`를 호출해 AI 내담자 음성을 재생한다. 새 텍스트 발화를
보내면 이전 합성/재생 요청을 무효화해 겹쳐 재생하지 않는다.
- 네트워크/TTS가 끊기면 Session은 작성 중 텍스트를 보존하고 텍스트 계속하기와 키보드 가능한 음성 재연결을
제공한다. 이 recovery는 실패한 provider를 성공으로 위장하지 않는다.
### 2.9 세션 라우트 — `app/routes/sessions.py` (실제 데이터 흐름)
학습자 전용. 모든 세션 작업은 소유권(learner_id)을 검사한다(`_load_session_or_404`).
브라우저-facing 세션 목록/대시보드/상세/리뷰/공유 DTO와 deterministic response builder는
`app/session_read_model.py`가 소유한다. `routes/sessions.py`는 route/auth/RLS DB read/persistence와
session lifecycle을 유지하며, future Node read API는 이 read-model contract를 미러한다.
핵심 엔드포인트:
- `POST /sessions``get_catalog_persona(code)`로 승인 카드 조회 → `case_profile` upsert →
case digest/직전 summary/pinned fact seed recall → `state_machine.init_state(...)`
`session_persistence.create_session(...)`. 생성 시 승인 페르소나의 `persona_id``persona_version`
세션에 고정하고 시작/상세 응답에도 두 값을 반환한다. 이후 더 높은 버전이 승인돼도 기존 회기는 고정된
역사 버전을 해석한다. DB 영속 생성 실패는 안전하지 않은 성공으로 흡수하지 않고 안정적인
`503 session_persistence_unavailable`로 반환한다. 프론트 세션 시작 전 화면은 `persona.theory_target`
기본값을 쓰되 학습자가 `humanistic`/`cbt`/`integrative` 중 하나를 명시 선택해 기존
`theory_mode` 계약으로 보낸다. DB가 없으면
`runtime_fallback_allowed()` 확인 후 `store.create(...)`로 in-proc 생성.
2026-07-13 한신대 회의 P1 반영: 요청에 이번 회기 목표 단계 `goal_stages`(StageLabel 1~4개 —
회의 권장은 2개 수준, 소유자 지시(2026-07-15)로 4개까지 허용. 중복 제거·최대 4 검증,
`app.sessions.session_goals` JSONB 저장)를 받고, 응답에
`started_at`/`goal_stages`/`duration_limit_seconds`/`warning_before_end_seconds`를 돌려준다.
회기 종료는 단계 완수가 아니라 **시간 기반**이다 — 프론트가 60분 타이머·10분 전 알람·만료 시
정리 유도를 주도하고, 서버는 제한+유예(`SESSION_DURATION_MINUTES`+`SESSION_OVERTIME_GRACE_MINUTES`)
초과 시 신규 턴을 `409 session_time_over`로 거부한다(`_ensure_turn_time_allowed`, 음성 WS 동일).
목표를 달성해도 시간 내에는 계속 진행한다(상태머신은 채점·표시용으로만 유지).
- `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 시점에 학습자 발화 + 누적 내담자 응답 + 상태를 먼저 영속화하고 즉시 완료한다. fast-loop 평가는
응답 경로 밖에서 같은 learner turn에 사후 저장한다. provider 연결이 깨끗한 EOF로 닫혀도 canonical
gateway `done`이 없으면 `client_stream_incomplete` 오류로 끝내며 가짜 성공 턴을 저장하지 않는다.
`sse_heartbeat_seconds`마다 ping(Cloudflare 타임아웃 회피).
- `POST /sessions/{id}/live-coach` — 방금 완료된 상담자 발화를 워크북 요약/RAG/evaluator fast-loop 신호와 대조해
라이브 코칭 카드 1개를 반환하고 `app.live_coach_events``use/-1`로 저장한다. 저장 payload는 마스킹
excerpt와 코칭 구조화 JSON이며, 잔여 기회가 없으면 409로 차단한다.
- `GET /sessions/{id}/live-coach` — 현재 회기에서 학습자에게 실제 전달된 코칭 이력을 시간순으로 반환한다.
세션 화면은 이 응답을 `turn_seq`별로 묶어 학습자 발화 우측 코칭 마커와 채팅 위 스크롤 오버레이에 표시하고,
`quota`/`credit_events`로 사용권 점 표시와 사용·충전 애니메이션을 갱신한다.
- `POST /kb/live-coach/source-packs/sync` — 관리자 전용. 로컬 라이브 코칭 source pack을 `kb.source` upsert 후
`kb.document/kb.chunk`로 색인한다. active hash가 같으면 skip하고, 다르면 최신 document version+1로 색인한다.
임베딩 모델이 없으면 BM25-only degraded 색인으로 진행한다.
- `POST /sessions/{id}/end``memory.make_carry_over(...)` → 세션 종료 + carry 준비 →
`_schedule_session_evaluation(sess)`로 deep-loop 평가를 비동기 태스크로 발사. 세션 종료 영속화가 평가보다
먼저 확정되므로 기존 회기의 deep 평가가 실패해 `error`로 남아도 그 리뷰는 degraded 상태로 읽히고,
같은 학습자는 새 회기를 생성해 턴을 이어갈 수 있다.
- 진행도 파생(P2, 2026-07-14): `session_read_model.build_session_progress(state, prev_rapport_credit,
goal_stages)`가 상태머신 수치를 학습자-안전 %로 파생한다 — 단계별 누적 게이지(현재 단계는
`rapport_credit / STAGE_ADVANCE_RAPPORT` 기준, 지나온 단계 100, 전이 대기 99 캡), 라포 누적
(`/0.55` 전 주기 기준)과 이번 회기 증가분(`prev_rapport_credit` 대비), 방어(저항)·유효 개방도 %.
노출 지점: `SessionDetailResponse.progress`, `TurnResponse.progress`, 스트림 `done` payload,
음성 WS `reply`, 대시보드 `persona_progress[].rapport_percent`. rapport_credit이 회기 간 ×0.7
이월되므로 게이지가 회기를 건너 누적된다(초심 상담자 학습 신호 — 회의 P2 의도). ideation·CCD는
계속 비노출.
- `GET /sessions/dashboard` — 학습자 본인 세션만 `include_turn_evaluation=true`로 집계해
`overview/growth/persona_progress/achievements/recent_feedback`를 반환한다. `overview.archived_sessions`는
학습자별 보관 상태(`app.session_archive_state`) 기준 카운트이며, 보관된 종료 회기는 리뷰 대기 행동 큐에서
제외한다. `growth.training_exposure`는 종료 회기만 집계하고 4회 미만이면 `insufficient`, 4회 이상에서
최다 페르소나 비중이 0.75 이상이면 `훈련 집중 주의`, 그 밖에는 `balanced`로 표시한다. 투명한 노출 비중이지
공정성·임상 진단이 아니다. 성취는 공식 등급/수료가 아니라 실제 연습 milestone만 표시한다.
- `GET /sessions/{id}/review` — 저장된 축어록 + 평가 AI 산출물을
`session_read_model.build_session_review(...)`가 학습자-안전 리뷰로 구성한다. 리뷰 조회 시 발화별 fast-loop 평가는
`app.feedback_scores`/라벨 조인 테이블에서 `TurnRecord.evaluation` 형태로 hydrate한다.
평가 미완이면 `degraded/reviewReady=false`로 표기. 학습자가 저장한 사례개념화 워크시트가 있으면
자동 초안보다 `saved_by_learner` 저장본을 우선 반환한다. 교수자/관리자는 담당 범위 회기를 읽기
전용으로 검토할 수 있지만, 학습자 워크시트 저장 권한은 learner 전용으로 유지한다.
`ReviewNote.body`는 제한 markdown(`code`, `strong`, blockquote, list)으로 렌더링하고,
`effective_openness` 같은 내부 수치는 `effective openness(유효 개방도)`로 학술 용어화한다.
상담자 질문/발화 근거는 `ReviewNote.quote`로 분리해 리뷰 카드 안에서 인용 블록으로 표시한다.
첫 회기에는 `first-session-rapport-open-question.v1` 체크리스트가 라포 반영과 한 초점 열린 질문을
결정론적으로 관찰하고 근거 turn을 연결한다. 관찰되지 않으면 `not_observed`, 2회기 이후면
`not_applicable`이며 임상 점수가 아닌 교육 체크리스트다.
학습자의 AI 피드백 노출은 현재 계정 설정과 세션 시작 시점 snapshot이 모두 켜진 경우에만 허용한다.
여러 회기를 합치는 학습자 집계도 같은 AND를 각 원천에 적용한다. `calibration/learners/me`는 과거 OFF
원천 또는 실행 회기가 하나라도 섞이면 learner-input-only로 fail-closed해 AI·교수자 파생값을 제거하고,
`practice/learners/me`는 처방·에피소드·최신 역량 그래프의 원천 회기 중 하나라도 OFF이면 403을 반환한다.
일반 practice attempt 제출도 현재 계정과 처방 원천 회기 snapshot이 모두 ON이어야 한다. 그 밖에 OFF인
순수 AI 파생 엔드포인트는 403이고 혼합 응답은 사용자가 입력한 calibration/alliance/multimodal 필드만
보존한 채 AI 결과·근거·측정을 제거한다. 축어록, 저장된 학습자 워크시트 원문, alliance 자기보고,
outcome 관찰 입력, calibration 예측·수정·잠금·실행 입력, multimodal 동의·철회·삭제·raw audio·privacy
ledger, privacy export/delete는 유지한다. teacher/admin 감독 뷰에는 이 learner gate를 적용하지 않는다.
- `POST /sessions/{id}/share` — 학습자가 종료된 본인 회기 리뷰를 URL로 공유하기 위해 공개 토큰을 생성한다.
서버는 `session_read_model.session_share_payload(...)`로 preview를 정규화한 뒤 `app.session_share_link`에
토큰 해시와 sanitized preview payload만 저장하고, 원문 축어록·학습자
식별자는 payload에 넣지 않는다. 토큰은 생성 응답에서만 반환되며 새 생성은 기존 토큰을 교체한다.
학습자 AI 피드백이 현재 계정 또는 세션 snapshot에서 OFF이면 새 공유를 거부하고, ON일 때 이미 만든
토큰도 public load 시 같은 두 정책을 다시 확인해 404로 닫는다.
- `DELETE /sessions/{id}/share` — 해당 회기의 공개 공유 토큰을 폐기한다.
- `POST /sessions/{id}/archive` / `POST /sessions/{id}/restore` — 학습자 본인의 종료 회기를 보관/복원한다.
진행 중 회기는 409로 거부한다. 보관은 `app.session_archive_state`만 upsert/delete하는 보기 상태이며,
`app.sessions`, `app.turns`, 리뷰, 공유 링크, 연구/감사 증거는 삭제하지 않는다.
- `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.9.1 교수자 회기 검토 상태 — `app/routes/teacher.py`
- `GET /teacher/dashboard` — 담당 학습자 성장, 안전 알림, 종료 회기 검토 큐를 반환한다. 회기 요약에는
`review_status`, `review_note`, `reviewed_at`를 포함해 교수자가 이미 검토한 회기를 구분한다.
학습자 표시는 `app.app_user.display_name`/`nickname`/`email`을 우선 사용하고, 사용자 row가 없을 때만
축약 learner id로 폴백한다.
- `GET /teacher/learners/{learner_id}/analysis` — 담당 범위 안의 특정 학습자 전체 회기 타임라인을
오래된 순서로 반환한다. `summary`/`points`는 제한 없는 사용자별 추이를 담고, `stage_breakdown`은
라포·탐색·개입·정리 단계별 회기 수와 턴 수를 담는다. 담당 범위 밖 learner id는 404로 닫는다.
- `PUT /teacher/sessions/{session_id}/review-status` — 교수자/관리자가 회기 검토 메모와 상태를 저장한다.
저장 대상은 `app.session_review_status`이며, 검토 완료된 회기는 pending queue에서 제외된다.
- `/teach` 교수 콘솔은 검토 큐·위기 알림·페르소나 검수·최근 회기 triage만 맡는다.
`/teach/analysis` 학생 분석은 별도 메뉴/라우트로 분리되어 전체 담당 학습자를 검색 가능한
1행 테이블로 먼저 보여주고, 우측 펼침으로 행 아래 미니 추이·기법·최근 기록을 확장한다.
학습자 이름 또는 상세 보기 버튼은 `?learner=` 상세 드릴다운으로 이동하며, 상세 화면은
페르소나별 회기를 기본 탭으로 먼저 보여준다. 각 페르소나 행은 회기 수·평균 적절성·라포·검토
카운트를 요약하고 펼침으로 해당 페르소나의 회기 목록을 연다. 추이·전체 회기·단계 분석 탭은 같은
상세 화면에서 선택 학습자의 전체 이력을 보조한다. `/teach/session/:sessionId/review` 화면은 같은
`GET /sessions/{id}/review` 자료를 교수자 읽기 전용으로 표시하고, 검토 메모 저장은 위 teacher endpoint로 분리한다.
### 2.9.2 운영 메일 알림 — `app/services/notifications.py`
- 가입 승인 알림: Google/SAML 신규 사용자가 `account_status=pending`으로 세션을 만들면
`account_pending_approval:{user_id}` idempotency key로 `app.notification_event`를 만들고,
슈퍼 관리자·관리자 콘솔 접근권자 중 `account_approval` 알림을 켠 수신자에게 메일 delivery를 큐잉한다.
- 회기 검토 알림: 회기 종료 후 `app.session_evaluation` 저장이 완료되면
`session_review_ready:{session_id}` idempotency key로 담당 코호트 교수자와 관리자에게
`/teach/session/{sessionId}/review` 딥링크 메일을 큐잉한다. 평가 생성이 실패해도 error record가 저장되면
교수자 수동 검토가 필요하므로 알림은 생성된다.
- 메일은 업무 상태의 원본이 아니다. 승인 상태는 `app.app_user.account_status`, 교수자 검토 상태는
`app.session_review_status`, 전송 상태는 `app.notification_delivery`가 각각 원본이다.
- SMTP 발송은 `NOTIFICATION_EMAIL_PROVIDER=smtp`와 `SMTP_*` env가 있을 때만 수행한다. provider가
`disabled`면 이벤트/큐 구조는 유지되고 실제 발송은 worker 또는 관리자 처리 API 실행 시 skipped로 남는다.
- 메일 HTML은 Vignette 토큰 톤(종이 배경, 세이지-틸 CTA, 8px radius)을 inline style로 재현한다. 메일 본문에는
축어록, 평가 전문, 민감한 심리 상태를 넣지 않고, 로그인 후 앱 화면에서만 확인하게 한다.
- 운영 API: `GET /admin/notifications`는 최근 delivery와 queued/failed/sent/skipped 카운트를 반환하고,
`POST /admin/notifications/process` 또는 `scripts/run-notification-worker.py`는 큐를 한 번 drain한다.
`POST /admin/notifications/test`는 `AUTH_SUPER_ADMIN_EMAILS` 대상에게 `admin_test_email:{uuid}` 테스트
이벤트를 만들고 같은 발송 큐로 즉시 처리한다.
### 2.9.3 공개 공유·검색 메타 — `app/routes/share.py`
- `GET /share/session/{token}` — 인증 없이 접근 가능한 unfurl HTML. Open Graph/Twitter Card/JSON-LD를 서버에서
직접 내려 URL만 전달해도 카카오톡·Slack·메일·AI 브라우저가 제목/요약/썸네일을 읽을 수 있게 한다.
- `GET /share/session/{token}/summary` — 동일 sanitized payload의 JSON 응답. 회기 원문, 턴별 축어록, 학습자
식별 정보는 포함하지 않는다.
- 공유 HTML과 API 도메인 `/robots.txt`는 `noindex`/`Disallow: /share/` 정책을 둔다. 공유 URL은 검색 색인용
공개 문서가 아니라 교수자 전달용 미리보기 카드다.
### 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, model, max_tokens, temperature, structured_schema, session_id, metadata)`.
- `session_id`를 넘기면 게이트웨이가 상주 풀을 재사용해 멀티턴 prompt caching 이점을 살린다.
- 장애는 `EngineError`로 전파 → 라우트가 503/SSE error 프레임으로 변환.
- 앱 전역 싱글톤 `engine_client`(lifespan에서 startup/shutdown).
- Node.js 교체 가능성을 위해 wire contract는 `app/contracts/engine_gateway.py`가 소유한다.
현재 FastAPI client와 Python gateway가 같은 `GenerateRequest`/`GenerateResponse`/`Stream*Event`
모델, SSE `token|done|error` 프레임 helper, `EngineGatewaySseLineDecoder` parser를 공유한다.
앱 서비스 레이어는 raw gateway SSE line을 직접 해석하지 않고 `EngineClient.stream_packets()`가
반환하는 `EngineGatewaySsePacket`만 처리한다. Python gateway의 `/v1/generate`도
`GenerateResponse.model_dump()`로 응답해 hand-mirrored dict drift를 줄인다.
`apps/api/engine_gateway/golden/engine_gateway_contract.v1.json`와
`apps/api/engine_gateway/golden/engine_gateway_schema.v1.json`는 generate request/response와
stream token/done/error decoded packet fixture/schema를 고정한다.
`scripts/check-engine-gateway-contract.mjs`는 같은 artifact를 Node.js에서 Python import 없이 검증하므로
미래 Node.js gateway의 최소 conformance gate로 쓴다.
`/v1/stream` 종료는 provider pass-through `data: [DONE]`가 아니라 `event: done` + JSON telemetry
payload로 변환해야 한다. provider의 `[DONE]`은 호환 입력일 뿐 canonical 완료가 아니며, 구조화 `done`
없이 EOF가 오면 앱은 `client_stream_incomplete`로 실패 처리한다.
브라우저로 재방출되는 `/sessions/{id}/stream` SSE는 별도 앱 계약이며, engine gateway SSE의
JSON token payload와 섞지 않는다.
### 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) 순으로 시도한다.
- 회기 평가 저장은 `SessionEvaluationWrite`가 `status/source/scope/stage/payload/error` write packet을
소유한다. `routes/sessions.py`와 `routes/eval.py`는 같은 named packet을 만들어 저장하고, DB SQL과
fallback cache record는 `session_persistence.py`가 유지한다.
### 2.12 페르소나 카탈로그 — `app/persona_repository.py`
DB `app.persona_card`가 승인 페르소나의 SoR. 이 모듈이 in-proc `PersonaCard`와 DB 행을 잇는 경계다.
브라우저-facing persona catalog/review/draft/source/evidence DTO와 deterministic mapper는
`app/persona_read_model.py`가 소유한다. draft generation structured schema, prompt bundle
id/version/hash, `GenerateResponse` payload extraction, generated draft default/coercion은
`app/persona_generation_contract.py`가 소유한다. `routes/personas.py`는 route/auth,
teacher/admin gate, DB repository 호출, RAG source 등록, engine invocation, provenance assembly,
HTTP error mapping을 유지한다.
- `load_file_personas()` / `built_in_personas()` — in-code P1~P3과 저장소 `data/personas/P4~P7.json`을 deterministic catalog로 합친다.
- `materialize_seed_personas()` — built-in P1~P7을 초기 승인 카탈로그로 누락분만 insert(admin 롤). `ON CONFLICT DO NOTHING`이므로 교수 편집본을 덮어쓰거나 `archived` 보관본을 재승인하지 않는다.
- `scripts/materialize-persona-seeds.py` — API 서버 없이 seed/version manifest를 보고하는 운영 runner. 기본은 dry-run이고, `--json`은 P1~P7 `seed_version`/deterministic `persona_id`/`source_provenance`를 출력하며, `--apply`일 때만 DB pool lifecycle을 감싼 뒤 기존 idempotent DB materializer를 실행한다.
- `create_persona_revision_from_existing()` — 공개 승인본을 같은 `persona_id`의 다음 `version` draft/review로 복제한다. 이미 열린 draft/review가 있으면 새 버전을 만들지 않고 기존 초안을 돌려준다.
- `archive_persona_family()` — 교수/관리자 UI의 삭제 동작. 세션 FK 보존을 위해 hard delete 대신 같은 `code` family의 비보관 버전을 모두 `status='archived'`로 전환한다.
- `get_approved_persona(code)` / `list_approved_personas()` — 승인된 최신 버전 조회(AI 컨텍스트).
- `get_catalog_persona(code)` — 승인 카드 조회, 실패 시 `settings.allow_seed_persona_fallback`이면
`seed_fallback_persona`(degraded=True)로 폴백.
- `persona_read_model._presenting_summary()` — 카드 JSON의 키 순서와 무관하게 `complaint`/`주호소` 계열을
우선해 제시문제를 구성한다. `surface` 같은 표면 상태를 주호소로 오인하지 않는다.
- 페르소나 워크스페이스: `/teach/personas`는 teacher/admin 전용 운영·저작 작업면이다. 상단 horizontal tab이
`대시보드`(공개/활성 페르소나·학습 인원·세션·평가/라포·검수 요약), `카탈로그`(공개 규칙·구성·버전),
`페르소나`(검색/필터 가능한 전체 table + 내부 `검수 현황` 탭)를 나눈다. 행을 누르면 소개·학습 현황·설정
drilldown으로 들어가며, 수정/신규 작성은 URL query 상태의 독립 작업면에서 자료→초안→설정→검토 4단계를
사용한다. 작성 중간 저장은 브라우저 임시 저장을 항상 남기고, 기본 식별자가 채워지면 기존 draft API에도 저장한다.
구조화 편집 자체는 개요·임상·저항·회기·말투·안전·프롬프트 탭 계약을 유지한다.
- 자유 양식 표 업로드(2026-07-13 회의 P4): `POST /personas/sources/upload`는 xlsx/xlsm/csv 파일을
받아 `services/tabular_ingest.extract_tabular_text()`로 결정론 평탄화(라벨: 값 쌍, 시트 구분,
cp949 폴백, .xls 거부)한 뒤 아래 `/personas/sources` 등록 경로를 그대로 재사용한다.
업로드 원본 바이트는 핸들러 메모리에서만 파싱하고 저장하지 않는다(원본 파기 — hash-only 증거만 남음).
- RAG 첨부 SSOT: `POST /personas/sources`는 첨부/붙여넣기 자료를 `mask_pii()` 후
raw 원문 hash-only 증거는 `kb.raw_source_artifact`에 따로 기록하고, sanitized 파생본만
`kb.source/document/chunk`에 evaluator 전용 근거(`visible_to=['evaluator']`, `sensitivity=2`)로 등록한다.
`rag.index_document()`는 `sensitivity=3` 또는 raw source marker chunk를 DB 접근 전에 거부하므로
raw 원문은 `kb.chunk`/embedding/FTS 검색면에 들어가지 않는다.
`POST /personas/drafts/generate`는 raw text가 아니라 `source_id` 기반 RAG 검색 결과를 생성 근거로 사용하고,
draft `source_provenance`에 source id와 chunk id를 남긴다. 생성 요청 metadata와 provenance에는
`persona-draft-rag@2026-06-28.1` prompt bundle id/version/hash도 함께 남겨 생성 프롬프트 버전 drift를 추적한다.
- repo-managed source pack sync: `app.services.source_pack_sync`와 `scripts/sync-persona-sources.py`가
active `kb.document.content_hash`를 비교한다. dry-run은 DB 상태를 읽어 would-apply version을 보고하고,
`--apply`에서만 `kb.source` upsert와 `kb.document/kb.chunk` 색인을 수행한다. 이 runner는 seed
materializer와 별개이며, 교수자가 업로드하는 `/personas/sources` UUID 자료를 임의로 재작성하지 않는다.
- 교수 검수: `list_persona_review_queue(role)` / `update_persona_review_status(action=approve|reject)`
— 승인/반려 시 `audit.audit_log`에 감사 기록.
### 2.13 개선관리 프로토콜 레지스트리 — `app/services/protocol_registry.py`
관리자 전용 `/admin/protocols`가 프로토콜 목록, draft 생성, 활성화, 폐기를 제공한다. 상태는
`draft → active → retired` 단방향이며 retired 항목은 다시 활성화하지 않는다.
- 생성 내용은 서버에서 정규화하고 SHA-256을 계산한다. 라이선스 분류 A/B/C/D와
`external_llm_ok`를 함께 저장하며 C/D는 요청·서비스·DB 제약 모두에서 외부 LLM 사용을 거부한다.
- 활성화는 한 트랜잭션에서 `kb.source`를 등록하고 RAG `kb.document/kb.chunk` 색인을 완료한 뒤에만
`active`로 바꾼다. 색인 실패는 전체 rollback하며 동시 활성화는 직렬화돼 하나만 성공한다.
- chunk는 `visible_to=['evaluator']`, `sensitivity=2`이고 라이선스·외부 LLM 허용 메타데이터를 보존한다.
외부 LLM에 보낼 수 없는 근거는 retrieval 후에도 prompt assembly에서 제외한다.
- 폐기는 연결 문서를 비활성화한 뒤 terminal `retired`로 전이하고, 중간 실패는 rollback한다.
- `ensure_protocol_tables()`는 migration 17 계약을 조회할 뿐 DDL을 만들지 않는다. 누락 시 적용해야 할
migration 파일을 명시해 fail-closed한다.
---
## 3. 엔진 게이트웨이 — `apps/api/engine_gateway/gateway.py`
컨테이너 밖(호스트)에서 도는 **별도 서비스**. Claude CLI 상주 멀티턴 풀과 Anthropic API,
Codex CLI, Agy CLI의 모델 탐색·실행 차이를 흡수한다. Claude CLI는 회기당 1 `EngineSession` 프로세스를
상주시켜 페르소나 system 프롬프트와 prompt caching을 유지한다. 나머지 공급자는 격리된 stateless 호출로 실행한다.
선택 보안 경계인 `ENGINE_GATEWAY_SHARED_SECRET`을 설정한 인스턴스는 순수 liveness인 `/health`만
무인증으로 열고, 실제 생성을 수행하는 `/ready`와 capability·session·generate·stream을 포함한 나머지 모든 HTTP 경로에서
`X-Vignette-Engine-Token`을 constant-time 비교한다. 값은 32자 이상 비-placeholder여야 하며 API와
gateway 양쪽에 동일하게 주입한다. 미설정 인스턴스는 기존 localhost 9099 개발 호환을 유지한다.
인증은 미들웨어 경계에 있어 secret이나 인증 헤더 값이 OpenAPI·애플리케이션 로그에 포함되지 않는다.
실행:
```
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()`로
`GatewayPromptParts(system_prompt, user_payload)` 분리
→ `--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/token/cost 메타) / `event: error` 프레이밍.
- `session_id`가 살아있으면 풀 재사용, 아니면 1회성(ephemeral) 세션 생성 후 close(`_resolve_session`).
- **공급자 capability** `GET /v1/capabilities` — Codex app-server `model/list`, `agy models`, Anthropic
`GET /v1/models` 또는 Claude CLI 공식 alias를 공통 `EngineModelOption`으로 정규화한다. 60초 캐시와
관리자 강제 새로고침을 지원한다.
- `GET /ready` — `/health`(얕은 프로세스 liveness)와 달리 실제 1턴 생성을 시도해
선택한 provider/model/reasoning_effort의 "설치됐지만 미인증" 상태를 학습자 도달 전에 잡는다(TTL 캐시).
provider 라우팅 모드는 `ENGINE_MODE`(`app/config.py`) 또는 DB의 관리자 설정으로 선택한다.
실행 가능 공급자는 `claude_cli`, `claude_api`, `codex_cli`, `agy_cli`다. Codex 기본은
`gpt-5.6-terra` / Medium, Agy 기본은 `gemini-3.6-flash-high` / High다. `openai`와 `solar`는
기존 계약값을 보존하지만 실행 어댑터가 없어 capability에서 unavailable로 fail-closed한다.
각 어댑터는 사용량을 입력·캐시 입력·출력 토큰으로 정규화한다. Claude CLI는 result의
`modelUsage`/`model_usage`를 우선해 전체 agent tree를 합산하며, 원장의 입력 토큰은
비캐시 입력 + cache read + cache creation 합계다. 모델별 사용량이 없을 때만 최상위
`usage`로 폴백한다. `total_cost_usd`는 Claude SDK의 호출별 클라이언트 추정값이지 청구서
실비가 아니므로 `provider_estimate`로 표시한다. Agy/Gemini·Codex·Claude API는
`app.services.llm_pricing.estimate_reference_cost()`로 공식 참조단가를 적용한다. 과거
토큰 미수집 행은 비용에서 역산하지 않는다. `scripts/backfill-claude-token-usage.py`는 로컬
Claude JSONL의 실제 assistant usage와 DB 응답을 정규화 본문 SHA-256·생성 시각 창으로 대조하고,
후보가 정확히 하나인 행만 `--expected-matches` 가드 아래 복구한다. 일치하지 않거나 모호한 행은
`token_unmetered_turns`로 남긴다.
`AdminEngineConfigResponse`는 provider·URL·model에 `reasoning_effort`를 더해 DB에 저장한다. PATCH는
제안된 게이트웨이에서 capability를 강제 재조회한 뒤 실제 목록에 없는 모델·추론 강도, 미인증 공급자,
접속 불가 URL을 422로 거부하고 저장 성공 후 `EngineClient` 런타임 설정을 함께 교체한다.
> 포트 주의: 게이트웨이 docstring 예시는 `:9099`이고, `app/config.py`의 bare Settings fallback은
> legacy compose 서비스명 기반 `http://engine:8100`이다. 현재 지원 compose/local runtime은
> `ENGINE_URL`을 실제 host gateway 포트(예: `http://host.docker.internal:9099` 또는
> `http://127.0.0.1:9099`)로 override한다.
---
## 4. 프론트엔드(apps/web)
React 19 + Vite. 라우팅은 `apps/web/src/App.tsx`(react-router-dom).
| 경로 | 화면 | 역할 가드 |
|---|---|---|
| `/login` | Login (dev-login 경로 포함) | 공개 |
| `/pending` | PendingApproval(승인 대기/보류 안내) | 인증됨, approved 전용 제한 화면 |
| `/learn` | LearnerHome(대시보드: 학습 요약, 최근 회기 리캡, AI 코치) | learner/admin(learner 관점) |
| `/learn/practice` | LearnerHome(연습 대상 선택·새 회기 시작) | learner/admin(learner 관점) |
| `/learn/history` | LearnerHome(회기 기록·보관/복원·리뷰 진입) | learner/admin(learner 관점) |
| `/learn/session/:sessionId` | Session(상담 화면) | learner/admin(learner 관점) |
| `/learn/session/:sessionId/review` | SessionReview(회기 리뷰) | learner/admin(learner 관점) |
| `/learn/avatar-expressions` | AvatarExpressionLab | learner/admin(learner 관점) |
| `/teach` | Professor(교수자 대시보드) | teacher/admin |
| `/teach/analysis` | Professor(학생 분석 작업면) | teacher/admin |
| `/teach/personas` | PersonaStudio(페르소나 저작·검수) | teacher/admin |
| `/teach/session/:sessionId/review` | SessionReview(교수자 읽기 전용 회기 검토) | teacher/admin |
| `/admin` | Admin(운영 홈) | admin |
| `/admin/ai` | AdminAi(AI 공급자·실시간 모델·추론 강도 드롭다운, 기간별 DB 비용·토큰·계량 커버리지, provider/model 원장, 평가 캐시 효율) | admin |
| `/admin/users` | Admin(사용자 관리) | admin |
| `/admin/access` | Admin(접근 권한) | admin |
| `/admin/tickets` | Admin(운영 티켓 처리 큐: 접수, 필터, 카테고리 큐, 우선순위, 수동 상태 변경 감사) | admin |
| `/settings` | Settings | 3역할 공통 |
- `RequireAuth`가 `lib/auth`의 AuthContext로 가드하고, 권한 불일치 시 역할 홈(`roleHomePath`)으로 보낸다.
- `App.tsx`는 모든 역할 페이지를 `lazy()`로 로드하고 공통 `Suspense` 부트 경계를 사용한다. 페이지별 순수 표시 계산은
`pages/*/model.ts`, 브라우저 음성 캡처는 `pages/session/voiceCapture.ts`, 페르소나 이름·난도·아바타 팔레트는
`lib/personaViewModel.ts`가 소유해 라우트 컴포넌트의 API/상태/렌더 책임과 분리한다.
- Pages 배포 전환 중 열린 탭이 삭제된 lazy 청크를 요청하면 `lib/chunkRecovery.ts`가 Vite
`vite:preloadError`를 받아 현재 경로에서 문서 재로드를 1회만 수행한다. 정상 라우트 렌더 뒤 재시도 표식을
지우고, 같은 경로의 연속 실패는 무한 재로드하지 않고 `RouteErrorBoundary`의 수동 복구 액션으로 넘긴다.
- 최초 `/auth/me` 복원은 요청별 5초 상한과 짧은 3회 재시도를 둔다. 재부팅 직후 API가 늦게 올라와도
세션을 즉시 로그아웃 처리하지 않으며, 끝내 연결되지 않으면 무한 `불러오는 중…` 대신 같은 HttpOnly
세션으로 다시 확인할 수 있는 `AuthRestoreGate` 복구 화면을 표시한다. `401`만 정상 로그아웃 상태로
확정하고 네트워크/5xx 실패는 권한 상실과 구분한다.
- 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`(dashboard/list/get/start/turn/liveCoach/liveCoachHistory/end/review/stream/archive/restore), `personaApi`, `personaReviewApi`,
`adminApi`, `adminEngineApi`, `teacherApi`, `userApi`. 주요 응답 타입은 FastAPI OpenAPI에서 생성한
`src/lib/api.gen.ts`의 `ApiSchema<...>` alias를 사용하고, generated optional 배열은 화면 렌더링 계층에서
빈 배열 fallback으로 흡수한다. 세션 시작/상세/턴 응답의 stage는 backend `StageLabel`
enum(`라포|탐색|개입|정리`)에서 생성한 union을 `SessionStage`로 사용한다.
- `sessionApi.createShare`/`revokeShare` — 학습자 리뷰 화면에서 공개 공유 URL 생성/폐기를 호출한다.
`SessionReview`은 종료된 회기에서만 "공유 URL 복사" 버튼을 노출한다.
- dev 환경: `vite.config`가 `/api` → `http://127.0.0.1:8000` 프록시(`/api` 프리픽스 제거).
배포 호스트(`vignette.chanpaca.net`, `*.pages.dev`)에서는 `api-vignette.chanpaca.net`을 직접 가리킨다.
- 스타일 소유권:
- 전역 토큰과 리셋은 `apps/web/src/styles/tokens.css` / `global.css`, 공용 UI primitive는
`components/ui/ui.css`, 셸은 `components/shell/shell.css`가 소유하고 `main.tsx`에서 1회 import한다.
- 화면 전용 스타일은 해당 라우트 TSX가 가까운 `.css` 파일을 import한다
(예: `pages/login/login.css`, `pages/learner-home.css`, `pages/session/session.css`).
TSX 안에 `<style>{..._CSS}</style>` template literal을 두지 않는다. 새 화면은 스타일을
페이지/기능 CSS로 분리하고, 반복 JSX는 작은 presentational component로 빼서 인증·데이터 상태 로직과 섞지 않는다.
- 공통 UI/셸 CSS의 raw color는 0개를 강제한다. 몰입형 세션·인증·아바타 아트처럼 전역 의미 토큰이 아닌 국소 색은
`check:design-ssot`의 파일별 고정 예산을 넘길 수 없어 예외가 다른 화면으로 확산되지 않는다.
- 로그인 진입 화면은 `pages/login/LoginBrand.tsx`, `LoginPanel.tsx`, `login.css`로 분리되어
`Login.tsx`는 OAuth/dev-login 상태와 라우팅만 소유한다.
세션 화면 흐름(요약): 학습자 발화 입력 → `sessionApi.stream(id, text)`(SSE) 또는 `turn`(동기) →
토큰 누적 표시 → 코칭 모드면 `sessionApi.liveCoach(...)` + `liveCoachHistory(...)`로 아바타 말풍선/발화별
코칭 마커 갱신 → 회기 종료(`end`) → `/review`에서 `sessionApi.review(id)`로 리뷰 표시.
정적 SEO/GEO 자산:
- `apps/web/index.html` — 기본 description/canonical/Open Graph/Twitter Card/JSON-LD(WebApplication).
- `apps/web/public/robots.txt` — 인증 내부 경로(`/pending`, `/learn`, `/teach`, `/admin`, `/settings`, `/dev`) 색인 차단.
AI 검색용 `OAI-SearchBot`, `ChatGPT-User`에도 같은 경계를 명시한다.
- `apps/web/public/sitemap.xml` — 공개 entry point만 포함한다.
- `apps/web/public/llms.txt` — AI 검색/요약용 공개 설명, 공유 URL의 no-transcript 정책, 비공개 경로 경계를 명시한다.
---
## 5. 데이터베이스 스키마 개요
DB는 PostgreSQL 16 + pgvector(단일 SoR). 초기화 SQL은 `infra/db/init/`에 번호순으로 적용되며,
개선관리 계약까지 필요한 현행 끝점은 `17_improvement_workbook_contracts.sql`이다.
스키마는 **4분할**: `app` / `kb` / `audit` / `ds`.
### 5.1 app 스키마 (`infra/db/init/02_schema.sql`)
- **인적 주체** `app.app_user` — RBAC 앵커(role: learner/teacher/admin, cohort, consent_at)와
계정 승인 상태(`account_status`: pending/approved/suspended), 최초 온보딩 프로필(닉네임, 자기소개,
아바타 URL, 이름, 소속, 학과, 학년/직위, 연락처, 주소/수령지, 약관·개인정보 동의 버전).
- **페르소나** `app.persona_card` — 불변 버전드 카드(`UNIQUE(code, version)`, status draft→review→approved→archived,
ccd/dsm5_dimensional/affect_baseline는 JSONB). `archived`는 카탈로그 제거용 tombstone이며 기존 세션은 `persona_id/persona_version` 핀으로 계속 해석한다.
`app.persona_voice_map`은 같은 `persona_id/version`에 묶인 provider-agnostic 음성 설정이다. live OpenAI TTS는
provider=`openai` row의 `voice_id`와 `base_params`를 `VoicePreset`으로 해석하고, seed materializer는 기본 OpenAI row를
충돌 없이 생성한다. 기존 dev DB에 테이블이 없으면 `ensure_runtime_tables()`가 `persona_card` 기준 FK 구조로 보강한다.
`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.session_archive_state` — `session_id`/`learner_id` 단위의 학습자 보기 상태.
`archived_at`/`updated_at`만 저장하고 restore 시 row를 삭제한다. RLS는 학습자 본인의 보관/복원과
teacher/admin 조회만 허용하며, 원 세션·발화·리뷰·공유 링크는 보존한다.
- **발화** `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/provider_events; review는 제한된 taxonomy 칩만 파생). `turn_technique`/`turn_client_state`
다대다, `supervisor_comment`(rationale/critique + intent_deviation JSONB), `safety_events`.
- **사례개념화 제출물** `app.case_worksheet` — 회기 리뷰의 축어록 기반 자동 초안을 학습자가 편집해 저장한 JSONB.
`session_id` 단위 upsert이며, `GET /review`에서 자동 초안보다 우선된다.
- **교수자 검토 상태** `app.session_review_status` — 교수자/관리자의 회기 검토 상태, 메모, 검토 시각을
`session_id` 단위로 저장한다. teacher dashboard는 `review_status/review_note/reviewed_at`를 내려 보내며,
검토 완료된 회기는 pending queue에서 제외한다.
- **공개 공유 카드** `app.session_share_link` — `session_id` 단위 공개 토큰 해시와 sanitized preview payload.
RLS는 학습자 본인 생성/폐기와 teacher/admin 열람, public route의 AI 컨텍스트 조회만 허용한다. 원문 축어록을
저장하지 않는다.
- **운영 메일 알림** `app.notification_event` / `app.notification_delivery` — 가입 승인 요청과 회기 검토 요청을
이벤트와 수신자별 delivery로 분리해 저장한다. `idempotency_key`가 중복 메일을 막고, delivery는
`queued/sending/sent/failed/skipped` 상태와 시도 횟수, provider message id, 마지막 오류만 저장한다.
메일 본문 HTML이나 회기 축어록은 DB에 복제하지 않는다. RLS는 관리자 전체 처리만 허용한다.
- **Outcome & Alliance 측정 원장** `app.measurement_instrument` / `app.measurement_event` — 척도·훈련지표를
`(instrument_id, instrument_version)`으로 고정하고 측정값을 append-only event로 남긴다. 정정은 UPDATE가
아니라 `supersedes_id`를 가진 새 event이며, `source_kind`와 `perspective`의 호환성, scale, 상태,
`visible_to[]`, 모델 provenance를 DB CHECK/RLS로 강제한다. 기존 `rapport_credit`와 `alliance_level`은
Working Alliance가 아닌 `simulation_progress` 신호로만 적응한다.
- **Alliance Pulse 실행 원장** `app.alliance_pulse` / `app.self_assessment` /
`audit.alliance_pulse_status_event` — 수련생의 goal/task/bond 자기평가를 먼저 잠근 뒤 가상내담자와 독립
관찰자를 서로 다른 엔진 세션으로 실행한다. `awaiting_agents` 동안은 자기보고만 읽을 수 있고 모든 agent
실행·측정 event 저장이 끝난 뒤 `ready`로 단방향 전이하면서 reveal한다. 실패는 무점수 `error/degraded`로
남기며 정상 점수로 보정하지 않는다. 교수자 평정은 `human_rated/supervisor_human` 새 event 3개를 추가하고
재평정은 직전 event를 `supersedes_id`로 연결한다. prompt/schema/input evidence hash와 시도별 실패 이력은
`audit.model_run`에 보존한다.
- **종단 성과 궤적** `ds.synthetic_outcome_arc` / `app.outcome_trajectory_revision` /
`app.outcome_trajectory_observation` — 같은 case의 1~5회기에서 고통 부담·일상 기능·학습 참여를 독립 축으로
읽고, 기대분포는 `synthetic_educational`·`clinical_claim_allowed=false`로 고정한다. 누락·오류는 무점수로
보존하고 revision은 source fingerprint와 supersession을 가진 append-only snapshot이다. 학습자 체크인은
submission ID와 축별 measurement ID로 멱등하며, 관계 기억은 `app.relationship_memory_event`와
역할별 `app.relationship_memory_projection`으로 분리한다. safety signal은 outcome 상태에 합산하지 않는다.
- **파열·수선 원장** `app.rupture_episode` / `app.rupture_observation_event` /
`app.rupture_reconciliation_revision` / `app.rupture_safety_reference` — G3의 onset→recognized/repair_attempted→
missed/partial/resolved 전이를 episode별 sequence로 append한다. fast warning과 deep reconciliation은 별도
revision으로 대조하고 사람 정정은 supersession event로 남긴다. 내부 evaluator write API는
`VIGNETTE_RUPTURE_INTERNAL_TOKEN`이 없거나 32자 미만이면 503으로 닫히며, 잘못된 토큰은 evaluator DB
context를 열기 전에 거부한다. safety는 resolution에 영향을 주는 점수 없이 동일 회기 FK만 보존한다.
`rupture_scenario_director`는 case/session/turn seed에서 4~6턴 간격으로 9종을 순환하되 client prompt에는
조건부 행동 cue만 주입한다. taxonomy·scenario UUID·selector provenance는 engine metadata에만 남고,
safety escalation이나 내부 marker 누출 가능성이 있으면 생성·SSE를 fail-closed한다. 텍스트·SSE·음성은
모두 durable 상담자/내담자 UUID pair와 structured fast evaluation이 저장된 뒤 같은 detector를 실행한다.
- **의도적 수련 원장** `app.practice_coaching_card` / `app.practice_prescription` /
`app.practice_attempt_episode` / `app.practice_attempt` / `app.practice_competency_snapshot` /
`app.practice_curriculum_decision` / `app.practice_teacher_correction` — G4 coaching card를 관찰 가능한
원자 행동과 5종 실행 prescription으로 변환하고, 약점·망각 위험·선행 역량으로 다음 과제를 선택한다.
익숙한 장면의 반복 성공과 학습자 자기 성공 주장은 mastery 근거가 아니며, `unseen_transfer` 성공 전에는
`mastery_allowed=false`를 강제한다. 내부 처방 write는 `VIGNETTE_PRACTICE_INTERNAL_TOKEN`을 DB acquire 전에
검증하고, 학습자 시도와 교수자 교정은 역할·cohort RLS와 append-only supersession으로 분리한다.
- **자기보정·전이 원장** `app.calibration_prediction_revision` / `app.calibration_prediction_lock` /
`app.calibration_performance_observation` / `app.calibration_assessment` /
`app.calibration_meta_prescription` / `app.calibration_transfer_suite` / `app.calibration_transfer_trial` /
`app.calibration_subgroup_drift` — 외부평가 reveal 전 학습자 성공확률·확신도 revision을 보존하고, 명시적
lock 뒤 revision을 trigger로 거부한다. runtime/evaluator 수행 관측은 독립 perspective로 저장하며 역량별
error·bounded interval·과신/과소신과 baseline→recent 변화를 계산한다. transfer는 context·관계스타일·난이도·
표현의 coverage와 phrase/scenario family novelty를 요구하고 암기 문구를 차단한다. 합성 subgroup drift는
실제 인구집단 주장이 아니며 표본 부족을 별도 상태로 둔다. 내부 관측 write는
`VIGNETTE_CALIBRATION_TRANSFER_INTERNAL_TOKEN`을 DB acquire 전에 검증하고 역할·cohort RLS를 유지한다.
- **감독·연구 OS 원장** `app.supervision_attention_snapshot` / `app.supervision_attention_item` /
`app.supervision_evidence_pointer` / `app.supervision_teacher_ai_disagreement` /
`app.supervision_calibration_dataset_row` / `audit.supervision_teacher_event` /
`app.supervision_curriculum_gap_snapshot` / `app.supervision_evaluation_batch` /
`app.supervision_drift_report` / `app.supervision_phase3_manifest` — G6 위험·정체·미해결 관계 사건을
총점 대신 원장 UUID pointer 최대 3개로 queue화하고, 교수자 정정은 raw transcript 없는 disagreement/dataset/audit
append로 보존한다. baseline/candidate model·prompt·instrument와 subgroup metric, Phase 3 네 도메인 artifact
hash/provenance를 재현 read model로 제공한다. supervisor/research AI view는 물리 분리하며 내부 write는
`VIGNETTE_SUPERVISION_RESEARCH_INTERNAL_TOKEN`을 DB acquire 전에 검증하고 사람 조회는 teacher/admin·cohort
RLS로 제한한다.
- **멀티모달 동맹 원장** `app.multimodal_consent_snapshot` / `app.multimodal_audio_asset` /
`app.multimodal_audio_timeline` / `app.multimodal_word_timestamp` / `app.multimodal_voice_event` /
`app.multimodal_axis_measurement` / `app.multimodal_fusion_decision` /
`app.multimodal_deletion_request` / `audit.multimodal_deletion_tombstone` — G7 음성 입력을 STT word와
silence/overlap/interruption/prosody가 공유하는 단일 밀리초 시계로 정렬한다. text와 voice 축 측정은 별도
provenance로 저장하고 synthetic benchmark에서 유의한 추가 이득이 있을 때만 fusion한다. 비언어 이벤트는
관찰 가능한 interaction signal만 허용하며 감정·진단 추론을 계약에서 거부한다. streaming STT word 원문은
저장하지 않고 deployment `SESSION_SECRET`으로 `session_id:submission_id:casefold(word)`를 HMAC-SHA256한
pseudonym과 시간만 저장한다. learner 동의가 없거나 철회되면 최초 처리 전과 열린 stream 중 1초 간격으로
voice ingestion을 fail-closed하고, raw audio는 learner/admin만 보며 teacher는 metadata-only read model을 사용한다.
삭제 tombstone 뒤에는 raw audio와 derived voice feature를 read model에서도 제거한다. 내부 write는
`VIGNETTE_MULTIMODAL_ALLIANCE_INTERNAL_TOKEN`을 DB acquire 전에 검증한다.
- **G7 voice runtime·external evidence boundary** — Session의 첫 음성 입력은 처리 설명과 명시 동의 뒤
`app.multimodal_consent_snapshot`에 원음 미보존·전사/파생 특징 30일 동의를 먼저 append한다. write 실패 전에는
브라우저 `getUserMedia`와 `/voice/ws`를 시작하지 않으며, 회기 전환·unmount·pause는 capture generation을
무효화하고 뒤늦은 MediaStream track을 종료한다. 관리자 전용 `GET /admin/voice-runtime`은 single-worker
RSS/peak RSS·CPU·thread/FD, WebSocket/provider session, route audio buffer, streaming event queue,
overflow/fallback/error high-water만 반환한다. session ID·축어록·원음·provider payload는 포함하지 않는다.
production Docker는 Uvicorn worker 1개와 `--ws-max-queue 4`를 사용한다. 외부 종료는 public WSS/physical mic,
process-local runtime, exact Linux topology, independent human-held-out voice-gain 네 artifact가 같은 public host와
겹치는 50분 시간창을 가져야 한다. Cloudflare 내부 큐를 직접 측정했다고 주장하지 않고 public runner의
TLS/CF-Ray/latency/gap/disconnect를 edge 경계로 쓴다. canonical validator는
`scripts/check-g7-external-proof.py`이며 synthetic/preflight/short probe는 종료 증거가 아니다.
- **지속 개선 원장** `app.ci_agentic_job` / `app.ci_content_pipeline` / `app.ci_content_qualification` /
`app.ci_model_change_gate` / `app.ci_release_gate` / `app.ci_gate_artifact` /
`audit.ci_human_approval_event` / `audit.ci_lifecycle_event` / `app.ci_operational_incident` /
`app.ci_regression_dag_node` — G8 source pack에서 생성된
사례·균열·연습·benchmark 초안을 독립 red-team과 품질 gate로 통과시킨 뒤, baseline·threshold·provenance·
rollback 네 종류 증거와 관리자 사유가 모두 있을 때만 append-only 승인 effect를 기록한다. 자동 publish 버튼은
없고 pending human approval을 권한 경계로 유지한다. 모델·릴리스 monitor와 rollback, 운영 incident→재현 테스트→
backlog DAG를 같은 read model에 연결하되 raw transcript·PII·임상 주장·합산 총점을 노출하지 않는다. 내부 write는
`VIGNETTE_CONTINUOUS_IMPROVEMENT_INTERNAL_TOKEN`을 DB acquire 전에 검증한다. opt-in scheduled producer는
repo-approved synthetic source를 immutable job으로 등록하고 lease/재시도 상태를 durable하게 소유한다.
source usage·고정 classification·본문 hash·PII를 model 호출 전에 다시 검사하고, model/structured 실패는
`retry_wait`, 안전 gate 실패는 pipeline 미저장 `rejected`, 전 단계 통과만 `pending_human_approval`로 남긴다.
API lifespan은 schema contract가 준비됐을 때만 producer를 시작하며 producer는 사람 승인·catalog 승격을
수행할 권한이 없다. 별도 opt-in drift trigger는 G6 합성 drift 원장의 canonical 전체·subgroup 임계값과
근거 행을 다시 대조한 뒤 metadata-only incident·4-node DAG·`scheduled_incident` job을 같은 research
트랜잭션에서 멱등 생성하며, 자동 승인이나 catalog 쓰기 경로를 호출하지 않는다.
- **운영 콘솔** `app.admin_health_event` / `app.admin_health_daily_rollup` — 관리자 `/admin/health`
조회 시점 또는 `scripts/record-admin-health-sample.py` synthetic sampler 실행 시점의 서비스별 원시
헬스 샘플은 `app.admin_health_event`에 남긴다. `scripts/maintain-admin-health-events.py`는 명시
인자를 받은 dry-run/apply 작업으로 오래된 원시 샘플을 일별 rollup에 먼저 집계한 뒤 retention window 밖
raw row만 삭제한다. 이 값은 SLA 보장 수치가 아니라 운영 콘솔/샘플러가 관측한 최근 샘플 및 집계 이력이다.
`app.support_ticket`은 인증 사용자가 제출한 문제·불만·장애 티켓을 저장하고, 관리자는 `/admin/tickets`에서
상태·카테고리·우선순위·담당그룹·정체·검색 필터와 카테고리 큐를 실제 DB 기준으로 처리한다.
티켓 생성 시 결정론적 `fingerprint`를 저장해 같은 내용의 중복 후보를 힌트로 보여주고, 관리자는
`parent_ticket_id`를 수동 지정/해제해 parent-child 관계를 남긴다. 이 힌트는 자동 병합·자동 우선순위
변경·자동 담당그룹 배정에 쓰지 않는다. 티켓 접수와 관리자 수동 처리 변경은 `audit.audit_log`에
metadata-only로 남기며, 제목/본문 전문은 감사 로그에 복제하지 않는다. Claude Recipe 자동 수정 후보,
이슈/PR 생성은 운영자 승인 전까지 실행하지 않는다.
RLS는 티켓 제출자 본인 insert/select와 admin 전체 처리만 허용한다.
- **메모리 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). 현재 자동 쓰기는 learner-owned case의 마스킹 identity/agreement
fact만 허용하고, 삽입/값 변경 history와 명시적 상담 약속 철회 contradiction까지 남긴다.
관계·임상 fact 승격과 광범위 자동 모순 판정은 후속이다.
### 5.1.1 개선관리 migration 17
`infra/db/init/17_improvement_workbook_contracts.sql`은 `app.app_user`와 `app.sessions`의
`learner_feedback_enabled`, 프로토콜 레지스트리와 라이선스/외부 LLM 제약, lifecycle timestamp/index,
관리자 전용 `kb.chunk` write RLS를 멱등하게 추가한다. 새 DB volume은 번호순 init으로 적용되고,
기존 DB는 owner가 단일 트랜잭션으로 실행한다. 애플리케이션 역할 startup은 readiness만 확인하며 runtime
DDL로 빠진 계약을 보충하지 않는다. 누락되면 migration 17 적용을 요구하며 fail-closed한다.
### 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 본문 미저장).
- 측정 실행 감사(append-only): `audit.model_run`은 provider/model뿐 아니라 prompt bundle id/version/hash,
structured schema version, input evidence hash를 남긴다. `model_inferred`·`agent_reported` 측정은 이 실행을
참조하지 않으면 저장할 수 없다.
### 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).
G0 측정 benchmark는 운영 회기와 분리된 `ds.benchmark_case` / `ds.benchmark_observation`에 둔다. 첫 pack은
goal/task mismatch, empathic miss, withdrawal, confrontation, successful/failed repair,
warm-but-directionless의 8개 버전 고정 장면이며 기대 방향과 근거 turn index, 금지 주장을 함께 저장한다.
### 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;
measurement 원장은 supervisor/research까지 확장)
+ `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/도메인 수준 정보만
서버 로그에 남긴다. Uvicorn access logger에는 별도 필터를 설치해 `/auth/callback`과 후행 슬래시 변형의
query 전체를 제거하되 다른 요청의 query는 보존한다. 프론트는 `token_exchange_failed`, `invalid_state`, provider error(`access_denied`/`provider_error`),
identity claim 실패를 구분하고 실패 reason code를 화면에 함께 표시한다.
- 역할은 `AUTH_TEACHER_EMAILS`/`AUTH_ADMIN_EMAILS` email allowlist로 1차 판정한다.
실제 `admin` 역할 사용자는 관리자 콘솔, 교수자 공간, 학습자 공간에 모두 접근할 수 있다.
`admin_access`는 기본 역할과 별도인 관리자 콘솔 진입 권한이며, 비관리자 계정에 학습자·교수자
공간 접근권을 추가하지 않는다. 슈퍼 관리자만 `/admin/users`에서 `admin_access`를 부여·회수할 수 있다.
`AUTH_SUPER_ADMIN_EMAILS`는 항상 관리자 콘솔 접근, 학습자·교수자 공간 접근,
approved 상태를 부여하는 신뢰 루트다(기본 `yunchan@twentyoz.kr`, `hoonjungkoo@hs.ac.kr`). 코호트는
`AUTH_EMAIL_COHORT_MAP`과 `AUTH_DOMAIN_COHORT_MAP` 설정, SAML fixture의 `cohort` claim을 합쳐
`cohort_ids`로 세션에 저장한다. 관리 사용자 `app_user.external_id`는 provider subject 기반
(`google:`/`saml:`/`dev:`)으로 저장해 email 변경 리스크를 줄인다.
- 한 사람이 복수 Google subject를 가져야 할 때는 소유자가 검증한 `app.auth_identity_alias`만
provider subject를 canonical `app_user`에 연결한다. callback은 이메일 조회보다 이 별칭을 먼저 해석하고,
역할·코호트·온보딩·관리자 권한과 학습 데이터는 canonical 사용자에서 읽되 `auth_session.login_email`에는
실제 로그인에 사용한 검증 이메일을 별도로 남긴다. 같은 이메일이라는 이유만으로 계정을 자동 병합하지
않으며 inactive/suspended canonical 대상은 fail-closed한다. 별칭 테이블은 RLS SELECT 정책만 두어 런타임
역할의 쓰기를 막고, 연결·해제는 owner-run migration/복구 절차와 감사 로그로만 수행한다.
- 관리자 외부 연구참여자 사전등록 `POST /admin/users`는 요청값과 무관하게 항상
`account_status=pending`으로 정확한 이메일 계정을 만든다. 승인·정지는 별도
`PATCH /admin/users/{user_id}`에서만 수행해 create와 승인 권한 효과를 분리한다. 이는 공개 무제한 가입
엔드포인트가 아니며 관리자 콘솔의 사전등록 탭과 승인 큐가 같은 경계를 따른다.
- 프론트의 최초 진입 경로(`initialPathForUser`)는 pending이면 `/pending`, 온보딩 미완료 일반 사용자는
`/onboarding`, 관리자 콘솔 접근권이 있는 사용자는 기본 역할이 learner/teacher여도 `/admin`을 우선한다.
역할 전환용 `roleHomePath`는 그대로 역할별 홈(`/learn`, `/teach`, `/admin`)만 소유한다.
- SPA 라우트 전환은 `App.tsx`가 `pathname` 변경마다 문서 스크롤을 맨 위로 복원하고 브라우저
`history.scrollRestoration`을 `manual`로 둔다. 실제 세로 스크롤 소유자는 document가 아니라
`.vg-main`이므로 `AppShell`이 mount·pathname 변경·`pageshow`마다 내부 `scrollTop/scrollLeft`를
직접 0으로 되돌린다. 재부팅 뒤 기존 관리자 탭이나 긴 관리자 하위 페이지에서 이동한 화면에 sticky 셸만
남고 본문이 화면 밖에 머무르는 빈 화면을 막는다.
- 관리자 콘솔은 shell만 남고 본문이 비는 silent failure를 허용하지 않는다. `/admin/users` 응답의
`cohort_ids`, `active_sessions`, `created_at`와 `/admin/tickets` 응답의 `summary`, `by_status`,
`by_priority` 같은 필드가 런타임 계약과 다르면 프론트가 안전한 기본값으로 정규화하고 `관리자 데이터 진단`
패널에 깨진 필드와 asset/path를 표시한다. 앱 route-level error boundary는 관리자 컴포넌트 바깥에서
터진 렌더 예외도 full white page 대신 진단 화면으로 노출하고, 본문 0-height 상태도 route-scoped
diagnostic panel로 표시해 운영자가 원인 없이 빈칸만 보지 않게 한다.
- 관리자 DOM/CSS는 광고 차단기의 generic cosmetic selector와 충돌하는 `ad-*` 접두사를 쓰지 않고
`vgops-*` namespace를 쓴다. EasyList에는 `.ad-root`와 `.ad-section`이 실제 광고 숨김 규칙으로
등록되어 있어, 옛 접두사는 API가 모두 정상이어도 관리자 본문 전체를 `display:none`으로 만들었다.
관리자 루트와 진단 marker는 class명이 아닌 `data-vignette-admin-*` 속성으로 식별한다.
- 이 계약은 관례가 아니라 빌드 게이트다. `scripts/check-cosmetic-filter-safety.mjs`가 프로덕션 TS/TSX/CSS와
`index.html`의 class/id 후보를 정적으로 검사하고, 광고 의미의 위험 namespace를 발견하면 `build`와
`lint`를 실패시킨다. 검사기는 내장된 위험/안전 fixture를 매번 먼저 실행해 탐지 로직이 무력화된 채
통과하지 못하게 한다. 공개 관리자 E2E는 동일 EasyList 규칙 아래 5경로의 실제 픽셀 가시성을 별도로
검증하므로 정적 검사와 런타임 검사가 서로 다른 실패면을 막는다.
- `index.html`은 React module 실행 자체가 실패하는 경우도 full white page로 두지 않는다. 부트스트랩 watchdog은
3.5초/8초 시점에 body/root visible content와 관리자 본문 marker를 검사하고, 실패 시 `Vignette 화면 진단`
패널을 React root 바깥 body에 직접 추가해 path, asset, bodyText, visibleNodes, mainScrollTop, global error를
표시한다. visible node와 관리자 marker는 `.vg-main` viewport 교차, rect, computed display/visibility/opacity를
함께 검사하므로 화면 밖 DOM이나 광고 차단기에 숨은 DOM을 정상 픽셀로 오인하지 않는다.
- 공개 런타임은 관리자·인증 제어면(`environment=prod`, `db=true`)과 AI 엔진 readiness를 별도 게이트로
감시한다. 엔진만 실패하면 `start-public-runtime.ps1`가 살아 있는 API·웹·cloudflared를 유지하고 엔진만
복구한다. 부팅 작업도 엔진이 늦더라도 관리자·인증 API가 준비되면 성공으로 끝내며 degraded 엔진은 별도
경고로 남긴다. 따라서 엔진 probe 실패가 API 재시작과 관리자 세션 복원 공백으로 전파되지 않는다.
- 설정 기반 슈퍼 관리자/관리자 이메일이 로그인하거나 기존 세션을 복원하면 유효한 관리자 접근권을
`app.app_user.admin_access`에도 영속화한다. primary role은 learner/teacher로 유지할 수 있지만,
재기동 뒤 환경설정 판정만으로 권한을 복원하지 않으며 기본 역할 사이드바에도 `운영 콘솔` 링크를 노출한다.
관리자 작업면에서는 `navRole="admin"`으로 전체 관리자 5경로를 항상 표시한다.
- OAuth 로그인에서 이메일 기반 기존 사용자와 provider external id를 연결할 때 nullable
`admin_access` SQL 인자는 명시적으로 `boolean` 캐스팅한다. non-dev의 managed-user 저장 실패는
메모리 폴백으로 숨기지 않고 원래 DB 예외를 서버 로그에 남긴 뒤 503으로 닫는다.
- OAuth/SAML callback은 저장된 `next`가 일반 진입 경로(`/`, `/learn`, `/teach`, `/login`, `/onboarding`)이고
로그인 사용자가 관리자 콘솔 접근권을 가지면 `/admin`으로 정규화한다. 단, `/learn/session/...` 같은 깊은
링크는 사용자가 의도적으로 연 URL일 수 있으므로 보존한다.
- Google OIDC는 ID token의 issuer·audience·서명 검증 경로와 `email_verified`를 통과한 모든 이메일을
도메인·사전등록 없이 허용한다. 신규 사용자는 learner·`approved`로 만들고, 기존 pending row를 Google
external id로 연결할 때도 approved로 승격한다. 기존 inactive/suspended 계정은 이 경로로 우회하지 못한다.
- `AUTH_ALLOWED_EMAIL_DOMAINS`는 dev-login·SAML 조직 정책용 게이트다. 슈퍼 관리자/관리자가
`/admin/users`에 미리 만든 정확한 이메일은 이 비-Google 경계에서 도메인 밖 예외로 허용하고,
`email:<주소>` 관리 row를 provider external id로 이어받아 역할·코호트·승인 상태를 보존한다.
- SAML 등 비-Google 신규 사용자는 기본적으로 `account_status=pending`으로 생성된다
(`AUTH_NEW_USER_DEFAULT_STATUS`). `/auth/me`만 pending 상태 확인용으로 열어두고, 그 외 REST/WS 기능
경로는 `account_pending` 또는 인증 실패로 막는다. `AUTH_APPROVED_EMAILS`, 관리자 생성 사용자,
`AUTH_SUPER_ADMIN_EMAILS`, 로컬 dev-login(`external_id=dev:*`)은 자동 approved다.
- 프론트 전역 `PendingApprovalGate`는 pending/suspended 사용자를 `/pending`으로 돌리고, 관리자는
`/admin/users`의 가입 승인 탭에서 pending 계정을 approved 또는 suspended로 변경한다.
- Google/SAML/dev-login 성공 직후 `onboarding_completed_at`이 없거나 닉네임/자기소개가 비어 있으면
프론트 전역 `OnboardingGate`가 `/onboarding` 외 모든 앱 URL(`/learn`, `/settings`, `/admin`,
`/dev/avatar-preview`, `/login` 포함)을 `/onboarding`으로 돌린다. 온보딩 중에는 공용 셸 메뉴를
렌더링하지 않고, 빈 상태 카드 대신 가입 직후 사용자 정보를 입력하는 단순 폼만 보여준다.
이 화면은 이메일을 다시 받지 않고 닉네임, 자기소개,
선택 아바타 이미지, 이름, 소속, 학과, 학년/직위, 연락처, 주소/수령지와 서비스 이용약관·개인정보
처리방침 초안 동의를 저장한다. 아바타 파일은 `POST /users/me/avatar`가 MIME/시그니처/3MB 제한을
확인한 뒤 `USER_UPLOAD_DIR/profile-avatars`에 저장하고 URL만 `app_user.avatar_url`에 보관한다.
학습자 `POST /sessions`와 dev 음성 persona 시작은 온보딩 완료 후에만 허용하며, 온보딩 저장 시
learner `consent_at`도 함께 세팅한다.
- 관리자/교수자 권한은 온보딩 화면에서 신청받지 않는다. 서버는 `AUTH_ADMIN_EMAILS` /
`AUTH_TEACHER_EMAILS` allowlist와 관리자 사용자 관리 경로로만 역할을 부여한다.
- 로컬/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)은 동작 |
| 선택 STT credential 미설정 | `/voice/health` `degraded` 또는 `ready`의 batch fallback 메타 | Deepgram 선택+key 없음은 OpenAI key가 있을 때만 `openai-batch-fallback`; usable credential이 없으면 WS degraded close |
| 운영 TTS provider/model 불일치 | health/ready expected exact match 실패 | 운영은 OpenAI API `gpt-4o-mini-tts`와 AI 생성 음성 고지를 유지하고, Higgs·sample provider는 dev-only로 차단 |
| 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 턴 생성, 다중 공급자)
```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)
```
`claude_api`는 게이트웨이 프로세스에 `ANTHROPIC_API_KEY`, CLI 모드는 해당 호스트에 로그인된
`claude`/`codex`/`agy` 실행 파일이 필요하다. 관리자 화면의 목록 조회가 unavailable이면 저장도 차단된다.
게이트웨이가 없으면 `/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 + Web + Proxy)
`infra/docker-compose.yml`(db: pgvector pg16 + api + web + proxy). Docker Desktop 필요. DB 컨테이너 기동 시
`infra/db/init/*.sql`이 순서대로 적용된다. API 이미지는 저장소 루트 컨텍스트에서 빌드해
`data/personas`를 포함하고, 사용자 업로드는 `apiuploads` 볼륨(`/app/uploads`)에 보관한다.
RAG 임베딩/리랭커 의존성은 기본 슬림 이미지에 넣지 않고 `INSTALL_RAG=true` build arg로만 설치한다.
라이브 코칭 source pack(`data/kb/live_coaching_*`)도 같은 이미지 입력이므로 clean clone/배포지에서 누락되면
`scripts/check-deploy-preflight.py`가 실패해야 정상이다. DB schema drift는 API startup에서 owner 권한을 키워
수습하지 않고, owner-run init/migration 후 app-role DSN으로 preflight를 통과시킨 뒤 API를 띄운다.
---
## 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해 학습자-안전 형태로 가공한다.
- 평가/로깅 훅 실패는 비치명적으로 흡수되어 상담 루프를 멈추지 않는다.
- 회기 종료 영속화는 deep 평가보다 먼저 확정한다. 오래된 회기의 평가 실패는 error/degraded 리뷰로 남지만
새 회기 생성과 턴 저장을 막지 않는다.
- 학습자 AI 파생 피드백은 현재 계정 설정과 세션 snapshot의 논리 AND다. 현재 OFF가 과거 ON보다 우선하고,
순수 파생 API는 403, 기존 공개 share token은 404다. 사용자 원문/입력·privacy 조작은 보존하며
teacher/admin 감독 뷰는 유지한다.
```