vignette/docs/guides/architecture.md
2026-07-15 21:31:30 +09:00

73 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 풀     │ 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_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()) + 엔진 게이트웨이 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/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).
  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.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

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한다.
  • 텍스트 턴의 AI 내담자 응답은 인증된 POST /voice/speechsession_id/turn_seq로 소유 회기를 다시 로드하고, 이미 저장된 client-visible 내담자 응답만 MP3로 합성한다. 브라우저가 임의 문장을 보내는 유료 TTS 프록시가 아니며, 마이크 WebSocket과 같은 voice map/OpenAI TTS 어댑터를 공유한다.
  • 키 없으면 명확히 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_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 내담자 음성을 재생한다. 새 텍스트 발화를 보내면 이전 합성/재생 요청을 무효화해 겹쳐 재생하지 않는다.

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.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 시점에 학습자 발화 + 누적 내담자 응답을 영속화. 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 평가를 비동기 태스크로 발사.
  • 진행도 파생(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) 기준 카운트이며, 보관된 종료 회기는 리뷰 대기 행동 큐에서 제외한다. 성취는 공식 등급/수료가 아니라 실제 연습 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_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로 변환해야 한다. 브라우저로 재방출되는 /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)로 폴백.
  • 페르소나 워크스페이스: /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에 감사 기록.

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/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/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/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/상태/렌더 책임과 분리한다.
  • 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/에 순서대로 적용된다 (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_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는 관리자 전체 처리만 허용한다.
  • 운영 콘솔 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_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 본문 미저장).

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).

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_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/도메인 수준 정보만 서버 로그에 남긴다. 프론트는 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 변경 리스크를 줄인다.
  • 프론트의 최초 진입 경로(initialPathForUser)는 pending이면 /pending, 온보딩 미완료 일반 사용자는 /onboarding, 관리자 콘솔 접근권이 있는 사용자는 기본 역할이 learner/teacher여도 /admin을 우선한다. 역할 전환용 roleHomePath는 그대로 역할별 홈(/learn, /teach, /admin)만 소유한다.
  • SPA 라우트 전환은 App.tsxpathname 변경마다 문서 스크롤을 맨 위로 복원한다. 또한 브라우저 history.scrollRestorationmanual로 두고 pageshow에서도 다시 복원해, 재부팅 뒤 기존 관리자 탭을 되살릴 때 이전 scrollY 때문에 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로 표시해 운영자가 원인 없이 빈칸만 보지 않게 한다.
  • index.html은 React module 실행 자체가 실패하는 경우도 full white page로 두지 않는다. 부트스트랩 watchdog은 3.5초/8초 시점에 body/root visible content와 관리자 본문 marker를 검사하고, 실패 시 Vignette 화면 진단 패널을 React root 바깥 body에 직접 추가해 path, asset, bodyText, visibleNodes, global error를 표시한다.
  • 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 기동 가능)

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 턴 생성, ENGINE_MODE=claude_cli)

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)

게이트웨이가 없으면 /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해 학습자-안전 형태로 가공한다.
  • 평가/로깅 훅 실패는 비치명적으로 흡수되어 상담 루프를 멈추지 않는다.