# 아키텍처 가이드 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 풀 │ 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` 이벤트 + 안전 대체로 종결한다. 세션 라우트는 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`). 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` 이벤트를 남기고, 충전은 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: `/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` 바인딩이 같은 테이블을 사용하게 한다. - 로컬 개발에서 `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를 산출한다. 디코딩 실패 시 `