560 lines
36 KiB
Markdown
560 lines
36 KiB
Markdown
# 아키텍처 가이드
|
||
|
||
Vignette(AI 심리상담 시뮬레이션 훈련 플랫폼)의 시스템 아키텍처를 실제 코드 기준으로 정리한 문서다.
|
||
모든 서술은 저장소의 실제 파일을 근거로 하며, 인용 경로는 저장소 루트(`D:/workspace/vignette`) 기준 상대경로다.
|
||
|
||
> 대상 독자: 백엔드/프론트 기여자, 신규 합류자, 운영자.
|
||
> 같이 보면 좋은 문서: `docs/MASTERPLAN.md`, `docs/MEMORY_KNOWLEDGE_PERSONA_DESIGN.md`, DB DDL `infra/db/init/*.sql`.
|
||
|
||
---
|
||
|
||
## 1. 한눈에 보기
|
||
|
||
학습자(상담수련생)가 AI 가상내담자 페르소나와 상담 회기를 진행하고, 회기 종료 후 평가 AI가
|
||
만든 리뷰 피드백을 받는다. 시스템은 **3종 AI 역할**을 분리한다.
|
||
|
||
- **내담자 AI(client)** — 페르소나를 연기. 내부 설정(CCD·진단 차원·상태 수치)은 *행동으로만* 드러냄.
|
||
- **상담사 AI(counselor)** — 데모/self-play용(스키마·프로필 존재, 런타임 핵심 경로는 인간 학습자가 상담사 역할).
|
||
- **평가 AI(evaluator)** — 슈퍼바이저. 학습자 발화를 4차원 태깅(fast-loop) + 회기말 정밀평가(deep-loop).
|
||
|
||
핵심 설계 원칙:
|
||
|
||
- **결정론과 LLM의 분리**: 단계 전이·개방도(openness)·저항 수치는 백엔드가 결정론으로 소유하고,
|
||
LLM이 절대 만지지 않는다(`services/state_machine.py`). LLM은 "연기"와 "평가"만 한다.
|
||
- **하드 안전 게이트**: PII 마스킹 후에만 외부 LLM 경로로 텍스트가 나가고(`services/guardrail.py`),
|
||
자살 수단/방법 정보는 출력 가드레일이 차단한다.
|
||
- **주입형(hook) 경계**: 오케스트레이터는 평가/로깅 함수를 *주입*받는다. `services/orchestrator.py`는
|
||
`services/evaluator.py`를 import 하지 않는다(소유권 분리).
|
||
- **degraded 폴백**: DB(단일 SoR)가 없어도 `app/store.py` in-memory 미러로 1턴이 돈다.
|
||
|
||
### 1.1 모노레포 레이아웃
|
||
|
||
```
|
||
vignette/
|
||
├─ apps/
|
||
│ ├─ api/ FastAPI 백엔드 (Python)
|
||
│ │ ├─ app/ 라우터·서비스·스토어·DB·인증
|
||
│ │ └─ engine_gateway/ 별도 서비스: claude -p 상주 풀 / provider 어댑터
|
||
│ └─ web/ React 19 + Vite + Playwright 프론트
|
||
├─ infra/ docker-compose(db+api), DB 초기화 SQL
|
||
├─ docs/ 설계·운영 문서 (이 파일 포함)
|
||
└─ scripts/ 운영 스크립트(PowerShell 등)
|
||
```
|
||
|
||
### 1.2 런타임 토폴로지
|
||
|
||
```
|
||
┌──────────────────────────────────────────┐
|
||
브라우저 │ apps/web (Vite dev :5173) │
|
||
(학습자/교수/ │ /learn /teach /admin /settings │
|
||
관리자) │ api.ts: fetch(credentials:include) + SSE │
|
||
└───────────────┬──────────────────────────┘
|
||
│ /api → 프록시(프리픽스 제거)
|
||
▼
|
||
┌──────────────────────────────────────────┐
|
||
│ apps/api app.main:app (uvicorn :8000) │
|
||
│ routes/ → services/ → engine_client │
|
||
└───┬───────────────┬───────────────┬───────┘
|
||
│ HTTP(ENGINE_URL)│ asyncpg 풀 │ httpx(OpenAI)
|
||
▼ ▼ ▼
|
||
engine_gateway Postgres16 OpenAI
|
||
(:9099 등 host) + pgvector STT/TTS
|
||
claude -p 상주풀 (app/audit/kb/ds) (voice 캐스케이드)
|
||
│
|
||
▼ subprocess
|
||
claude CLI (Opus 4.8) — ENGINE_MODE=claude_cli
|
||
```
|
||
|
||
- 프론트는 `apps/web/src/lib/api.ts`에서 모든 요청에 `credentials:"include"`를 붙여 BFF
|
||
HttpOnly 쿠키(`__Host-vignette_sid`)를 전송한다. SSE는 `EventSource`가 아니라
|
||
`fetch` 스트림으로 직접 파싱한다(`openSessionStream`).
|
||
- 백엔드는 엔진 게이트웨이를 `ENGINE_URL`로 HTTP 호출만 한다(`app/engine_client.py`).
|
||
게이트웨이가 provider 라우팅·캐싱·상주 프로세스 풀을 흡수한다.
|
||
|
||
---
|
||
|
||
## 2. 백엔드(apps/api/app)
|
||
|
||
### 2.1 앱 엔트리포인트 — `app/main.py`
|
||
|
||
`lifespan`(startup/shutdown)에서:
|
||
|
||
1. `init_pool()` → DB 풀 생성, `ensure_runtime_tables()` / `ensure_review_tables()` 보장.
|
||
2. `settings.auto_seed_personas` 면 `materialize_seed_personas()`로 시드 P1~P3과 저장소 `data/personas/P4~P7.json`을 `app.persona_card`에 upsert.
|
||
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, ...}`를 반환한다.
|
||
|
||
### 2.2 턴 오케스트레이터 — `app/services/orchestrator.py`
|
||
|
||
상담 1턴 파이프라인을 1~8단계로 조립한다. 모듈 상단 docstring에 단계가 그대로 명시돼 있다.
|
||
|
||
| 단계 | 내용 | 소유 |
|
||
|---|---|---|
|
||
| 1 | 입력 가드레일 — PII 마스킹 + 위기분류 | `guardrail` |
|
||
| 2 | 상태머신 — `effective_openness` 결정론 계산 + 단계 전이 | `state_machine` |
|
||
| 3 | 페르소나 컨텍스트 — L0~L6 messages 조립 | `persona` |
|
||
| 4 | 내담자 AI 생성(CCD 비노출) | `engine_client` |
|
||
| 5 | 출력 가드레일 — 자살수단 차단, ideation 상한 | `guardrail` |
|
||
| 6 | 평가 훅(주입형) | `eval_hook` |
|
||
| 7 | 상태 갱신(체크포인트는 호출부가 DB/store에 반영) | 호출부 |
|
||
| 8 | 로깅 훅(주입형, turns insert/임베딩) | `log_hook` |
|
||
|
||
함수 경계:
|
||
|
||
- **`prepare_turn(...)`** (1~3단계, 순수함수, IO/LLM 없음): `TurnContext`를 만든다.
|
||
- `guardrail.mask_pii(learner_text)`로 마스킹 → `ctx.learner_text_masked`.
|
||
- `guardrail.classify_crisis(...)`로 위기 분류 → `ctx.crisis`.
|
||
- 라포 신호는 `eval_rapport_signal`(평가 AI 신호)이 있으면 그것을, 없으면
|
||
`state_machine.estimate_rapport_signal(...)` 휴리스틱을 쓴다.
|
||
- `state_machine.evolve(...)`로 새 상태 산출 → `ctx.state_after`.
|
||
- `persona.build_turn_messages(...)`로 L0~L6 `EngineMessage[]` 조립.
|
||
- 회상/핀/직전 턴 등 *주입 텍스트도 전부 다시 마스킹*한다(`_mask_optional_text` 등).
|
||
- **`run_turn_generate(ctx, engine, *, eval_hook=None, log_hook=None)`** (4~8, 동기/폴백/테스트 경로):
|
||
`engine.generate()`로 응답 한 번에 수신 → `guardrail.sanitize_client_reply()` → 수단정보 누출 시
|
||
안전 대체 응답("…(말을 잇지 못하고 잠시 침묵한다)")으로 치환 → eval/log 훅 순차 적용 → `TurnResult` 반환.
|
||
훅 예외는 모두 비치명적으로 흡수(상담 루프를 막지 않음).
|
||
- **`run_turn_stream(ctx, engine, *, log_hook=None)`** (4~8, 기본 UX 경로):
|
||
게이트웨이 SSE 원시 라인을 받아 `token | done | safety | error`로 재방출.
|
||
출력 가드레일은 *누적 텍스트* 기준으로 수단정보를 스캔하고, 발견 시 `safety` 이벤트 + 안전 대체로 종결한다.
|
||
|
||
`TurnContext`/`TurnResult`/`StreamEvent` dataclass가 파이프라인을 관통한다. `EvalHook`/`LogHook`은
|
||
`Callable[[TurnContext, str], Awaitable[...]]` 타입으로 주입된다.
|
||
|
||
### 2.3 페르소나 메시지 빌더 — `app/services/persona.py` (L0~L6)
|
||
|
||
`build_turn_messages(card, state, learner_text_masked, ...)`가 한 턴의 `EngineMessage[]`를 조립한다.
|
||
레이어 구조(논리 레이어, docstring):
|
||
|
||
| 레이어 | 내용 | cache_control | 비고 |
|
||
|---|---|---|---|
|
||
| L0 | 역할 + 안전 가드레일 + 도식노출금지(`L0_SAFETY`) | ✅ | 전 페르소나 공통, 캐시 대상 |
|
||
| L1 | 페르소나 카드(정적, CCD 포함) | ✅ | `build_persona_system_text()`로 L0와 합쳐 1개 system |
|
||
| L2 | RAG 임상청크 / 회상 + KB 행동단서 | ✅ | 회기 내 1회 로드(캐시 친화) |
|
||
| L3 | 상태머신 주입(stage, openness, resistance, ideation) | ❌ | 수치는 내부용, 발화 표면화 금지 |
|
||
| L4 | 메모리 버퍼(pinned fact hard-pin) | ❌ | "자기 기억"으로만 표현 |
|
||
| L6 | 직전 K턴 맥락(히스토리) + 이번 발화(L5) | ❌ | 상담자=user, 내담자(자기)=assistant 매핑 |
|
||
|
||
메시지 순서: `system(L0+L1, cache)` → `system(L2, cache)` → `system(L3)` → `system(L4)` →
|
||
assistant/user 히스토리(L6) → `user(이번 마스킹 발화, L5)`. 게이트웨이는 마지막 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}`와 저장소 `data/personas/P4~P7.json` — `persona_repository.built_in_personas()`.
|
||
|
||
| 코드 | 인물 | 난이도 | 이론타깃 | 특징 |
|
||
|---|---|---|---|---|
|
||
| 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`, 코드펜스 관용).
|
||
- **엔진/파싱 실패는 비치명적** — `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).
|
||
|
||
- 회기 시작: `(persona_id, learner_id)` 안정 `case_profile`을 확보한 뒤
|
||
`build_recall_context(...) -> RecallContext`를 조립한다. 현재 동기 seed는 직전
|
||
`session_summary` 기반이고, episodic 단편/KB 단서는 백그라운드 warm cache로 붙는다.
|
||
**회상 요약엔 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)`로 승인 카드 조회 → `case_profile` upsert →
|
||
직전 `session_summary` seed recall → `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`로 표기. 학습자가 저장한 사례개념화 워크시트가 있으면
|
||
자동 초안보다 `saved_by_learner` 저장본을 우선 반환한다.
|
||
- `PUT /sessions/{id}/review/worksheet` — 학습자가 수정한 사례개념화 워크시트를 저장한다.
|
||
저장본은 `app.case_worksheet`에 session 단위로 upsert되며, owner learner 또는 teacher/admin RLS 범위에서만 읽힌다.
|
||
|
||
발화 가시성: 학습자에게는 `visible_to`에 `counselor`가 포함된 턴만 보여준다
|
||
(`_learner_visible_turns` → `_LEARNER_VISIBLE_AI_ROLE="counselor"`). 평가 전용 데이터는 노출되지 않는다.
|
||
|
||
### 2.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 행을 잇는 경계다.
|
||
|
||
- `load_file_personas()` / `built_in_personas()` — in-code P1~P3과 저장소 `data/personas/P4~P7.json`을 deterministic catalog로 합친다.
|
||
- `materialize_seed_personas()` — built-in P1~P7을 `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 → 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`의 `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`. 주요 응답 타입은 FastAPI OpenAPI에서 생성한
|
||
`src/lib/api.gen.ts`의 `ApiSchema<...>` alias를 사용하고, generated optional 배열은 화면 렌더링 계층에서
|
||
빈 배열 fallback으로 흡수한다. 세션 시작/상세/턴 응답의 stage는 backend `StageLabel`
|
||
enum(`라포|탐색|개입|정리`)에서 생성한 union을 `SessionStage`로 사용한다.
|
||
- 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`.
|
||
- **사례개념화 제출물** `app.case_worksheet` — 회기 리뷰의 축어록 기반 자동 초안을 학습자가 편집해 저장한 JSONB.
|
||
`session_id` 단위 upsert이며, `GET /review`에서 자동 초안보다 우선된다.
|
||
- **메모리 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).
|
||
|
||
### 5.2 audit 스키마 + app 평가/종단 (`infra/db/init/04_audit_eval_rls.sql`)
|
||
|
||
- 평가: `app.feedback_scores`(발화별 점수·rationale, `visible_to='{evaluator}'`, loop fast/deep),
|
||
`app.turn_technique`/`app.turn_client_state`(fast-loop 라벨 정규화),
|
||
`app.supervisor_comment`(intent_deviation). `app.alternative_utterance`는 deep-loop 대안발화 정규화 대상이다.
|
||
`session_persistence.append_turn`은 `app.turns INSERT ... RETURNING id`를 확인한 뒤 같은 트랜잭션에서
|
||
evaluator 컨텍스트로 fast-loop 평가 rows를 적재한다. 리뷰 경로만 evaluator 컨텍스트로 hydrate한다.
|
||
- 종단: `app.learner_profile`(dim_ewma/dim_slope/persistent_gaps — 회기 거듭하며 나아지는지).
|
||
- 감사(append-only): `audit.persona_drift_log`(임베딩 일관성/CCD 누출), `audit.audit_log`(인간 열람 추적),
|
||
`audit.llm_call_log`(비용·inference_geo·latency metadata-only; prompt/completion 본문 미저장).
|
||
|
||
### 5.3 ds 스키마 — 재귀학습 파이프라인 (`infra/db/init/04_audit_eval_rls.sql` §4)
|
||
|
||
AI자동(1R) → 인간검수(2R) → IAA 게이트(κ≥0.6, ICC≥0.75) → 골든셋 → JSONL.
|
||
`ds.dataset` → `ds.dataset_item` → `ds.annotation_round`(ai_auto/human_review) → `ds.annotation`
|
||
→ `ds.export_manifest`(iaa_kappa/iaa_icc/jsonl_path).
|
||
|
||
### 5.4 정보비대칭 2-레이어 + RLS
|
||
|
||
DB 레벨 이중강제(`04_audit_eval_rls.sql` §5, `app/db.py` `acquire()`):
|
||
|
||
- **레이어1 (AI 정보비대칭)**: `app.ai_context='1'` + `app.current_ai_view`(client/counselor/evaluator)
|
||
+ `app.current_sens_max` → `turns`/`kb.chunk`/`pinned_fact`의 `visible_to[]`/`sensitivity` WHERE 강제.
|
||
AIView별 sensitivity 상한 기본값: client=1, counselor=0, evaluator=2(`db._default_sensitivity_max`).
|
||
- **레이어2 (인간 RBAC×cohort)**: `app.current_role` + `app.current_uid` + `app.current_cohort`로
|
||
RLS 정책(학습자=본인 / 교수자=담당 코호트 / 관리자=전부). `feedback_scores`는 학습자 직접열람 차단.
|
||
|
||
세션 변수는 트랜잭션 내 `SELECT set_config(..., true)`(SET LOCAL 의미)로 주입해 풀 재사용 누수를 막는다
|
||
(`app/db.py:94-137`). 애플리케이션 측 RBAC는 `app/deps.py`(`Role`, `AIView`, `Principal`,
|
||
`get_current_principal`, `require_role`, `db_for_human`, `db_for_ai_view`)가 담당한다.
|
||
|
||
---
|
||
|
||
## 6. 인증/OAuth 경계
|
||
|
||
- 브라우저는 BFF API에만 로그인한다. Google OAuth는 Authorization Code + PKCE를 사용하고, 완료 후
|
||
서버가 opaque HttpOnly 쿠키(`__Host-vignette_sid`)를 발급한다. id/access token은 브라우저에 저장하지 않는다.
|
||
- OAuth `state`는 서버 메모리 `_oauth_states`에 호환용으로 보관하지만, 동시에 HMAC 서명 토큰 자체로
|
||
`next`, 발급시각, nonce를 검증하고 PKCE verifier를 재계산한다. 시작 응답은 같은 state를 HttpOnly
|
||
`__Host-vignette_oauth_state` 쿠키로도 내려주며, callback은 URL state와 쿠키 state가 일치할 때만 복구한다.
|
||
그래서 public API가 로그인 시작과 콜백 사이에 재시작돼도 callback은 `invalid_state`로 실패하지 않고
|
||
Google token exchange 단계까지 가며, 쿠키 없는 외부 callback 주입은 차단된다.
|
||
- OAuth callback 실패는 authorization code, token, raw email을 남기지 않고 reason/status/도메인 수준 정보만
|
||
서버 로그에 남긴다. 프론트는 `token_exchange_failed`, `invalid_state`, provider error(`access_denied`/`provider_error`),
|
||
identity claim 실패를 구분하고 실패 reason code를 화면에 함께 표시한다.
|
||
- 역할은 `AUTH_TEACHER_EMAILS`/`AUTH_ADMIN_EMAILS` email allowlist로 1차 판정한다. 코호트는
|
||
`AUTH_EMAIL_COHORT_MAP`과 `AUTH_DOMAIN_COHORT_MAP` 설정, SAML fixture의 `cohort` claim을 합쳐
|
||
`cohort_ids`로 세션에 저장한다. 관리 사용자 `app_user.external_id`는 provider subject 기반
|
||
(`google:`/`saml:`/`dev:`)으로 저장해 email 변경 리스크를 줄인다.
|
||
- 로컬/Tailnet dev는 dev-login을 사용한다. 공개 `OAUTH_REDIRECT_URI`가 로컬/Tailnet 세션이 아니라 public API
|
||
세션으로 돌아가는 혼선을 막기 위해 dev-origin의 Google 직접 시작은 `local_oauth_unavailable`로 차단한다.
|
||
|
||
---
|
||
|
||
## 7. degraded / 폴백 정책
|
||
|
||
| 미가용 대상 | 증상 | 동작 |
|
||
|---|---|---|
|
||
| DB(Postgres) | `/health` `db:false`, status `degraded` | `app/main.py` lifespan이 dev에서 예외 흡수, `store.py` in-proc로 1턴 동작 |
|
||
| 엔진 게이트웨이 | `/health` `engine:false` | 턴 생성 실패(503/SSE error). UI/로그인/페르소나/세션생성(in-memory)은 동작 |
|
||
| OpenAI 키 미설정 | `/voice/health` `degraded` | 음성 비활성, WS는 degraded 통지 후 정상 close |
|
||
| Presidio 미설치 | — | 정규식 PII 마스킹 폴백 |
|
||
| 승인 페르소나 조회 실패 | persona `degraded=true` | `ALLOW_SEED_PERSONA_FALLBACK=true`면 시드 카드로 폴백 |
|
||
|
||
폴백 허용 여부는 `app/runtime_policy.py`가 게이트한다(무분별한 in-proc 사용 방지).
|
||
|
||
---
|
||
|
||
## 8. 로컬 실행 (Windows 11 + PowerShell 기준)
|
||
|
||
> 한글/공백 경로 주의. `apps/api/.env`는 pydantic-settings가 자동 로드한다.
|
||
|
||
### 7.1 API (DB 없이 degraded 기동 가능)
|
||
|
||
```powershell
|
||
cd D:\workspace\vignette\apps\api
|
||
# 시드 페르소나를 in-proc로 쓰려면(개발용):
|
||
$env:AUTO_SEED_PERSONAS = "true"; $env:ALLOW_SEED_PERSONA_FALLBACK = "true"
|
||
python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload
|
||
# 확인: GET http://127.0.0.1:8000/health → {"status":"degraded","db":false,...} (DB 없을 때)
|
||
```
|
||
|
||
dev 인증(`apps/api/.env`에 `AUTH_DEV_LOGIN_ENABLED=true`, `ENVIRONMENT=dev`):
|
||
```
|
||
POST /auth/dev-login {email, role: learner|teacher|admin, display_name}
|
||
→ __Host-vignette_sid 쿠키 발급(웹은 Login.tsx에 dev-login 경로 존재)
|
||
```
|
||
|
||
### 7.2 엔진 게이트웨이 (AI 턴 생성, ENGINE_MODE=claude_cli)
|
||
|
||
```powershell
|
||
cd D:\workspace\vignette\apps\api
|
||
python -m uvicorn engine_gateway.gateway:app --host 0.0.0.0 --port 9099
|
||
# API의 ENGINE_URL을 게이트웨이 포트로 맞춘다(예: http://127.0.0.1:9099)
|
||
```
|
||
게이트웨이가 없으면 `/health`의 `engine:false`이고 턴 생성은 실패하지만, UI/네비/로그인/페르소나/
|
||
세션생성(in-memory)은 동작한다.
|
||
|
||
### 7.3 웹
|
||
|
||
```powershell
|
||
cd D:\workspace\vignette\apps\web
|
||
npm run dev # http://localhost:5173, /api → 127.0.0.1:8000 프록시
|
||
```
|
||
|
||
### 7.4 테스트
|
||
|
||
```powershell
|
||
# 백엔드
|
||
cd D:\workspace\vignette\apps\api
|
||
python -m pytest app/ -q # 앱 단위 테스트
|
||
python -m pytest engine_gateway/ -q # 게이트웨이 테스트
|
||
# 웹
|
||
cd D:\workspace\vignette\apps\web
|
||
npm run typecheck
|
||
npm run build # vite
|
||
npm run e2e # Playwright (web+api+DB 스택 필요)
|
||
```
|
||
|
||
### 7.5 Docker compose (DB + API)
|
||
|
||
`infra/docker-compose.yml`(db: pgvector pg16 + api). Docker Desktop 필요. DB 컨테이너 기동 시
|
||
`infra/db/init/*.sql`이 순서대로 적용된다.
|
||
|
||
---
|
||
|
||
## 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해 학습자-안전 형태로 가공한다.
|
||
- 평가/로깅 훅 실패는 비치명적으로 흡수되어 상담 루프를 멈추지 않는다.
|
||
```
|