vignette/docs/guides/architecture.md

101 KiB
Raw Blame History

아키텍처 가이드

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.pyservices/evaluator.py를 import 하지 않는다(소유권 분리).
  • LLM 감사 경계: evaluator/orchestrator의 생성 시간·토큰·비용 기록은 services/llm_audit.pygenerate_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_personasmaterialize_seed_personas()로 시스템 페르소나 P1~P3과 저장소 data/personas/P4~P7.jsonapp.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/LogHookCallable[[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.jsonpersona_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.jsondata/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.sourcekb.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) 형태가 아니라 quotacredit_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/speechsession_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_turnrun_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 /sessionsget_catalog_persona(code)로 승인 카드 조회 → case_profile upsert → case digest/직전 summary/pinned fact seed recall → state_machine.init_state(...)session_persistence.create_session(...). 생성 시 승인 페르소나의 persona_idpersona_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_turnrun_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_eventsuse/-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}/endmemory.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_tocounselor가 포함된 턴만 보여준다 (_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=smtpSMTP_* 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/testAUTH_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.txtnoindex/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/generateGenerateResponse.model_dump()로 응답해 hand-mirrored dict drift를 줄인다. apps/api/engine_gateway/golden/engine_gateway_contract.v1.jsonapps/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) 순으로 시도한다.
  • 회기 평가 저장은 SessionEvaluationWritestatus/source/scope/stage/payload/error write packet을 소유한다. routes/sessions.pyroutes/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_syncscripts/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/generateEngineMessage[]_split_messages()GatewayPromptParts(system_prompt, user_payload) 분리 → --system-prompt로 주입, 마지막 user를 stdin content로. structured_schema는 _inject_schema()로 system에 JSON 준수 지시 주입(claude -p는 response_format 미지원이라 차선).
    • POST /v1/streamturn_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다. openaisolar는 기존 계약값을 보존하지만 실행 어댑터가 없어 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역할 공통
  • RequireAuthlib/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.tsApiSchema<...> alias를 사용하고, generated optional 배열은 화면 렌더링 계층에서 빈 배열 fallback으로 흡수한다. 세션 시작/상세/턴 응답의 stage는 backend StageLabel enum(라포|탐색|개입|정리)에서 생성한 union을 SessionStage로 사용한다.
    • sessionApi.createShare/revokeShare — 학습자 리뷰 화면에서 공개 공유 URL 생성/폐기를 호출한다. SessionReview은 종료된 회기에서만 "공유 URL 복사" 버튼을 노출한다.
  • dev 환경: vite.config/apihttp://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_idbase_paramsVoicePreset으로 해석하고, 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_statesession_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_linksession_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_kindperspective의 호환성, scale, 상태, visible_to[], 모델 provenance를 DB CHECK/RLS로 강제한다. 기존 rapport_creditalliance_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.sqlapp.app_userapp.sessionslearner_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_turnapp.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.datasetds.dataset_itemds.annotation_round(ai_auto/human_review) → ds.annotationds.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_maxturns/kb.chunk/pinned_factvisible_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_MAPAUTH_DOMAIN_COHORT_MAP 설정, SAML fixture의 cohort claim을 합쳐 cohort_ids로 세션에 저장한다. 관리 사용자 app_user.external_id는 provider subject 기반 (google:/saml:/dev:)으로 저장해 email 변경 리스크를 줄인다.
  • 관리자 외부 연구참여자 사전등록 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.tsxpathname 변경마다 문서 스크롤을 맨 위로 복원하고 브라우저 history.scrollRestorationmanual로 둔다. 실제 세로 스크롤 소유자는 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를 발견하면 buildlint를 실패시킨다. 검사기는 내장된 위험/안전 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 기동 가능)

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/.envAUTH_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 턴 생성, 다중 공급자)

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이면 저장도 차단된다. 게이트웨이가 없으면 /healthengine:false이고 턴 생성은 실패하지만, UI/네비/로그인/페르소나/ 세션생성(in-memory)은 동작한다.

7.3 웹

cd D:\workspace\vignette\apps\web
npm run dev   # http://localhost:5173, /api → 127.0.0.1:8000 프록시

7.4 테스트

# 백엔드
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 감독 뷰는 유지한다.