vignette/docs/guides/architecture.md
2026-07-03 20:27:54 +09:00

846 lines
66 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+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 풀 │ 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`에 누락분만 물리화한다. 기존 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()`) + 엔진 게이트웨이 readiness
(`engine_client.health_detail()`)를 합쳐 `{"status": "ok|degraded", db, engine, engine_mode, ...}`를 반환한다.
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` 이벤트 + 안전 대체로 종결한다.
`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`).
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)을 관리자 관측값으로 반환한다.
`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` 이벤트를 남기고,
턴 평가가 `appropriateness=pos`, `rapport_signal>=0.35`, 그리고 단계 전환 또는 `effective_openness`
상승을 동시에 만족하면 `recharge/+1` 이벤트로 1개를 재충전한다(최대 3개). 사용권 없음은 409로 막는다.
- 전달된 코칭과 충전/사용 기록은 `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`
OpenAI STT/TTS 어댑터(순수 변환 + voice preset 매핑). 상담 로직은 라우트가 조립한다.
- STT: `/audio/transcriptions` (gpt-4o-transcribe → 404 시 whisper-1 폴백), 언어 힌트 `ko`.
- 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` 바인딩이 같은 테이블을 사용하게 한다.
프론트는 마이크 클릭 시 `AudioContext`를 먼저 resume해 재생 권한을 확보하고, TTS Blob을 Web Audio
buffer source로 재생하면서 `AnalyserNode`로 립싱크 RMS를 산출한다. 디코딩 실패 시 `<audio>` 재생으로
fallback한다.
- 키 없으면 명확히 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) → [binary audio] → tts_end → state(idle)
```
- 인증: REST와 동일한 서버측 세션 쿠키를 WebSocket 쿠키에서 복원(`_principal_from_websocket`),
learner만 허용.
- 턴 실행은 동일하게 `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를 싣지 않는다.
- `text_turn` 경로는 접근성/결정론 테스트용 텍스트 전용 경로.
### 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.theory_target`
기본값을 쓰되 학습자가 `humanistic`/`cbt`/`integrative` 중 하나를 명시 선택해 기존
`theory_mode` 계약으로 보낸다. 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}/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 평가를 비동기 태스크로 발사.
- `GET /sessions/dashboard` — 학습자 본인 세션만 `include_turn_evaluation=true`로 집계해
`overview/growth/persona_progress/achievements/recent_feedback`를 반환한다. `overview.archived_sessions`
학습자별 보관 상태(`app.session_archive_state`) 기준 카운트이며, 보관된 종료 회기는 리뷰 대기 행동 큐에서
제외한다. 성취는 공식 등급/수료가 아니라 실제 연습 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`로 분리해 리뷰 카드 안에서 인용 블록으로 표시한다.
- `POST /sessions/{id}/share` — 학습자가 종료된 본인 회기 리뷰를 URL로 공유하기 위해 공개 토큰을 생성한다.
서버는 `session_read_model.session_share_payload(...)`로 preview를 정규화한 뒤 `app.session_share_link`
토큰 해시와 sanitized preview payload만 저장하고, 원문 축어록·학습자
식별자는 payload에 넣지 않는다. 토큰은 생성 응답에서만 반환되며 새 생성은 기존 토큰을 교체한다.
- `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로 변환해야 한다.
브라우저로 재방출되는 `/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)로 폴백.
- 페르소나 스튜디오: `/teach/personas`가 teacher/admin 전용 저작 작업면이다. 교수 콘솔은 진입점/triage만 맡고,
실제 저작은 개요·임상·저항·회기·말투·안전·프롬프트 탭으로 분리한다.
- 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`에 감사 기록.
---
## 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()`
`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/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`의 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/users` | Admin(사용자 관리) | admin |
| `/admin/access` | Admin(접근 권한) | admin |
| `/admin/tickets` | 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`(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로 빼서 인증·데이터 상태 로직과 섞지 않는다.
- 로그인 진입 화면은 `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/`에 순서대로 적용된다
(`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/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는 관리자 전체 처리만 허용한다.
- **운영 콘솔** `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.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차 판정한다.
실제 `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 변경 리스크를 줄인다.
- 프론트의 최초 진입 경로(`initialPathForUser`)는 pending이면 `/pending`, 온보딩 미완료 일반 사용자는
`/onboarding`, 관리자 콘솔 접근권이 있는 사용자는 기본 역할이 learner/teacher여도 `/admin`을 우선한다.
역할 전환용 `roleHomePath`는 그대로 역할별 홈(`/learn`, `/teach`, `/admin`)만 소유한다.
- SPA 라우트 전환은 `App.tsx``pathname` 변경마다 문서 스크롤을 맨 위로 복원한다. 긴 관리자 목록에서
짧은 `/admin/access` 정책 화면으로 이동할 때 이전 `scrollY`가 유지되어 sticky 셸만 보이는 빈 화면을 막기 위한 계약이다.
- OAuth/SAML callback은 저장된 `next`가 일반 진입 경로(`/`, `/learn`, `/teach`, `/login`, `/onboarding`)이고
로그인 사용자가 관리자 콘솔 접근권을 가지면 `/admin`으로 정규화한다. 단, `/learn/session/...` 같은 깊은
링크는 사용자가 의도적으로 연 URL일 수 있으므로 보존한다.
- `AUTH_ALLOWED_EMAIL_DOMAINS`는 기본 도메인 게이트다. 단, 슈퍼 관리자/관리자가 `/admin/users`
미리 만든 정확한 이메일은 도메인 밖이어도 Google/SAML/dev-login의 이메일 검증을 통과한다.
이 예외는 도메인 전체를 열지 않고, provider 로그인 시 기존 `email:<주소>` 관리 row를
`google:`/`saml:`/`dev:` external_id로 이어받아 역할·코호트·승인 상태를 보존한다.
- 신규 Google/SAML 사용자는 기본적으로 `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)은 동작 |
| 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 + 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해 학습자-안전 형태로 가공한다.
- 평가/로깅 훅 실패는 비치명적으로 흡수되어 상담 루프를 멈추지 않는다.
```