32 KiB
아키텍처 가이드
Vignette(AI 심리상담 시뮬레이션 훈련 플랫폼)의 시스템 아키텍처를 실제 코드 기준으로 정리한 문서다.
모든 서술은 저장소의 실제 파일을 근거로 하며, 인용 경로는 저장소 루트(D:/workspace/vignette) 기준 상대경로다.
대상 독자: 백엔드/프론트 기여자, 신규 합류자, 운영자. 같이 보면 좋은 문서:
docs/MASTERPLAN.md,docs/MEMORY_KNOWLEDGE_PERSONA_DESIGN.md, DB DDLinfra/db/init/*.sql.
1. 한눈에 보기
학습자(상담수련생)가 AI 가상내담자 페르소나와 상담 회기를 진행하고, 회기 종료 후 평가 AI가 만든 리뷰 피드백을 받는다. 시스템은 3종 AI 역할을 분리한다.
- 내담자 AI(client) — 페르소나를 연기. 내부 설정(CCD·진단 차원·상태 수치)은 행동으로만 드러냄.
- 상담사 AI(counselor) — 데모/self-play용(스키마·프로필 존재, 런타임 핵심 경로는 인간 학습자가 상담사 역할).
- 평가 AI(evaluator) — 슈퍼바이저. 학습자 발화를 4차원 태깅(fast-loop) + 회기말 정밀평가(deep-loop).
핵심 설계 원칙:
- 결정론과 LLM의 분리: 단계 전이·개방도(openness)·저항 수치는 백엔드가 결정론으로 소유하고,
LLM이 절대 만지지 않는다(
services/state_machine.py). LLM은 "연기"와 "평가"만 한다. - 하드 안전 게이트: PII 마스킹 후에만 외부 LLM 경로로 텍스트가 나가고(
services/guardrail.py), 자살 수단/방법 정보는 출력 가드레일이 차단한다. - 주입형(hook) 경계: 오케스트레이터는 평가/로깅 함수를 주입받는다.
services/orchestrator.py는services/evaluator.py를 import 하지 않는다(소유권 분리). - degraded 폴백: DB(단일 SoR)가 없어도
app/store.pyin-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), 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)에서:
init_pool()→ DB 풀 생성,ensure_runtime_tables()/ensure_review_tables()보장.settings.auto_seed_personas면materialize_seed_personas()로 시드 P1~P3을app.persona_card에 upsert.engine_client.startup()/voice_service.startup()로 httpx 클라이언트 준비.- 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, ...}를 반환한다.
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~L6EngineMessage[]조립.- 회상/핀/직전 턴 등 주입 텍스트도 전부 다시 마스킹한다(
_mask_optional_text등).
run_turn_generate(ctx, engine, *, eval_hook=None, log_hook=None)(4~8, 동기/폴백/테스트 경로):engine.generate()로 응답 한 번에 수신 →guardrail.sanitize_client_reply()→ 수단정보 누출 시 안전 대체 응답("…(말을 잇지 못하고 잠시 침묵한다)")으로 치환 → eval/log 훅 순차 적용 →TurnResult반환. 훅 예외는 모두 비치명적으로 흡수(상담 루프를 막지 않음).run_turn_stream(ctx, engine, *, log_hook=None)(4~8, 기본 UX 경로): 게이트웨이 SSE 원시 라인을 받아token | done | safety | error로 재방출. 출력 가드레일은 누적 텍스트 기준으로 수단정보를 스캔하고, 발견 시safety이벤트 + 안전 대체로 종결한다.
TurnContext/TurnResult/StreamEvent dataclass가 파이프라인을 관통한다. EvalHook/LogHook은
Callable[[TurnContext, str], Awaitable[...]] 타입으로 주입된다.
2.3 페르소나 메시지 빌더 — app/services/persona.py (L0~L6)
build_turn_messages(card, state, learner_text_masked, ...)가 한 턴의 EngineMessage[]를 조립한다.
레이어 구조(논리 레이어, docstring):
| 레이어 | 내용 | cache_control | 비고 |
|---|---|---|---|
| L0 | 역할 + 안전 가드레일 + 도식노출금지(L0_SAFETY) |
✅ | 전 페르소나 공통, 캐시 대상 |
| L1 | 페르소나 카드(정적, CCD 포함) | ✅ | build_persona_system_text()로 L0와 합쳐 1개 system |
| L2 | RAG 임상청크 / 회상 + KB 행동단서 | ✅ | 회기 내 1회 로드(캐시 친화) |
| L3 | 상태머신 주입(stage, openness, resistance, ideation) | ❌ | 수치는 내부용, 발화 표면화 금지 |
| L4 | 메모리 버퍼(pinned fact hard-pin) | ❌ | "자기 기억"으로만 표현 |
| L6 | 직전 K턴 맥락(히스토리) + 이번 발화(L5) | ❌ | 상담자=user, 내담자(자기)=assistant 매핑 |
메시지 순서: system(L0+L1, cache) → system(L2, cache) → system(L3) → system(L4) →
assistant/user 히스토리(L6) → user(이번 마스킹 발화, L5). 게이트웨이는 마지막 user를 stdin으로 주입한다.
안전 불변식(L0_SAFETY, docstring R4/R5/M6):
- CCD(core_belief/automatic_thought/coping)·DSM 차원·정답 라벨은 행동으로만 드러낸다. "제 핵심신념은…" 같은 메타 발화 절대 금지(추론 훈련 무력화 차단).
- 자살·자해의 구체적 '방법/수단'은 절대 발화하지 않는다.
_format_openness_directive()가effective_openness수치를 연기 강도 지시문으로 환산한다(수치 자체는 비노출).
시드 페르소나(개발 부트스트랩): SEED_PERSONAS = {P1, P2, P3} — get_seed_persona(code).
| 코드 | 인물 | 난이도 | 이론타깃 | 특징 |
|---|---|---|---|---|
| P1 | 서연(고2) | hard | humanistic | 우울/자살사고(ideation_stage=2), 비자발·고저항(base 0.7) |
| P2 | 민재(32) | moderate | cbt | 범불안/신체화, 자살사고 없음(ideation 1) |
| P3 | 지우(28) | moderate | humanistic | 미혼모 역할부담·소진, 라포/무조건적 존중 연습용 |
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
세 가지 순수 함수 경계:
- 입력 PII 마스킹
mask_pii(text) -> MaskResult: Presidio가 설치돼 있으면 우선 사용, 미설치면 정규식 폴백(_PII_PATTERNS: 주민번호/휴대폰/전화/이메일/장문 숫자열). 마스킹본만 저장·외부 LLM 전송에 쓴다(하드 게이트, F-03). Presidio는 지연 로드 캐시(_try_load_presidio). - 위기 분류
classify_crisis(text, speaker_is_persona_context=True) -> CrisisResult: 가상내담자의 자살사고 연기는 시뮬레이션 정상(PERSONA_PLAY, escalate=False). 그러나 1인칭 실제 단서(_FIRST_PERSON_NOW)가 강하면 수련생 본인의 실제 위기(LEARNER_REAL, escalate=True)로 보수적 승격. - 출력 가드레일
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, 코드펜스 관용). - 엔진/파싱 실패는 비치명적 —
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.7 회기 메모리 — app/services/memory.py
회기 라이프사이클 메모리(4계층 매핑: ① working / ② episodic / ③ summary / ④ semantic).
- 회기 시작:
build_recall_context(...) -> RecallContext— 큰그림(case_digest) → 직전 요약 → episodic 단편 순으로 회상 조립. 회상 요약엔 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 폴백). 페르소나 preset → OpenAI voice 매핑 (PRESET_TO_OPENAI_VOICE, P1=coral / P2=ash / P3=shimmer). 스트림 청크마다 립싱크 RMS 힌트 (estimate_chunk_rms, PCM 디코딩 없이 바이트 에너지 근사) 동봉. - 키 없으면 명확히 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) → tts_chunk(메타) + [binary audio] → tts_end → state(idle)
- 인증: REST와 동일한 서버측 세션 쿠키를 WebSocket 쿠키에서 복원(
_principal_from_websocket), learner만 허용. - 턴 실행은 동일하게
orchestrator.prepare_turn→run_turn_generate를 거치고, 응답 생성 후에만 발화를 영속화한다(실패한 AI 턴이 학습자 단독 축어록을 남기지 않도록). text_turn경로는 접근성/결정론 테스트용 텍스트 전용 경로.
2.9 세션 라우트 — app/routes/sessions.py (실제 데이터 흐름)
학습자 전용. 모든 세션 작업은 소유권(learner_id)을 검사한다(_load_session_or_404).
핵심 엔드포인트:
POST /sessions—get_catalog_persona(code)로 승인 카드 조회 →memory.build_recall_context()→state_machine.init_state(...)→session_persistence.create_session(...). DB가 없으면runtime_fallback_allowed()확인 후store.create(...)로 in-proc 생성.POST /sessions/{id}/turn— 동기 경로.prepare_turn→run_turn_generate→ 학습자 발화 turn(speaker=counselor, 원문text+text_masked) + 내담자 응답 turn(speaker=client) 순차 append → 상태 갱신.EngineError는 503으로 변환.POST /sessions/{id}/stream— SSE 경로(run_turn_stream). token/done/ping/error를 흘리고, done 시점에 학습자 발화 + 누적 내담자 응답을 영속화.sse_heartbeat_seconds마다 ping(Cloudflare 타임아웃 회피).POST /sessions/{id}/end—memory.make_carry_over(...)→ 세션 종료 + carry 준비 →_schedule_session_evaluation(sess)로 deep-loop 평가를 비동기 태스크로 발사.GET /sessions/{id}/review— 저장된 축어록 + 평가 AI 산출물로 학습자-안전 리뷰 구성 (_rubric_from_evaluation,_ai_review_points등). 리뷰 조회 시 발화별 fast-loop 평가는app.feedback_scores/라벨 조인 테이블에서TurnRecord.evaluation형태로 hydrate한다. 평가 미완이면degraded/reviewReady=false로 표기.
발화 가시성: 학습자에게는 visible_to에 counselor가 포함된 턴만 보여준다
(_learner_visible_turns → _LEARNER_VISIBLE_AI_ROLE="counselor"). 평가 전용 데이터는 노출되지 않는다.
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, tier, model, max_tokens, temperature, structured_schema, session_id, metadata)—tier:client(Sonnet/Solar) /feedback(Opus) /fast(Haiku) 라우팅 힌트.session_id를 넘기면 게이트웨이가 상주 풀을 재사용해 멀티턴 prompt caching 이점을 살린다.- 장애는
EngineError로 전파 → 라우트가 503/SSE error 프레임으로 변환. - 앱 전역 싱글톤
engine_client(lifespan에서 startup/shutdown).
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) 순으로 시도한다.
2.12 페르소나 카탈로그 — app/persona_repository.py
DB app.persona_card가 승인 페르소나의 SoR. 이 모듈이 in-proc PersonaCard와 DB 행을 잇는 경계다.
materialize_seed_personas()— 시드 P1~P3을status='approved'로 upsert(admin 롤).get_approved_persona(code)/list_approved_personas()— 승인된 최신 버전 조회(AI 컨텍스트).get_catalog_persona(code)— 승인 카드 조회, 실패 시settings.allow_seed_persona_fallback이면seed_fallback_persona(degraded=True)로 폴백.- 교수 검수:
list_persona_review_queue(role)/update_persona_review_status(action=approve|reject)— 승인/반려 시audit.audit_log에 감사 기록.
3. 엔진 게이트웨이 — apps/api/engine_gateway/gateway.py
컨테이너 밖(호스트)에서 도는 별도 서비스. claude -p(Opus 4.8) 상주 멀티턴 풀을 흡수한다.
회기당 1 EngineSession = claude -p 프로세스 1개 상주 → 페르소나 system 프롬프트 고정 + 발화마다
stdin 주입(턴 간 컨텍스트 유지 + prompt caching 재사용).
실행:
cd apps/api && uvicorn engine_gateway.gateway:app --host 0.0.0.0 --port 9099
엔드포인트 2계열:
- 상주 풀
/session,/session/{sid}/turn,/session/{sid}(DELETE) — 회기 단위 프로세스. - stateless 어댑터(engine_client 계약과 정합):
POST /v1/generate—EngineMessage[]→_split_messages()로 (system_prompt, last_user) 분리 →--append-system-prompt로 주입, 마지막 user를 stdin content로. structured_schema는_inject_schema()로 system에 JSON 준수 지시 주입(claude -p는 response_format 미지원이라 차선).POST /v1/stream—turn_stream()이 assistant 텍스트 델타를 즉시 yield → SSEevent: 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의engine_url기본값은 compose 서비스명 기반http://engine:8100이다. 로컬에서는ENGINE_URL을 게이트웨이 실제 포트 (예:http://127.0.0.1:9099)로 맞춰 주입한다.
4. 프론트엔드(apps/web)
React 19 + Vite. 라우팅은 apps/web/src/App.tsx(react-router-dom).
| 경로 | 화면 | 역할 가드 |
|---|---|---|
/login |
Login (dev-login 경로 포함) | 공개 |
/learn |
LearnerHome | learner |
/learn/session/:sessionId |
Session(상담 화면) | learner |
/learn/session/:sessionId/review |
SessionReview(회기 리뷰) | learner |
/learn/avatar-expressions |
AvatarExpressionLab | learner |
/teach |
Professor(교수자 대시보드) | teacher |
/admin |
Admin | admin |
/settings |
Settings | 3역할 공통 |
RequireAuth가lib/auth의 AuthContext로 가드하고, 권한 불일치 시 역할 홈(roleHomePath)으로 보낸다.- API 클라이언트
src/lib/api.ts:apiFetch/api(get/post/put/del) — 표준 JSON, 에러는ApiError로 정규화, 항상credentials:"include".- SSE:
openSessionStream(sessionId, text, handlers)—fetch스트림을 직접 라인 파싱해token | done | safety | ping | error이벤트를 콜백으로 전달. - 도메인 헬퍼:
sessionApi(list/get/start/turn/end/review/stream),personaApi,personaReviewApi,adminApi,adminEngineApi,teacherApi,userApi. 응답 타입은 백엔드 라우트의 pydantic 모델 미러.
- dev 환경:
vite.config가/api→http://127.0.0.1:8000프록시(/api프리픽스 제거). 배포 호스트(vignette.chanpaca.net,*.pages.dev)에서는api-vignette.chanpaca.net을 직접 가리킨다.
세션 화면 흐름(요약): 학습자 발화 입력 → sessionApi.stream(id, text)(SSE) 또는 turn(동기) →
토큰 누적 표시 → 회기 종료(end) → /review에서 sessionApi.review(id)로 리뷰 표시.
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/instructor/admin, cohort, consent_at). - 페르소나
app.persona_card— 불변 버전드 카드(UNIQUE(code, version), status draft→review→approved→archived, ccd/dsm5_dimensional/affect_baseline는 JSONB).app.persona_voice_map(provider-agnostic 음성),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.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).turn_technique/turn_client_state다대다,supervisor_comment(rationale/critique + intent_deviation JSONB),safety_events. - 메모리 4계층:
- ① WORKING
app.session_state— 상태머신 수치 체크포인트(매 턴 UPSERT, openness/ideation CHECK 제약). - ② EPISODIC 임베딩
app.turn_embedding— BGE-M3 densevector(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_historyappend-only).
- ① WORKING
5.2 audit 스키마 + app 평가/종단 (infra/db/init/04_audit_eval_rls.sql)
- 평가:
app.feedback_scores(발화별 점수·rationale,visible_to='{evaluator}', loop fast/deep),app.turn_technique/app.turn_client_state(fast-loop 라벨 정규화),app.supervisor_comment(intent_deviation).app.alternative_utterance는 deep-loop 대안발화 정규화 대상이다.session_persistence.append_turn은app.turns INSERT ... RETURNING id를 확인한 뒤 같은 트랜잭션에서 evaluator 컨텍스트로 fast-loop 평가 rows를 적재한다. 리뷰 경로만 evaluator 컨텍스트로 hydrate한다. - 종단:
app.learner_profile(dim_ewma/dim_slope/persistent_gaps — 회기 거듭하며 나아지는지). - 감사(append-only):
audit.persona_drift_log(임베딩 일관성/CCD 누출),audit.audit_log(인간 열람 추적),audit.llm_call_log(비용·inference_geo).
5.3 ds 스키마 — 재귀학습 파이프라인 (infra/db/init/04_audit_eval_rls.sql §4)
AI자동(1R) → 인간검수(2R) → IAA 게이트(κ≥0.6, ICC≥0.75) → 골든셋 → JSONL.
ds.dataset → ds.dataset_item → ds.annotation_round(ai_auto/human_review) → ds.annotation
→ ds.export_manifest(iaa_kappa/iaa_icc/jsonl_path).
5.4 정보비대칭 2-레이어 + RLS
DB 레벨 이중강제(04_audit_eval_rls.sql §5, app/db.py acquire()):
- 레이어1 (AI 정보비대칭):
app.ai_context='1'+app.current_ai_view(client/counselor/evaluator)app.current_sens_max→turns/kb.chunk/pinned_fact의visible_to[]/sensitivityWHERE 강제. 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. 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 사용 방지).
7. 로컬 실행 (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/.env에 AUTH_DEV_LOGIN_ENABLED=true, ENVIRONMENT=dev):
POST /auth/dev-login {email, role: learner|teacher|admin, display_name}
→ __Host-vignette_sid 쿠키 발급(웹은 Login.tsx에 dev-login 경로 존재)
7.2 엔진 게이트웨이 (AI 턴 생성, ENGINE_MODE=claude_cli)
cd D:\workspace\vignette\apps\api
python -m uvicorn engine_gateway.gateway:app --host 0.0.0.0 --port 9099
# API의 ENGINE_URL을 게이트웨이 포트로 맞춘다(예: http://127.0.0.1:9099)
게이트웨이가 없으면 /health의 engine:false이고 턴 생성은 실패하지만, UI/네비/로그인/페르소나/
세션생성(in-memory)은 동작한다.
7.3 웹
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)
infra/docker-compose.yml(db: pgvector pg16 + api). Docker Desktop 필요. DB 컨테이너 기동 시
infra/db/init/*.sql이 순서대로 적용된다.
8. 안전 불변식 요약(설계 보증)
- 마스킹되지 않은 원문은 외부 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해 학습자-안전 형태로 가공한다. - 평가/로깅 훅 실패는 비치명적으로 흡수되어 상담 루프를 멈추지 않는다.