diff --git a/.env.example b/.env.example index 18a76e4..6b48ad1 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,7 @@ AUTO_SEED_PERSONAS=false ALLOW_SEED_PERSONA_FALLBACK=false EVALUATOR_GOLDEN_FEWSHOT_ENABLED=false FRONTEND_BASE_URL=http://localhost:5173 -CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173"] +CORS_ORIGINS=["https://vignette.chanpaca.net","https://vignette-b1q.pages.dev","http://localhost:5170","http://localhost:5171","http://localhost:5172","http://localhost:5173","http://localhost:5174","http://localhost:5175","http://localhost:5176","http://localhost:5177","http://localhost:5178","http://localhost:5179","http://localhost:5180","http://127.0.0.1:5170","http://127.0.0.1:5171","http://127.0.0.1:5172","http://127.0.0.1:5173","http://127.0.0.1:5174","http://127.0.0.1:5175","http://127.0.0.1:5176","http://127.0.0.1:5177","http://127.0.0.1:5178","http://127.0.0.1:5179","http://127.0.0.1:5180"] # Live2D runtime. Models must be configured per persona via live2dModelUrl. VITE_LIVE2D_CUBISM_CORE=/live2d/live2dcubismcore.min.js diff --git a/.gitignore b/.gitignore index c39965c..f7aef58 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ apps/api/gateway.restart.* apps/api/e2e_*.py apps/web/_pptr_check.cjs apps/web/_pptr*.cjs + +# 로컬 dev 서버 로그(scripts/dev-up.ps1) +.devlogs/ diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..c0471fe --- /dev/null +++ b/AGENT.md @@ -0,0 +1,55 @@ +# AGENT.md — 에이전트 운영 수칙 (Vignette) + +이 저장소에서 자동화 에이전트/서브에이전트가 일할 때의 운영 수칙. 상세 프로젝트 +지침은 [`CLAUDE.md`](./CLAUDE.md) 참조. + +**작업 전 관련 가이드를 먼저 읽어라**: [`README.md`](./README.md) · +[로컬 실행](./docs/guides/local-development.md) · [아키텍처](./docs/guides/architecture.md) · +[테스트](./docs/guides/testing.md) · [원천문서·갭](./docs/guides/source-docs-and-gaps.md) · +SSOT [`docs/dev_dashboard.html`](./docs/dev_dashboard.html). 동작/구조 변경 시 해당 문서와 SSOT를 갱신. + +--- + +## ⚠️ 규칙 0 — 무조건 OS를 먼저 파악한다 (필수, 최우선) + +**모든 작업의 첫 단계는 OS·셸·경로·도구 환경 확정이다.** 명령을 한 줄이라도 +실행하기 전에 다음을 확인하라. 생략하면 환경 차이로 반드시 시간을 버린다. + +- [ ] **OS / 셸 확인** — 주 환경은 **Windows 11 + PowerShell**. POSIX 가정 금지. +- [ ] **경로 규칙** — Windows 절대경로, 한글·공백 경로 빈번. `-LiteralPath` 사용, + 외부 도구엔 **ASCII 이름으로 로컬 복사 후** 전달. +- [ ] **PowerShell 5.1 함정** — 인라인 if/else·삼항 없음, 네이티브 stderr `2>&1` 금지, + 파일 출력은 `-Encoding utf8`. +- [ ] **외부 CLI 블로킹 검증** — GUI 런처는 즉시 detach. 실제 작업 바이너리 + (예: `soffice.bin`)를 직접 호출하고 `-Wait` 동작을 확인. 좀비/락 먼저 정리. +- [ ] **도구 가용성 탐지 우선** — 변환·처리 전 LibreOffice/pandoc/python lib/ + Playwright 브라우저 설치 여부와 경로를 먼저 잡는다. + +> **"OS·셸·경로·도구를 확정한 뒤에 실행한다. 추정으로 시작하지 않는다."** + +--- + +## 규칙 1 — 증거 정직성 + +- 가짜 증거로 DONE 표기 금지. 실증 불가/외부 의존/소유자 결정 항목은 + `docs/ops/backlog-*.md`에 분류·추적. +- 변경 후 검증(typecheck / E2E 게이트)을 실제로 돌리고 결과를 그대로 보고. + +## 규칙 2 — 범위·권한 + +- 소유자(윤찬) 단독 결정 사안은 임의 결정 금지. +- 전 페이지 공용 셸 변경 등 광범위 영향 작업은 회귀 검증을 동반. + +## 규칙 3 — 출력 + +- 한글로 소통. 커밋 메시지에 Claude/Co-Authored-By 문구 금지. + +## 규칙 4 — 이미지 생성 / 아바타 리깅 + +- "이미지 생성·만들어·그려줘" 요청 → `~/.claude/skills/codex-image` 스킬 사용. + gpt-image-2 래퍼 `~/.codex/imagegen-headless/codex_imagegen.sh`(ChatGPT 구독 인증, **API 키 금지**). + codex 0.140+는 결과가 rollout JSONL에 base64로 인라인 → 래퍼의 추출 스크립트만 결정적(직접 `codex exec` 금지). +- 누끼: `object-separation` 스킬(BiRefNet, `~/.venvs/object-separation`). +- 아바타 래스터 리깅(파츠 분리) 파이프라인·재현 절차는 **CLAUDE.md §4** 와 + [`docs/ops/handoff-avatar-seoyeon-2026-06-27.md`](./docs/ops/handoff-avatar-seoyeon-2026-06-27.md) 참조. +- 비전(analyze_image) 도구가 다중 패널/캐릭터를 자주 혼동 → 시각 판단은 **사용자 확인 + 스크린샷** 우선. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5a562f8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,101 @@ +# CLAUDE.md — Vignette 프로젝트 작업 지침 + +> Vignette = AI 심리상담 시뮬레이션 훈련 플랫폼 (한신대 산학협력). +> 모노레포: `apps/api`(FastAPI/Python), `apps/web`(React 19/Vite/Playwright), `docs`, `infra`, `scripts`. + +## 📚 문서 맵 — 작업 전 해당 가이드를 먼저 읽어라 + +| 목적 | 문서 | +|---|---| +| 저장소 개요·빠른 시작 | [`README.md`](./README.md) | +| **로컬 서버 띄우기·테스트** | [`docs/guides/local-development.md`](./docs/guides/local-development.md) | +| 시스템 아키텍처·데이터 흐름 | [`docs/guides/architecture.md`](./docs/guides/architecture.md) | +| 테스트·검증 실행 | [`docs/guides/testing.md`](./docs/guides/testing.md) | +| 원천문서·갭 로드맵 | [`docs/guides/source-docs-and-gaps.md`](./docs/guides/source-docs-and-gaps.md) | +| **SSOT 상태판** | [`docs/dev_dashboard.html`](./docs/dev_dashboard.html) | +| 백로그 | [`docs/ops/backlog-2026-06-26.md`](./docs/ops/backlog-2026-06-26.md) | +| **서연 아바타 핸드오프** | [`docs/ops/handoff-avatar-seoyeon-2026-06-27.md`](./docs/ops/handoff-avatar-seoyeon-2026-06-27.md) | + +작업 결과로 동작/구조가 바뀌면 해당 가이드와 SSOT 대시보드를 함께 갱신한다. + +--- + +## ⚠️ 0. 무조건 OS를 먼저 파악하고 시작한다 (최우선·필수) + +**어떤 작업이든 명령을 실행하기 전에 OS와 셸을 먼저 확정하라.** 이걸 건너뛰면 +경로/인코딩/도구 차이로 시간을 크게 낭비한다(실제로 그랬다). + +작업 시작 시 반드시 확인할 것: + +1. **OS / 셸**: 이 저장소의 주 개발 환경은 **Windows 11 + PowerShell**이다. + POSIX를 가정하지 마라. Bash 도구도 쓸 수 있으나 셸마다 문법이 다르다. +2. **경로 규칙**: Windows 절대경로(`D:\...`, `C:\...`). 한글·공백 포함 경로가 흔하다 + (예: OneDrive `문서\카카오톡 받은 파일`). `-LiteralPath`로 다루고, 외부 도구에 + 넘기기 전에 **ASCII 이름으로 로컬 복사**해 인코딩/공백 문제를 차단하라. +3. **PowerShell 판(5.1) 주의**: 인라인 `if(){}else{}`를 식으로 못 쓴다(삼항 없음). + 네이티브 exe stderr를 `2>&1`로 합치지 마라(ErrorRecord로 감싸짐). + 기본 출력 인코딩은 UTF-16 — 다른 도구가 읽을 파일은 `-Encoding utf8`. +4. **외부 CLI는 실제로 블로킹되는지 확인**: GUI 런처(`soffice.exe` 등)는 즉시 + detach되어 `Start-Process -Wait`가 변환을 안 기다린다. 실제 작업 프로세스 + (`soffice.bin`)를 직접 호출하라. 좀비 프로세스가 락을 잡으면 정리부터 한다. +5. **도구 가용성 먼저 탐지**: 변환/처리 전에 LibreOffice·pandoc·python 라이브러리· + Playwright 브라우저 등 무엇이 설치돼 있는지 먼저 확인하고 경로를 잡아라. + +> 한 줄 요약: **"먼저 OS·셸·경로·도구를 확정한 뒤 실행한다."** 추정 금지. + +--- + +## 1. 운영 원칙 + +- **가짜 증거로 DONE 표기 금지.** 실증/외부 의존/소유자 결정이 필요한 항목은 + `docs/ops/backlog-*.md`에 분류해 추적한다(B1 코스메틱 · B2 환경제약 · B3 소유자결정 · B4 외부거버넌스). +- **`docs/dev_dashboard.html`이 SSOT(단일 진실 공급원)다.** 상태·검증 증거·결정 필요·로드맵의 권위 기준이며, 새 발견·작업 결과·상태 변경은 별도 문서로만 남기지 말고 대시보드에 반영/동기화한다. 백로그(`docs/ops/backlog-*.md`)는 대시보드와 일치시킨다(어긋나면 대시보드 기준). +- 소유자(윤찬) 단독 결정 사안을 임의로 정하지 않는다(월권 금지). + +## 2. 검증 기준 (프론트 변경 시) + +- `cd apps/web && npm run typecheck` +- 레이아웃 변경은 `e2e/layout-visual-gate.spec.ts`(7/7) + 레이아웃 포커스 E2E + + `e2e/session-layout.spec.ts`(8/8) 무회귀. E2E는 web+api(+DB) 스택이 떠 있어야 한다. + +## 3. 커뮤니케이션 + +- 모든 대화·주석·커밋 메시지는 한글. +- git 커밋 메시지에 Co-Authored-By / Claude 관련 문구 추가 금지. + +--- + +## 4. 이미지 생성(gpt-image-2 = imagegen2) · Live2D식 아바타 + +> "이미지 생성해/만들어/그려줘" 요청 → `~/.claude/skills/codex-image` 스킬이 아래 래퍼를 자동 사용. + +### 4.1 gpt-image-2 호출 (반드시 래퍼) +```bash +bash ~/.codex/imagegen-headless/codex_imagegen.sh \ + --out <경로.png> [--size WxH] [--quality low|medium|high|auto] \ + [-i <참조이미지> ...] [--all] "<프롬프트>" +``` +- 인증: ChatGPT 구독 OAuth(`~/.codex/auth.json`의 `auth_mode=="chatgpt"`). **API 키 사용 금지**(과금). +- codex 0.140+는 생성 이미지를 세션 rollout JSONL에 **base64로 인라인 반환** → 래퍼의 `extract_imagegen.py` 추출만 결정적. stdout의 "저장 경로"는 **환각**(직접 codex exec 금지). +- 프롬프트는 **stdin 파이프**로(인자 전달 시 멈춤). 변주 생성 시 base를 `-i` 참조로 넘겨 아이덴티티·프레이밍 고정. +- 투명배경 미지원 → 단색 평면 배경으로 생성 후 누끼. 한글 텍스트 렌더 가능(stdin 파이프라 인코딩 문제 없음). + +### 4.2 누끼(컷아웃) +- `object-separation` 스킬(BiRefNet): `~/.venvs/object-separation/Scripts/python.exe ~/.agents/skills/object-separation/scripts/separate_object.py --model birefnet-general` +- 알파 정제(잔류 헤이즈 제거): 임계치 `<35→0, >205→255` + 페더(`docs/avatar-art/seoyeon/publish.py` 참조). + +### 4.3 Live2D식 아바타(파츠 분리 리깅) +아바타는 기본 **SVG 파라미터 리그** 또는 **래스터 파츠 분리 리깅**(`apps/web/src/components/avatar/RasterBust.tsx`)으로 렌더. 후자는 `persona.rasterArtSet` 지정 시 활성. +- 레이어(각각 독립 opacity/교체 → 표정 중에도 깜빡임·입술싱크가 따로 움직임): + `base(neutral 전신)` + `upperface-<표정>(눈썹+눈)` + `eyelid-closed(깜빡임, 표정 무관)` + `mouth-<표정>` + `mouth-open(립싱크)`. +- 파이프라인(재현 스크립트는 `docs/avatar-art/seoyeon/`): + 1. `codex_imagegen.sh`로 base + 표정 변주(sad/tired/anxious/warm/startled/eyes-closed/speaking) 생성. 변주는 base를 `-i` 참조로, 동일 평면 배경. + 2. BiRefNet 누끼 → `publish.py`(표준 캔버스 900×1125 정규화 + 알파 정제)로 `apps/web/public/avatar//` 게시. + 3. `make-parts.py`로 특징 영역(upperface/eyelid/mouth) 크롭+페더 파츠를 `parts/` 생성(영역 상수 튜너 블럭). +- 연결: `persona.ts`의 `AvatarPersona.rasterArtSet` / `Session.tsx`의 `PERSONA_AVATAR_LOOKS[].rasterArtSet` / `RasterBust.tsx`(28표정→클러스터 매핑 포함). +- dev 미리보기(인증 없음): `/dev/avatar-preview`(`AvatarPreview.tsx`). 스크린샷: `node apps/web/scripts/avatar-shot.mjs`(BASE_URL 환경변수로 포트 지정). +- 실제 Live2D Cubism(`.moc3`)은 편집기 저작이 필요해 자동화 불가 → 위 레이어 합성이 실용적 대안. + +### 4.4 진행 중인 아바타 작업 핸드오프 +서연(P1) 아바타 작업은 **별도 세션에서 진행**. 현재 상태·남은 작업(Image #2=짧은 보브 기준 재생성 등)은 +[`docs/ops/handoff-avatar-seoyeon-2026-06-27.md`](./docs/ops/handoff-avatar-seoyeon-2026-06-27.md) 참조. diff --git a/README.md b/README.md index 295e7fb..ef7b3a0 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,143 @@ -# Vignette +# Vignette 저장소 README -> AI 심리상담 시뮬레이션 훈련 플랫폼 — 한신대 SW중심대학 산학협력 (트웬티온스) +> **Vignette** — AI 심리상담 시뮬레이션 훈련 플랫폼. +> 한신대학교 산학협력(구훈정 교수) 프로젝트. 상담 수련생(학습자)이 **AI 내담자 페르소나**와 +> 회기를 진행하고, 백그라운드 평가 엔진이 **회기 리뷰 피드백**을 제공한다. +> "임상 비네트(사례 삽화)"로 안전하게 연습한다는 의미에서 *Vignette*. -상담 수련생이 **가상 내담자 AI**와 음성으로 상담을 연습하고, **백그라운드 평가 AI**가 실시간·회기말 피드백을 준다. "임상 비네트(사례 삽화)"로 안전하게 연습한다는 의미에서 *Vignette*. +핵심 구성: 페르소나 엔진(`persona_repository`, SEED P1~P3) · 이론모드(humanistic 등) · +오케스트레이터(`services/orchestrator.py`, `prepare_turn`/`run_turn_generate`, `eval_hook`/`log_hook` 주입형) · +저항엔진(state_machine openness) · 마스킹 게이트(PII, Presidio + 정규식) · +음성 캐스케이드(STT/TTS, `voice.py`) · 회기 리뷰(`evaluator.py` deep-loop + `make_eval_hook` fast-loop) · +평가 KPI(SUS·자기효능감·κ/ICC·환각률) · 재귀학습(`ds.*` 스키마) · 데이터/SSO 거버넌스(`saml.py`, auth allowlist). -## 구조 (모노레포) +AI 턴 생성 엔진은 별도 서비스인 **engine_gateway**(포트 9099)가 담당하며 +`ENGINE_MODE`로 백엔드를 고른다(`claude_cli` / `claude_api` / `openai` / `solar`). +저장소(SoR)는 **PostgreSQL 16 + pgvector** 단일 출처, 미가용 시 `store.py` 인메모리 degraded 폴백. -``` -vignette/ -├ apps/ -│ ├ web/ React 19 프론트엔드 (3역할: 관리자/교수자/학습자, 추후 RN 네이티브) -│ └ api/ FastAPI 백엔드 (엔진 어댑터·상태머신·가드레일·RAG) -├ infra/ Docker Compose (web·api·postgres·voice gateway) -└ docs/ 설계 문서 (SoT) -``` +--- -## 핵심 설계 (docs/) +## 1. 모노레포 구조 -| 문서 | 내용 | +| 경로 | 설명 | |---|---| -| `docs/MASTERPLAN.md` | 시스템 마스터플랜 (아키텍처·로드맵·스택) | -| `docs/redteam/REDTEAM_FINDINGS.md` | 적대검증 40결함 | -| `docs/redteam/MASTERPLAN_REVISIONS.md` | 재설계 패치 (claude -p 1급 엔진 복원 등) | -| `docs/MEMORY_KNOWLEDGE_PERSONA_DESIGN.md` | 메모리·지식·페르소나 (회기 간 연속성) | -| `docs/DESIGN_CONCEPT.md` | 디자인 컨셉 (토큰·아바타·화면) | -| `docs/mockups/` | 화면 레이아웃 시안 (HTML) | +| `apps/api/` | FastAPI/Python 백엔드. 오케스트레이터·페르소나·저항엔진·마스킹·음성·평가·인증. 엔진 게이트웨이(`apps/api/engine_gateway/`) 포함 | +| `apps/web/` | React 19 + Vite 프론트엔드(3역할: 관리자/교수자/학습자). Playwright E2E | +| `docs/` | 설계·운영 문서. `docs/dev_dashboard.html`이 SSOT(단일 진실 공급원) | +| `infra/` | Docker Compose 스택(`db`=pgvector pg16, `api`, `web`, `rag`, `proxy`=Caddy) 및 `.env.example` | +| `scripts/` | 운영 스크립트(PowerShell/Python): 공개 런타임 기동·감시, 엔진 게이트웨이 프로브, Postgres RLS 감사 등 | -## 확정 스택 +--- -- **엔진**: 로컬 Opus 4.8 `claude -p` 상주 멀티턴 풀(`--input-format stream-json`), Anthropic Messages API 폴백 -- **프론트**: React 19 + SSE (pnpm/Turborepo 모노레포, 추후 React Native) -- **백엔드**: FastAPI + SSE 스트리밍 -- **DB**: NAS PostgreSQL 16 + pgvector -- **RAG**: BGE-M3 + 하이브리드 + Contextual Retrieval + BGE-reranker-v2-m3 -- **음성**: OpenAI 캐스케이드(STT→LLM→TTS), 멀티보이스 + 페르소나 -- **인증**: OAuth 2.1 (BFF, 3역할 RBAC, visible_to 정보비대칭) -- **배포**: Docker Compose, chanpaca.net 외부노출(교수 테스트) +## 2. 빠른 시작 (로컬 dev) -## 3-AI +주 개발 환경은 **Windows 11 + PowerShell**. 아래는 PowerShell 기준 최소 명령이다. +(엔진 게이트웨이가 없어도 UI·로그인·페르소나·세션 생성(in-memory)·네비게이션은 동작하며, +실제 AI 턴 생성만 실패한다. DB가 없어도 인메모리 degraded로 기동된다.) -① 심리상담사 AI(선택) ② 가상 내담자 AI ③ 백그라운드 평가/교수 AI — 정보 비대칭을 DB `visible_to`가 강제. +### 2-1. API 백엔드 -## 상태 +```powershell +cd apps\api +# 로컬 dev 기본값 복사 (pydantic-settings가 apps/api/.env 를 자동 로드) +Copy-Item ..\..\.env.example .env +# (선택) seed 페르소나가 필요하면 기동 전에 환경변수 설정 +$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 +``` -설계 완료, 구현 착수 단계 (Phase 0 기반정렬 → P1 텍스트 상담 MVP → P2 음성+3역할 → P3 파일럿). +- 기동 로그에 `Application startup complete` 가 뜨면 정상. DB 연결 실패 시 `store` 인메모리 + degraded로 기동되고 `GET /health` 는 `{"status":"degraded","db":false,...}` 를 반환한다. +- dev 로그인 활성 조건: `.env` 에 `ENVIRONMENT=dev`, `AUTH_DEV_LOGIN_ENABLED=true` + (루트 `.env.example` 기본값에 이미 포함). + +### 2-2. Web 프론트엔드 + +```powershell +cd apps\web +npm install +npm run dev # http://localhost:5173 +``` + +- Vite dev 서버는 `/api` 요청을 `http://127.0.0.1:8000` 으로 프록시하고 `/api` 프리픽스를 제거한다 + (예: `/api/auth/dev-login` → 백엔드 `/auth/dev-login`). + +### 2-3. dev-login 으로 진입 + +웹 UI의 로그인 화면에서 dev-login 경로로 들어가거나, 직접 호출한다. + +```powershell +# 웹 프록시 경유 (web dev 서버가 떠 있을 때) +curl -X POST http://localhost:5173/api/auth/dev-login ` + -H "Content-Type: application/json" ` + -d '{"email":"learner@hs.ac.kr","role":"learner","display_name":"테스트 학습자"}' +``` + +- 요청 바디: `email`(필수), `role`(`learner` | `teacher` | `admin`, 기본 `learner`), `display_name`(선택). +- 성공 시 `__Host-vignette_sid` 세션 쿠키가 발급된다. + +### 2-4. (선택) AI 턴 생성용 엔진 게이트웨이 + +`ENGINE_MODE=claude_cli` 인 경우 호스트에서 게이트웨이를 9099 포트로 띄운다(claude CLI 사용). + +```powershell +cd apps\api +uvicorn engine_gateway.gateway:app --host 0.0.0.0 --port 9099 +``` + +게이트웨이가 없으면 `GET /health` 의 `engine:false` 이고 턴 생성만 실패한다. + +### 2-5. 테스트 / 검증 + +```powershell +# 백엔드 +cd apps\api; python -m pytest app/ -q # 현재 약 77 pass +python -m pytest engine_gateway/ -q # 약 7 pass +# 프론트엔드 +cd apps\web; npm run typecheck # tsc -b +npm run build # tsc -b && vite build +npm run e2e # Playwright (web + api + DB 스택 필요) +``` + +### 2-6. (선택) Docker Compose 전체 스택 + +```powershell +cd infra +Copy-Item .env.example .env # 실제 시크릿은 .env 에만 +docker compose up -d # db(pgvector pg16) + api + web + rag + proxy(Caddy) +``` + +Docker Desktop이 필요하다. + +--- + +## 3. 주요 문서 + +| 문서 | 용도 | +|---|---| +| **`docs/dev_dashboard.html`** | **SSOT(단일 진실 공급원)** — 상태·검증 증거·결정 필요·로드맵의 권위 기준 | +| `docs/ops/backlog-2026-06-26.md` | 운영 백로그(B1 코스메틱 · B2 환경제약 · B3 소유자결정 · B4 외부거버넌스). 대시보드와 일치 | +| `docs/ops/source-docs-gap-analysis-2026-06-26.md` | 원천문서 갭 분석(대시보드 "원천문서 갭" 항목의 상세 근거) | +| `docs/guides/local-development.md` | 로컬 개발 환경 구축·실행 상세 가이드 | +| `docs/guides/architecture.md` | 시스템 아키텍처(엔진/오케스트레이터/저항/마스킹/음성/평가/데이터) 상세 | +| `docs/guides/testing.md` | 테스트·검증(pytest, typecheck, Playwright E2E 게이트) 가이드 | +| `CLAUDE.md` / `AGENT.md` | 작업·에이전트 운영 지침(OS 선파악, 증거 정직성, SSOT 동기화) | + +참고 설계 문서: `docs/MASTERPLAN.md`(마스터플랜) · `docs/HANDOFF.md`(인수인계) · +`docs/DEPLOYMENT.md`(배포) · `docs/DESIGN_CONCEPT.md`(디자인 컨셉) · +`docs/MEMORY_KNOWLEDGE_PERSONA_DESIGN.md`(메모리·지식·페르소나). + +--- + +## 4. 핵심 운영 원칙 + +1. **OS를 먼저 파악하고 시작한다(최우선).** 주 환경은 Windows 11 + PowerShell. + POSIX를 가정하지 말고 경로·인코딩·도구 가용성을 먼저 확정한다(한글·공백 경로 주의, + 파일 출력은 `-Encoding utf8`). 자세한 내용은 `CLAUDE.md` / `AGENT.md` 규칙 0. +2. **가짜 증거로 DONE 표기 금지.** 실증 불가/외부 의존/소유자 결정 항목은 + `docs/ops/backlog-*.md` 에 분류·추적하고, 변경 후 검증(typecheck / pytest / E2E 게이트)을 + 실제로 돌려 결과를 그대로 보고한다. +3. **`docs/dev_dashboard.html` 이 SSOT.** 새 발견·작업 결과·상태 변경은 별도 문서로만 남기지 말고 + 대시보드에 반영·동기화한다. 백로그가 대시보드와 어긋나면 대시보드를 기준으로 맞춘다. +4. **소유자(윤찬) 단독 결정 사안은 임의로 정하지 않는다(월권 금지).** +5. 모든 소통·주석·커밋 메시지는 한글. 커밋 메시지에 Co-Authored-By / Claude 관련 문구 금지. diff --git a/apps/api/app/config.py b/apps/api/app/config.py index 142702a..51b2ba4 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -27,6 +27,19 @@ def _is_local_url(value: str) -> bool: return host in {"localhost", "127.0.0.1", "::1"} +def _is_allowed_local_dev_cors_origin(value: str) -> bool: + parsed = urlsplit(value) + host = (parsed.hostname or "").lower() + return ( + parsed.scheme == "http" + and host in {"localhost", "127.0.0.1"} + and parsed.port in range(5170, 5181) + and not parsed.path + and not parsed.query + and not parsed.fragment + ) + + class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", @@ -106,6 +119,22 @@ class Settings(BaseSettings): default=False, validation_alias="AUTH_DEV_LOGIN_ENABLED", ) + auth_saml_enabled: bool = Field( + default=False, + validation_alias="AUTH_SAML_ENABLED", + ) + saml_sp_entity_id: str = Field( + default="", + validation_alias="SAML_SP_ENTITY_ID", + ) + saml_sso_url: str = Field( + default="", + validation_alias="SAML_SSO_URL", + ) + saml_x509_cert_fingerprint: str = Field( + default="", + validation_alias="SAML_X509_CERT_FINGERPRINT", + ) default_affiliation: str = Field( default="", validation_alias="DEFAULT_AFFILIATION", @@ -160,11 +189,23 @@ class Settings(BaseSettings): forbidden.append("SESSION_SECRET") if _is_local_url(self.frontend_base_url): forbidden.append("FRONTEND_BASE_URL") - if any(_is_local_url(origin) for origin in self.cors_origins): + if any( + _is_local_url(origin) and not _is_allowed_local_dev_cors_origin(origin) + for origin in self.cors_origins + ): forbidden.append("CORS_ORIGINS") if forbidden: joined = ", ".join(forbidden) raise ValueError(f"{joined} must be production-safe when ENVIRONMENT={self.environment}") + if self.auth_saml_enabled: + missing_saml: list[str] = [] + if not self.saml_sp_entity_id.strip(): + missing_saml.append("SAML_SP_ENTITY_ID") + if not self.saml_sso_url.strip(): + missing_saml.append("SAML_SSO_URL") + if missing_saml: + joined = ", ".join(missing_saml) + raise ValueError(f"{joined} must be configured when AUTH_SAML_ENABLED=true") return self diff --git a/apps/api/app/engine_client.py b/apps/api/app/engine_client.py index e4ae027..f90e35c 100644 --- a/apps/api/app/engine_client.py +++ b/apps/api/app/engine_client.py @@ -39,8 +39,6 @@ class GenerateRequest(BaseModel): ai_role: AIRole messages: list[EngineMessage] - # tier 라우팅 힌트: client=Sonnet/Solar, evaluator=Opus, fast=Haiku (마스터플랜 §5) - tier: Literal["client", "feedback", "fast"] = "client" model: Optional[str] = None # 명시 시 게이트웨이 override max_tokens: int = 1024 temperature: float = 0.7 diff --git a/apps/api/app/persona_repository.py b/apps/api/app/persona_repository.py index 258e066..3ab400e 100644 --- a/apps/api/app/persona_repository.py +++ b/apps/api/app/persona_repository.py @@ -10,7 +10,7 @@ from __future__ import annotations import json import uuid from dataclasses import dataclass -from typing import Any, Iterable +from typing import Any, Iterable, Literal, cast from .config import settings from .db import acquire, get_pool @@ -18,6 +18,7 @@ from .services.persona import PersonaCard, SEED_PERSONAS, get_seed_persona SEED_VERSION = 1 +PersonaStatus = Literal["draft", "review", "approved", "archived"] _CARD_COLUMNS = """ persona_id, code, version, status, display_name, difficulty, theory_target, @@ -25,6 +26,15 @@ _CARD_COLUMNS = """ affect_baseline, ccd, dsm5_dimensional, source_provenance, is_synthetic """ +_REVIEW_COLUMNS = """ + persona_id, code, version, status, display_name, difficulty, theory_target, + source_provenance, is_synthetic, created_at, approved_at +""" + +_PERSONA_STATUSES = {"draft", "review", "approved", "archived"} +_REVIEW_QUEUE_STATUSES = ("draft", "review") +PersonaReviewAction = Literal["approve", "reject"] + @dataclass(frozen=True, slots=True) class CatalogPersona: @@ -35,6 +45,21 @@ class CatalogPersona: degraded: bool = False +@dataclass(frozen=True, slots=True) +class PersonaReviewItem: + persona_id: str + code: str + version: int + status: PersonaStatus + display_name: str + difficulty: str + theory_target: list[str] + source_provenance: str + is_synthetic: bool + created_at: str | None + approved_at: str | None + + def seed_persona_id(code: str) -> str: return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:persona:{code.upper()}")) @@ -54,6 +79,24 @@ def _string_list(value: Iterable[Any] | None) -> list[str]: return [str(item) for item in value] +def _optional_text(value: Any) -> str | None: + if value is None: + return None + isoformat = getattr(value, "isoformat", None) + if callable(isoformat): + return str(isoformat()) + return str(value) + + +def _normalize_statuses(statuses: Iterable[str]) -> list[str]: + normalized: list[str] = [] + for status in statuses: + value = str(status).strip().lower() + if value in _PERSONA_STATUSES and value not in normalized: + normalized.append(value) + return normalized + + def card_from_row(row: Any) -> PersonaCard: return PersonaCard( code=str(row["code"]).upper(), @@ -84,6 +127,22 @@ def catalog_persona_from_row(row: Any) -> CatalogPersona: ) +def persona_review_item_from_row(row: Any) -> PersonaReviewItem: + return PersonaReviewItem( + persona_id=str(row["persona_id"]), + code=str(row["code"]).upper(), + version=int(row["version"]), + status=cast(PersonaStatus, str(row["status"]).lower()), + display_name=str(row["display_name"]), + difficulty=str(row["difficulty"]), + theory_target=_string_list(row["theory_target"]), + source_provenance=str(row["source_provenance"] or ""), + is_synthetic=bool(row["is_synthetic"]), + created_at=_optional_text(row["created_at"]), + approved_at=_optional_text(row["approved_at"]), + ) + + def seed_fallback_persona(code: str) -> CatalogPersona | None: card = get_seed_persona(code) if card is None: @@ -209,6 +268,93 @@ async def get_approved_persona(code: str) -> CatalogPersona | None: return catalog_persona_from_row(row) if row is not None else None +async def list_persona_review_queue( + *, + role: str, + statuses: Iterable[str] = _REVIEW_QUEUE_STATUSES, +) -> list[PersonaReviewItem]: + if role not in {"teacher", "admin"}: + raise ValueError("persona review queue requires teacher or admin role") + status_values = _normalize_statuses(statuses) + if not status_values: + return [] + + get_pool() + async with acquire(role=role) as conn: + rows = await conn.fetch( + f""" + SELECT {_REVIEW_COLUMNS} + FROM app.persona_card + WHERE status = ANY($1::text[]) + ORDER BY + CASE status + WHEN 'review' THEN 0 + WHEN 'draft' THEN 1 + ELSE 2 + END, + code, + version DESC + """, + status_values, + ) + return [persona_review_item_from_row(row) for row in rows] + + +async def update_persona_review_status( + *, + persona_id: str, + action: PersonaReviewAction, + reviewer_id: str, + role: str, +) -> PersonaReviewItem | None: + if role not in {"teacher", "admin"}: + raise ValueError("persona review update requires teacher or admin role") + if action not in {"approve", "reject"}: + raise ValueError("unsupported persona review action") + + next_status = "approved" if action == "approve" else "draft" + approved_by = reviewer_id if action == "approve" else None + approved_at_expr = "now()" if action == "approve" else "NULL" + + get_pool() + async with acquire(role=role, user_id=reviewer_id) as conn: + row = await conn.fetchrow( + f""" + UPDATE app.persona_card + SET + status = $2, + approved_by = $3::uuid, + approved_at = {approved_at_expr} + WHERE persona_id = $1::uuid + AND status IN ('draft', 'review') + RETURNING {_REVIEW_COLUMNS} + """, + persona_id, + next_status, + approved_by, + ) + if row is None: + return None + await conn.execute( + """ + INSERT INTO audit.audit_log ( + actor_uid, action, target_kind, target_id, detail + ) + VALUES ($1::uuid, $2, $3, $4, $5::jsonb) + """, + reviewer_id, + f"persona_{action}", + "persona_card", + persona_id, + { + "next_status": next_status, + "code": str(row["code"]).upper(), + "version": int(row["version"]), + }, + ) + return persona_review_item_from_row(row) + + async def list_catalog_personas() -> list[CatalogPersona]: try: return await list_approved_personas() @@ -229,6 +375,9 @@ async def get_catalog_persona(code: str) -> CatalogPersona | None: __all__ = [ "CatalogPersona", + "PersonaReviewItem", + "PersonaReviewAction", + "PersonaStatus", "SEED_VERSION", "card_from_row", "catalog_persona_from_row", @@ -236,8 +385,11 @@ __all__ = [ "get_catalog_persona", "list_approved_personas", "list_catalog_personas", + "list_persona_review_queue", "materialize_seed_personas", + "persona_review_item_from_row", "seed_fallback_persona", "seed_fallback_personas", "seed_persona_id", + "update_persona_review_status", ] diff --git a/apps/api/app/routes/auth.py b/apps/api/app/routes/auth.py index 9f47bea..5e9dc44 100644 --- a/apps/api/app/routes/auth.py +++ b/apps/api/app/routes/auth.py @@ -25,6 +25,13 @@ from pydantic import BaseModel from ..auth_sessions import InactiveUserError, SessionUser, create_session, revoke_session from ..config import settings from ..deps import CurrentPrincipal, Principal, Role +from ..saml import ( + SamlIdentity, + acs_url_for_entity_id, + build_authn_request, + parse_fixture_response, + redirect_binding_url, +) router = APIRouter(prefix="/auth", tags=["auth"]) @@ -41,7 +48,15 @@ class OAuthState: created_at: float +@dataclass(slots=True) +class SamlState: + request_id: str + next_path: str + created_at: float + + _oauth_states: dict[str, OAuthState] = {} +_saml_states: dict[str, SamlState] = {} class MeResponse(BaseModel): @@ -52,8 +67,17 @@ class MeResponse(BaseModel): cohort_ids: list[str] +class AuthProviderStatus(BaseModel): + provider: Literal["google", "saml"] + configured: bool + enabled: bool + login_path: str + + class AuthConfigResponse(BaseModel): google_oauth_configured: bool + saml_configured: bool + providers: list[AuthProviderStatus] allowed_email_domains: list[str] redirect_uri: str dev_login_enabled: bool @@ -84,6 +108,37 @@ def _normalize_email_set(values: list[str]) -> set[str]: return {email for value in values if (email := _normalize_email(value))} +def _google_configured() -> bool: + return bool(settings.oauth_google_client_id and settings.oauth_google_client_secret) + + +def _saml_configured() -> bool: + return bool( + settings.auth_saml_enabled + and settings.saml_sp_entity_id.strip() + and settings.saml_sso_url.strip() + ) + + +def _auth_provider_statuses() -> list[AuthProviderStatus]: + google_ready = _google_configured() + saml_ready = _saml_configured() + return [ + AuthProviderStatus( + provider="google", + configured=google_ready, + enabled=google_ready, + login_path="/auth/login?provider=google", + ), + AuthProviderStatus( + provider="saml", + configured=saml_ready, + enabled=saml_ready, + login_path="/auth/login?provider=saml", + ), + ] + + def allowed_email_domains() -> set[str]: """Configured login email domains, normalized for claim checks.""" return { @@ -137,6 +192,15 @@ def _role_for_email(email: str) -> Role: return Role.LEARNER +def _role_for_saml_identity(identity: SamlIdentity) -> Role: + hinted = (identity.role_hint or "").strip().lower() + if hinted in {"admin", "administrator"}: + return Role.ADMIN + if hinted in {"teacher", "instructor", "faculty"}: + return Role.TEACHER + return _role_for_email(identity.email) + + def _safe_next_path(next_path: str | None) -> str: if not next_path or not next_path.startswith("/") or next_path.startswith("//"): return "/" @@ -211,6 +275,13 @@ def _prune_oauth_states() -> None: _oauth_states.pop(key, None) +def _prune_saml_states() -> None: + cutoff = time.time() - OAUTH_STATE_TTL_SECONDS + stale = [key for key, value in _saml_states.items() if value.created_at < cutoff] + for key in stale: + _saml_states.pop(key, None) + + def _cookie_secure() -> bool: # The __Host- prefix requires Secure, Path=/, and no Domain. Modern Chrome # accepts Secure cookies on localhost, which keeps dev and prod semantics @@ -291,10 +362,12 @@ def _dev_login_available(request: Request) -> bool: @router.get("/config", response_model=AuthConfigResponse) async def auth_config(request: Request) -> AuthConfigResponse: """Return non-secret login configuration for the browser login screen.""" + google_ready = _google_configured() + saml_ready = _saml_configured() return AuthConfigResponse( - google_oauth_configured=bool( - settings.oauth_google_client_id and settings.oauth_google_client_secret - ), + google_oauth_configured=google_ready, + saml_configured=saml_ready, + providers=_auth_provider_statuses(), allowed_email_domains=sorted(allowed_email_domains()), redirect_uri=settings.oauth_redirect_uri, dev_login_enabled=_dev_login_available(request), @@ -308,9 +381,33 @@ async def login( next: Annotated[str | None, Query()] = None, ) -> RedirectResponse: """Start Google OIDC authorization code + PKCE login.""" + if provider == "saml": + if not _saml_configured(): + return _frontend_login_redirect("saml_not_configured", request) + _prune_saml_states() + relay_state = secrets.token_urlsafe(32) + acs_url = acs_url_for_entity_id(settings.saml_sp_entity_id) + request_id, authn_request_xml = build_authn_request( + sp_entity_id=settings.saml_sp_entity_id, + sso_url=settings.saml_sso_url, + acs_url=acs_url, + ) + _saml_states[relay_state] = SamlState( + request_id=request_id, + next_path=_safe_next_path(next), + created_at=time.time(), + ) + return RedirectResponse( + redirect_binding_url( + sso_url=settings.saml_sso_url, + authn_request_xml=authn_request_xml, + relay_state=relay_state, + ), + status_code=302, + ) if provider != "google": return _frontend_login_redirect("unsupported_provider", request) - if not settings.oauth_google_client_id or not settings.oauth_google_client_secret: + if not _google_configured(): return _frontend_login_redirect("not_configured", request) _prune_oauth_states() @@ -406,6 +503,58 @@ async def callback( return response +@router.post("/saml/acs") +async def saml_acs(request: Request) -> RedirectResponse: + """Accept a minimal unsigned SAMLResponse for local fixture SAML proof. + + Signed SAML verification is intentionally not implemented. When + SAML_X509_CERT_FINGERPRINT is configured, this endpoint refuses to trust the + response so production does not silently run unsigned SAML. + """ + if not _saml_configured(): + return _frontend_login_redirect("saml_not_configured", request) + if settings.saml_x509_cert_fingerprint.strip(): + return _frontend_login_redirect("saml_signature_verification_required", request) + if settings.environment != "dev": + return _frontend_login_redirect("saml_fixture_acs_dev_only", request) + + form = await request.form() + relay_state = str(form.get("RelayState") or "") + encoded_response = str(form.get("SAMLResponse") or "") + if not relay_state or not encoded_response: + return _frontend_login_redirect("saml_missing_callback", request) + + _prune_saml_states() + stored = _saml_states.pop(relay_state, None) + if stored is None: + return _frontend_login_redirect("saml_invalid_state", request) + + try: + identity = parse_fixture_response(encoded_response) + email = validate_google_identity_domain( + email=identity.email, + email_verified=True, + hosted_domain=_email_domain(identity.email), + ) + except (HTTPException, ValueError): + return _frontend_login_redirect("saml_assertion_invalid", request) + + role = _role_for_saml_identity(identity) + try: + sid, _ = await create_session( + email=email, + display_name=identity.display_name or email, + role=role.value, + cohort_ids=[], + ) + except InactiveUserError: + return _frontend_login_redirect("inactive_user", request) + + response = RedirectResponse(_frontend_url(stored.next_path, request), status_code=302) + _set_session_cookie(response, sid) + return response + + @router.post("/dev-login", response_model=MeResponse) async def dev_login(request: Request, body: DevLoginRequest, response: Response) -> MeResponse: """Dev-only server login for local E2E and manual testing. diff --git a/apps/api/app/routes/personas.py b/apps/api/app/routes/personas.py index df8be3f..a368bd5 100644 --- a/apps/api/app/routes/personas.py +++ b/apps/api/app/routes/personas.py @@ -2,15 +2,23 @@ from __future__ import annotations -from typing import Any +from typing import Annotated, Any, Literal -from fastapi import APIRouter, HTTPException, Response, status +from fastapi import APIRouter, Depends, HTTPException, Response, status from pydantic import BaseModel -from ..deps import CurrentPrincipal -from ..persona_repository import CatalogPersona, list_catalog_personas +from ..deps import CurrentPrincipal, Principal, Role, require_role +from ..persona_repository import ( + CatalogPersona, + PersonaReviewAction, + PersonaReviewItem, + list_catalog_personas, + list_persona_review_queue, + update_persona_review_status, +) router = APIRouter(prefix="/personas", tags=["personas"]) +TeacherOrAdmin = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))] class PersonaSummary(BaseModel): @@ -25,6 +33,24 @@ class PersonaSummary(BaseModel): degraded: bool = False +class PersonaReviewSummary(BaseModel): + persona_id: str + code: str + version: int + status: Literal["draft", "review", "approved", "archived"] + display_name: str + difficulty: str + theory_target: list[str] + source_provenance: str + is_synthetic: bool + created_at: str | None = None + approved_at: str | None = None + + +class PersonaReviewDecisionRequest(BaseModel): + action: PersonaReviewAction + + def _first_text_value(data: dict[str, Any]) -> str: for value in data.values(): if isinstance(value, str) and value.strip(): @@ -46,6 +72,27 @@ def _summary(entry: CatalogPersona) -> PersonaSummary: ) +def _review_summary(entry: PersonaReviewItem) -> PersonaReviewSummary: + return PersonaReviewSummary( + persona_id=entry.persona_id, + code=entry.code, + version=entry.version, + status=entry.status, + display_name=entry.display_name, + difficulty=entry.difficulty, + theory_target=entry.theory_target, + source_provenance=entry.source_provenance, + is_synthetic=entry.is_synthetic, + created_at=entry.created_at, + approved_at=entry.approved_at, + ) + + +def _ensure_teacher_or_admin(principal: Principal) -> None: + if principal.role not in {Role.TEACHER, Role.ADMIN}: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="only teachers and admins can review personas") + + @router.get("", response_model=list[PersonaSummary]) async def list_personas(response: Response, _principal: CurrentPrincipal) -> list[PersonaSummary]: """Return latest approved personas from app.persona_card.""" @@ -64,3 +111,47 @@ async def list_personas(response: Response, _principal: CurrentPrincipal) -> lis response.headers["X-Vignette-Catalog-Source"] = "database" return [_summary(entry) for entry in personas] + + +@router.get("/review", response_model=list[PersonaReviewSummary]) +async def list_persona_reviews(principal: TeacherOrAdmin) -> list[PersonaReviewSummary]: + """Return draft/review personas awaiting faculty approval.""" + _ensure_teacher_or_admin(principal) + try: + queue = await list_persona_review_queue(role=principal.role.value) + except Exception as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + detail="persona review queue database unavailable", + ) from exc + return [_review_summary(entry) for entry in queue] + + +@router.post("/review/{persona_id}", response_model=PersonaReviewSummary) +async def decide_persona_review( + persona_id: str, + request: PersonaReviewDecisionRequest, + principal: TeacherOrAdmin, +) -> PersonaReviewSummary: + """Approve a persona for learners or return it to draft for changes.""" + _ensure_teacher_or_admin(principal) + try: + updated = await update_persona_review_status( + persona_id=persona_id, + action=request.action, + reviewer_id=principal.user_id, + role=principal.role.value, + ) + except ValueError as exc: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + detail="persona review update database unavailable", + ) from exc + if updated is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail="persona review item not found or not pending review", + ) + return _review_summary(updated) diff --git a/apps/api/app/routes/sessions.py b/apps/api/app/routes/sessions.py index 771fe7f..5ab7949 100644 --- a/apps/api/app/routes/sessions.py +++ b/apps/api/app/routes/sessions.py @@ -18,13 +18,13 @@ from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel, Field from sse_starlette.sse import EventSourceResponse -from .. import session_persistence +from .. import db, session_persistence from ..config import settings from ..deps import CurrentPrincipal, Principal, Role from ..engine_client import EngineError, engine_client from ..persona_repository import get_catalog_persona from ..runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed -from ..services import evaluator, memory, orchestrator, state_machine +from ..services import evaluator, memory, orchestrator, rag, state_machine from ..store import InProcSession, TurnRecord, store router = APIRouter(prefix="/sessions", tags=["sessions"]) @@ -193,6 +193,165 @@ class SessionReviewResponse(BaseModel): _RECALL_CACHE: dict[str, memory.RecallContext] = {} +# 세션별 KB 증상 행동단서(회기 1회 산출·캐시). 빈 list 캐시 = 회기 내 재시도 안 함(안정성). +_KB_CUES_CACHE: dict[str, list[str]] = {} +_LEARNER_VISIBLE_AI_ROLE = "counselor" + +# ──────────────────────────────────────────────────────────────────────────── +# RAG 배선 헬퍼 — 내담자(CLIENT) 뷰. 임베더/KB/DB 풀 미가용 시 빈 값으로 graceful +# degradation: 상담 루프를 절대 막지 않는다(라이브 루프 비차단이 계약). routes/kb.py가 +# 같은 예외를 503으로 올리는 것과 의도적으로 다르다. 임베딩은 rag가 스레드풀로 offload. +# ──────────────────────────────────────────────────────────────────────────── +_RAG_RECALL_K = 5 +_KB_CUES_K = 4 + + +def _persona_kb_query(card) -> str: + """페르소나 증상·호소 → KB 행동단서 검색 질의(임베더/tsquery 입력 전용, LLM 미주입). + + 질의는 프롬프트에 들어가지 않는다. 회수된 behavior_cue만 L2로 주입되고, CLIENT 정책 + (expose_body=False)이 본문을 잘라 '행동단서'만 돌려준다(CCD 본문 비노출 자동 보존). + """ + parts: list[str] = [] + presenting = getattr(card, "presenting", None) or {} + if presenting.get("주호소"): + parts.append(str(presenting["주호소"])) + if presenting.get("표층"): + parts.append(str(presenting["표층"])) + dsm = getattr(card, "dsm5_dimensional", None) or {} + parts.extend(str(key) for key in dsm.keys() if key != "note") + return " ".join(p for p in parts if p).strip() + + +async def _retrieve_kb_behavior_cues(card) -> list[str]: + """KB 증상 행동단서 회수(CLIENT 정책). 미가용 시 빈 리스트(비차단).""" + query = _persona_kb_query(card) + if not query: + return [] + try: + async with db.acquire(ai_view=rag.AIRole.CLIENT.value) as conn: + result = await rag.search_kb( + conn, + query=query, + role=rag.AIRole.CLIENT, + k=_KB_CUES_K, + ) + return [c.behavior_cue for c in result.chunks if c.behavior_cue] + except Exception: + # rag.NotConfigured(임베더/KB 미가용)·RuntimeError(풀 미초기화)·DB 오류 포함. + # 비치명적: 빈 단서로 진행. CancelledError는 BaseException이라 미포착. + return [] + + +async def _ensure_kb_cues(session_id: str, card) -> list[str]: + """세션별 KB 행동단서(회기 1회 산출·캐시, 서버 재시작/재개 시 lazy 재계산).""" + cached = _KB_CUES_CACHE.get(session_id) + if cached is not None: + return cached + cues = await _retrieve_kb_behavior_cues(card) + _KB_CUES_CACHE[session_id] = cues + return cues + + +async def _load_prev_case_summary(case_id: str) -> Optional[dict]: + """직전 회기 요약(case 스코프) → build_recall_context 입력. 미존재/미가용 시 None.""" + try: + async with db.acquire(ai_view=rag.AIRole.CLIENT.value) as conn: + row = await conn.fetchrow( + """ + SELECT digest, open_threads, end_state + FROM app.session_summary + WHERE case_id = $1::uuid + ORDER BY session_no DESC, created_at DESC + LIMIT 1 + """, + case_id, + ) + except Exception: + return None + if row is None: + return None + return { + "digest": row["digest"], + "open_threads": list(row["open_threads"] or []), + "end_state": dict(row["end_state"] or {}), + } + + +async def _hydrate_episodic_text(conn, result) -> list[str]: + """retrieve_persona_memory가 돌려준 turn_id → app.turns 마스킹 본문 조인(내담자 발화).""" + turn_ids = [c.meta.get("turn_id") for c in result.chunks if c.meta.get("turn_id")] + if not turn_ids: + return [] + rows = await conn.fetch( + """ + SELECT id, text_masked FROM app.turns + WHERE id = ANY($1::uuid[]) AND speaker = 'client' + """, + turn_ids, + ) + by_id = {str(r["id"]): r["text_masked"] for r in rows} + return [by_id[t] for t in turn_ids if by_id.get(t)] + + +def _recall_query(prev_summary: Optional[dict], card) -> str: + """episodic recall 질의: 직전 open_threads 우선, 없으면 주호소.""" + if prev_summary: + threads = prev_summary.get("open_threads") or [] + if threads: + return " ".join(str(t) for t in threads) + presenting = getattr(card, "presenting", None) or {} + return str(presenting.get("주호소") or "").strip() + + +async def _episodic_recall_snippets(case_id: str, query: str) -> list[str]: + """case 스코프 episodic 벡터 recall → 내담자 발화 단편(마스킹본). 미가용 시 [].""" + if not query: + return [] + try: + async with db.acquire(ai_view=rag.AIRole.CLIENT.value) as conn: + result = await rag.retrieve_persona_memory( + conn, case_id=case_id, query=query, k=_RAG_RECALL_K, + ) + return await _hydrate_episodic_text(conn, result) + except Exception: + return [] + + +async def _build_start_recall(*, case_id: str, card) -> memory.RecallContext: + """회기 시작 회상 조립: prev_summary(case) + episodic recall을 build_recall_context로 + 합본. 전 구간 graceful(미가용 시 빈 회상). + """ + try: + db.get_pool() # 풀 미초기화 시 RuntimeError → 첫 회기와 동일한 빈 회상 + except RuntimeError: + return memory.build_recall_context() + prev_summary = await _load_prev_case_summary(case_id) + query = _recall_query(prev_summary, card) + episodic = await _episodic_recall_snippets(case_id, query) + pinned = list((prev_summary or {}).get("pinned_facts") or []) + return memory.build_recall_context( + prev_summary=prev_summary, + episodic_snippets=episodic, + pinned_facts=pinned, + ) + + +async def _warm_rag_caches(session_id: str, case_id: str, card) -> None: + """RAG 회상·KB 행동단서를 **백그라운드**로 산출해 캐시한다(요청 경로 비차단). + + BGE-M3 임베더 첫 로드(~수 초)가 회기 시작/턴 응답을 막지 않도록 create_task로 띄운다. + warm 완료 전 턴은 빈 회상/단서로 진행(graceful), 이후 턴부터 RAG 주입. 전 구간 비치명적. + """ + try: + _RECALL_CACHE[session_id] = await _build_start_recall(case_id=case_id, card=card) + except Exception: + pass + try: + _KB_CUES_CACHE[session_id] = await _retrieve_kb_behavior_cues(card) + except Exception: + pass + _PHASE_KEY_BY_LABEL = { "라포": "rapport", @@ -481,6 +640,92 @@ def _evaluation_payload(record: dict[str, object] | None) -> dict[str, object]: return payload if isinstance(payload, dict) else {} +# fast-loop 턴 평가(TechniqueCategory) → 프론트 sr-technique--{kind} 시각 매핑. +_TECHNIQUE_KIND_BY_CATEGORY = { + "relational": "empathy", + "exploratory": "explore", + "intervention": "confront", + "stabilizing": "reflect", + "structuring": "closed", +} + + +def _review_techniques_from_turn_eval(ev: dict[str, object] | None) -> list[ReviewTechnique]: + """턴 평가의 기법 태그를 리뷰 칩으로. label_ko 우선, category로 색 kind 결정.""" + if not isinstance(ev, dict): + return [] + out: list[ReviewTechnique] = [] + for tag in ev.get("techniques") or []: + if not isinstance(tag, dict): + continue + label = str(tag.get("label_ko") or tag.get("code") or "").strip() + if not label: + continue + kind = _TECHNIQUE_KIND_BY_CATEGORY.get(str(tag.get("category") or ""), "explore") + out.append(ReviewTechnique(kind=kind, label=label)) + return out + + +def _review_note_from_turn_eval(ev: dict[str, object] | None) -> Optional[ReviewNote]: + """의도이탈(있으면 우선) 또는 적절성 신호를 턴 노트로. tone: good|warn(프론트 계약).""" + if not isinstance(ev, dict): + return None + dev = ev.get("intent_deviation") + if isinstance(dev, dict): + dimension = str(dev.get("dimension") or "").strip() + expected = str(dev.get("expected") or "").strip() + actual = str(dev.get("actual") or "").strip() + body = " / ".join(p for p in (f"권장: {expected}" if expected else "", f"실제: {actual}" if actual else "") if p) + return ReviewNote( + author="평가 AI", + tone="warn", + title=f"의도와 다른 부분 · {dimension}".rstrip(" ·") or "의도와 다른 부분", + body=body or "권장 반응과 실제 반응에 차이가 있었어요.", + ) + appropriateness = str(ev.get("appropriateness") or "neutral") + note_text = str(ev.get("appropriateness_note") or "").strip() + if appropriateness == "pos": + return ReviewNote(author="평가 AI", tone="good", title="적절한 개입", body=note_text or "이 개입은 흐름에 적절했어요.") + if appropriateness == "warn" and note_text: + return ReviewNote(author="평가 AI", tone="warn", title="점검해볼 지점", body=note_text) + return None + + +async def _record_safety_event(sess: InProcSession, ctx, result) -> None: + """위기 escalate 시 app.safety_events 적재(교수자 감사·알림 레코드). C2. + + 비차단: DB 미가용(degraded)·FK 미충족(in-memory 세션) 시 graceful skip — 상담 루프를 + 절대 막지 않는다. 실시간 교수자 push 알림은 후속(이 레코드가 1차 알림원). + """ + crisis = getattr(ctx, "crisis", None) + if crisis is None or not getattr(crisis, "escalate", False): + return + kind = getattr(crisis.kind, "value", None) or str(getattr(crisis, "kind", "crisis")) + try: + async with db.acquire() as conn: + await conn.execute( + """ + INSERT INTO app.safety_events + (session_id, trigger_type, ko_risk_level, escalated, detail) + VALUES ($1::uuid, $2, $3, TRUE, $4::jsonb) + """, + sess.session_id, + kind, + int(getattr(crisis, "risk_level", 0) or 0), + json.dumps({ + "matched": list(getattr(crisis, "matched", []) or []), + "stage": getattr(result, "stage", None), + "turn_seq": getattr(result, "turn_seq", None), + }), + ) + except Exception: + pass # 비차단(R5): 적재 실패가 위기 대응/상담을 막지 않음. + + +def _learner_visible_turns(sess: InProcSession) -> list[TurnRecord]: + return sess.turns_visible_to(_LEARNER_VISIBLE_AI_ROLE) + + async def _generate_and_save_session_evaluation(sess: InProcSession) -> None: if not sess.turns: return @@ -535,8 +780,9 @@ def _schedule_session_evaluation(sess: InProcSession) -> None: def _learner_summary(sess: InProcSession, *, review_ready: bool = False) -> LearnerSessionSummary: - learner_turns = sum(1 for turn in sess.turns if turn.speaker == "counselor") - client_turns = sum(1 for turn in sess.turns if turn.speaker == "client") + turns = _learner_visible_turns(sess) + learner_turns = sum(1 for turn in turns if turn.speaker == "counselor") + client_turns = sum(1 for turn in turns if turn.speaker == "client") return LearnerSessionSummary( session_id=sess.session_id, persona_code=sess.persona_code, @@ -544,7 +790,7 @@ def _learner_summary(sess: InProcSession, *, review_ready: bool = False) -> Lear session_no=sess.session_no, status="ended" if sess.ended else "active", stage=_stage_label(sess.state.stage), - turn_count=len(sess.turns), + turn_count=len(turns), learner_turn_count=learner_turns, client_turn_count=client_turns, started_at=_iso(sess.created_at) or "", @@ -554,7 +800,10 @@ def _learner_summary(sess: InProcSession, *, review_ready: bool = False) -> Lear async def _review_ready(sess: InProcSession, principal: Principal) -> bool: - if not sess.ended or not sess.turns: + turns = _learner_visible_turns(sess) + if not sess.ended or not turns: + return False + if len(turns) != len(sess.turns): return False evaluation_record, _ = await session_persistence.load_session_evaluation( sess.session_id, @@ -568,6 +817,7 @@ def _session_detail( *, review_ready: bool = False, ) -> SessionDetailResponse: + turns = _learner_visible_turns(sess) return SessionDetailResponse( session_id=sess.session_id, case_id=sess.case_id, @@ -587,7 +837,7 @@ def _session_detail( text=turn.text_masked, created_at=_iso(turn.created_at) or "", ) - for turn in sess.turns + for turn in turns ], review_ready=review_ready, ) @@ -649,10 +899,7 @@ async def start_session( recall = memory.build_recall_context() st = state_machine.init_state( - base_resistance=card.base_resistance(), - unlock_rate=card.unlock_rate(), - decay_floor=card.decay_floor(), - ideation_baseline=card.ideation_baseline(), + params=card.openness_params(), carry=recall.carry, ) @@ -680,7 +927,11 @@ async def start_session( ) else: store.put(sess) + + # 즉시 빈/carry 회상으로 응답을 막지 않는다. RAG 회상·KB 단서(임베더 로드 수 초)는 + # 백그라운드 warm으로 캐시 — 회기 시작/턴 응답이 임베더 로드에 블로킹되지 않게(성능 회귀 방지). _RECALL_CACHE[sess.session_id] = recall + asyncio.create_task(_warm_rag_caches(sess.session_id, sess.case_id, card)) return SessionStartResponse( session_id=sess.session_id, @@ -701,6 +952,8 @@ async def get_session_review( """Return a learner-safe review built only from the stored session transcript.""" _ensure_learner(principal) sess = await _load_session_or_404(session_id, principal, allow_ended=True) + visible_turns = _learner_visible_turns(sess) + hidden_turns = len(visible_turns) != len(sess.turns) end_ts = sess.ended_at or datetime.now().timestamp() duration_seconds = max(0, int(round(end_ts - sess.created_at))) @@ -708,7 +961,7 @@ async def get_session_review( client_initial = client_name[:1] or "내" reached_phase = _stage_label(sess.state.stage) - stage_labels = [turn.stage for turn in sess.turns] or [reached_phase] + stage_labels = [turn.stage for turn in visible_turns] or [reached_phase] axis = ["0:00"] if duration_seconds > 0: axis.append(_offset_label(duration_seconds)) @@ -717,16 +970,20 @@ async def get_session_review( session_id, principal, ) - evaluation_payload = _evaluation_payload(evaluation_record) - evaluation_status = str(evaluation_record.get("status") or "") if evaluation_record else "" - evaluation_ready = evaluation_status == "ready" + evaluation_payload = {} if hidden_turns else _evaluation_payload(evaluation_record) + evaluation_status = ( + "" if hidden_turns else str(evaluation_record.get("status") or "") if evaluation_record else "" + ) + evaluation_ready = not hidden_turns and evaluation_status == "ready" - first_turn_ts = sess.turns[0].created_at if sess.turns else sess.created_at + first_turn_ts = visible_turns[0].created_at if visible_turns else sess.created_at turns: list[ReviewTurn] = [] - for index, turn in enumerate(sess.turns): + for index, turn in enumerate(visible_turns): speaker: Literal["learner", "client"] = ( "learner" if turn.speaker == "counselor" else "client" ) + # 턴별 fast-loop 평가는 학습자 발화에만 부착(기법 태깅·노트). hidden 시 노출 안 함. + turn_eval = turn.evaluation if (speaker == "learner" and not hidden_turns) else None turns.append( ReviewTurn( id=f"t{index + 1}", @@ -734,8 +991,8 @@ async def get_session_review( speaker=speaker, who="학습자" if speaker == "learner" else client_name, text=turn.text_masked, - techniques=[], - note=None, + techniques=_review_techniques_from_turn_eval(turn_eval), + note=_review_note_from_turn_eval(turn_eval), ) ) @@ -783,10 +1040,10 @@ async def get_session_review( summary = _review_summary_from_evaluation( fallback=transcript_summary, - evaluation_record=evaluation_record, + evaluation_record=None if hidden_turns else evaluation_record, payload=evaluation_payload, ) - if evaluation_record and not evaluation_durable: + if evaluation_record and not hidden_turns and not evaluation_durable: summary += " 현재 평가는 런타임 캐시에서 복원되었습니다." return SessionReviewResponse( @@ -832,6 +1089,7 @@ async def submit_turn( _ensure_learner(principal) sess = await _load_session_or_404(session_id, principal) recall = _RECALL_CACHE.get(session_id) or memory.RecallContext() + kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful) ctx = orchestrator.prepare_turn( session_id=session_id, @@ -841,18 +1099,25 @@ async def submit_turn( learner_text=body.text, recall_summary=recall.recall_summary, pinned_facts=recall.pinned_facts, - recent_turns=sess.recent_turns(), + recent_turns=sess.recent_turns(visible_to="client"), + kb_behavior_cues=kb_cues, + theory_mode=sess.theory_mode, ) assert ctx.state_after is not None try: - result = await orchestrator.run_turn_generate(ctx, engine_client) + result = await orchestrator.run_turn_generate( + ctx, + engine_client, + eval_hook=evaluator.make_eval_hook(engine_client), + ) except EngineError as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"engine unavailable: {exc}", ) from exc + # 턴별 fast-loop 평가는 학습자(상담자) 발화에 부착(기법 태깅·적절성·의도이탈). await _append_session_turn( sess, TurnRecord( @@ -861,6 +1126,7 @@ async def submit_turn( stage=_stage_label(ctx.state_after.stage), text=body.text, text_masked=ctx.learner_text_masked, + evaluation=result.evaluation, ), ) @@ -873,9 +1139,15 @@ async def submit_turn( stage=_stage_label(result.state_after.stage), text=result.client_reply, text_masked=result.client_reply, + llm_provider=result.llm_provider, + model=result.model, + tokens_in=result.tokens_in, + tokens_out=result.tokens_out, + cost_usd=result.cost_usd, ), ) await _update_session_state(sess, result.state_after) + await _record_safety_event(sess, ctx, result) # C2: 위기 escalate 시 safety_events 적재(비차단) return TurnResponse( turn_seq=result.turn_seq, @@ -897,6 +1169,7 @@ async def stream_turn( _ensure_learner(principal) sess = await _load_session_or_404(session_id, principal) recall = _RECALL_CACHE.get(session_id) or memory.RecallContext() + kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful) ctx = orchestrator.prepare_turn( session_id=session_id, @@ -906,7 +1179,9 @@ async def stream_turn( learner_text=body.text, recall_summary=recall.recall_summary, pinned_facts=recall.pinned_facts, - recent_turns=sess.recent_turns(), + recent_turns=sess.recent_turns(visible_to="client"), + kb_behavior_cues=kb_cues, + theory_mode=sess.theory_mode, ) assert ctx.state_after is not None @@ -941,6 +1216,11 @@ async def stream_turn( stage=_stage_label(ctx.state_after.stage), text=final_reply, text_masked=final_reply, + llm_provider=str(ev.data.get("llm_provider") or ""), + model=str(ev.data.get("model") or ""), + tokens_in=int(ev.data.get("tokens_in") or 0), + tokens_out=int(ev.data.get("tokens_out") or 0), + cost_usd=float(ev.data.get("cost_usd") or 0.0), ), ) yield {"event": "done", "data": json.dumps(data, ensure_ascii=False)} @@ -980,6 +1260,7 @@ async def end_session( await _end_persisted_session(sess, carry) _RECALL_CACHE.pop(session_id, None) + _KB_CUES_CACHE.pop(session_id, None) _schedule_session_evaluation(sess) return SessionEndResponse( diff --git a/apps/api/app/routes/voice.py b/apps/api/app/routes/voice.py index 360928c..94ec38e 100644 --- a/apps/api/app/routes/voice.py +++ b/apps/api/app/routes/voice.py @@ -14,6 +14,8 @@ cleanly instead of crashing. from __future__ import annotations import json +import hashlib +import time from typing import Optional from fastapi import APIRouter, WebSocket, WebSocketDisconnect @@ -27,7 +29,7 @@ from ..deps import Principal, Role from ..engine_client import EngineError, engine_client from ..persona_repository import get_catalog_persona from ..runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed -from ..services import memory, orchestrator, state_machine +from ..services import evaluator, memory, orchestrator, state_machine from ..services import voice as voice_svc from ..services.voice import VoicePreset, VoiceUnavailable, resolve_voice, voice_service from ..store import InProcSession, TurnRecord, store @@ -113,6 +115,8 @@ async def voice_ws(websocket: WebSocket) -> None: audio_buf = bytearray() receiving = False + audio_started_at: float | None = None + last_audio_end_at: float | None = None try: while True: @@ -126,6 +130,7 @@ async def voice_ws(websocket: WebSocket) -> None: if not receiving: # Be tolerant when audio arrives before audio_start. receiving = True + audio_started_at = time.monotonic() audio_buf.clear() await _safe_send_json(websocket, {"type": "state", "state": "listening"}) audio_buf.extend(msg["bytes"]) @@ -151,11 +156,16 @@ async def voice_ws(websocket: WebSocket) -> None: ctype = ctrl.get("type") if ctype == "audio_start": receiving = True + audio_started_at = time.monotonic() audio_buf.clear() await _safe_send_json(websocket, {"type": "state", "state": "listening"}) elif ctype == "audio_end": receiving = False + audio_ended_at = time.monotonic() + silence_ms = _safe_int(ctrl.get("silence_ms")) + if silence_ms is None and last_audio_end_at is not None and audio_started_at is not None: + silence_ms = max(0, int((audio_started_at - last_audio_end_at) * 1000)) await _handle_utterance( websocket, session_id=session_id, @@ -163,7 +173,13 @@ async def voice_ws(websocket: WebSocket) -> None: voice_preset=voice_preset, audio=bytes(audio_buf), fmt=ctrl.get("format"), + audio_started_at=audio_started_at, + audio_ended_at=audio_ended_at, + silence_ms=silence_ms, + barge_in=_safe_bool(ctrl.get("barge_in")), ) + last_audio_end_at = audio_ended_at + audio_started_at = None audio_buf.clear() elif ctype == "text_turn": @@ -202,6 +218,10 @@ async def _handle_utterance( voice_preset: VoicePreset, audio: bytes, fmt: Optional[str], + audio_started_at: float | None = None, + audio_ended_at: float | None = None, + silence_ms: int | None = None, + barge_in: bool | None = None, ) -> None: """Transcribe one utterance, generate the client reply, then synthesize TTS.""" if not audio: @@ -226,6 +246,9 @@ async def _handle_utterance( return learner_text = stt.text + audio_ref = _voice_audio_ref(audio, fmt) + duration_s = stt.duration or _elapsed_seconds(audio_started_at, audio_ended_at) + speech_rate = _estimate_speech_rate(learner_text, duration_s) await _safe_send_json( websocket, {"type": "transcript", "text": learner_text, "final": True, "speaker": "counselor"}, @@ -240,6 +263,10 @@ async def _handle_utterance( principal=principal, voice_preset=voice_preset, learner_text=learner_text, + audio_ref=audio_ref, + silence_ms=silence_ms, + speech_rate=speech_rate, + barge_in=barge_in, ) @@ -250,6 +277,10 @@ async def _run_turn_and_speak( principal: Principal, voice_preset: VoicePreset, learner_text: str, + audio_ref: str | None = None, + silence_ms: int | None = None, + speech_rate: float | None = None, + barge_in: bool | None = None, ) -> None: """Run one counseling turn and stream synthesized client speech.""" sess, err = await _load_voice_session(session_id, principal) @@ -267,13 +298,18 @@ async def _run_turn_and_speak( learner_text=learner_text, recall_summary=recall.recall_summary, pinned_facts=recall.pinned_facts, - recent_turns=sess.recent_turns(), + recent_turns=sess.recent_turns(visible_to="client"), + theory_mode=sess.theory_mode, ) assert ctx.state_after is not None # Voice needs the full client reply before TTS starts. try: - result = await orchestrator.run_turn_generate(ctx, engine_client) + result = await orchestrator.run_turn_generate( + ctx, + engine_client, + eval_hook=evaluator.make_eval_hook(engine_client), + ) except EngineError as e: await _safe_send_json(websocket, {"type": "error", "detail": f"engine unavailable: {e}"}) await _safe_send_json(websocket, {"type": "state", "state": "idle"}) @@ -290,6 +326,11 @@ async def _run_turn_and_speak( stage=ctx.state_after.stage.value, text=learner_text, text_masked=ctx.learner_text_masked, + audio_ref=audio_ref, + silence_ms=silence_ms, + speech_rate=speech_rate, + barge_in=barge_in, + evaluation=result.evaluation, ), ) if reply: @@ -302,6 +343,11 @@ async def _run_turn_and_speak( stage=result.stage, text=reply, text_masked=reply, + llm_provider=result.llm_provider, + model=result.model, + tokens_in=result.tokens_in, + tokens_out=result.tokens_out, + cost_usd=result.cost_usd, ), ) await _update_voice_state(sess, result.state_after) @@ -333,10 +379,7 @@ async def _run_turn_and_speak( try: n = 0 async for ck in voice_service.synthesize_stream(reply, voice_preset): - # Metadata precedes the binary chunk so the client can pair them. - await _safe_send_json( - websocket, {"type": "tts_chunk", "seq": ck.seq, "rms": round(ck.rms, 4)} - ) + # 바이너리 오디오 청크만 송신(프론트가 Web Audio AnalyserNode로 립싱크 자체 산출). await _safe_send_bytes(websocket, ck.audio) n += 1 await _safe_send_json(websocket, {"type": "tts_end", "chunks": n}) @@ -451,10 +494,7 @@ async def _bind_session( card = catalog_persona.card st = state_machine.init_state( - base_resistance=card.base_resistance(), - unlock_rate=card.unlock_rate(), - decay_floor=card.decay_floor(), - ideation_baseline=card.ideation_baseline(), + params=card.openness_params(), ) sess = await session_persistence.create_session( learner_id=principal.user_id, @@ -511,6 +551,52 @@ def _audio_meta(fmt: Optional[str]) -> tuple[str, str]: return table.get(f, ("audio.webm", "audio/webm")) +def _voice_audio_ref(audio: bytes, fmt: Optional[str]) -> str | None: + if not audio: + return None + f = (fmt or "webm").lower().lstrip(".") or "webm" + digest = hashlib.sha256(audio).hexdigest()[:24] + return f"voice:{f}:sha256:{digest}" + + +def _elapsed_seconds(started_at: float | None, ended_at: float | None) -> float | None: + if started_at is None or ended_at is None: + return None + return max(0.001, ended_at - started_at) + + +def _estimate_speech_rate(text: str, duration_s: float | None) -> float | None: + if not text or not duration_s or duration_s <= 0: + return None + units = sum(1 for ch in text if not ch.isspace()) + if units <= 0: + return None + return round((units / duration_s) * 60.0, 2) + + +def _safe_int(value: object) -> int | None: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _safe_bool(value: object) -> bool | None: + if value is None: + return None + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "y"}: + return True + if normalized in {"0", "false", "no", "n"}: + return False + return bool(value) + + async def _safe_send_json(websocket: WebSocket, payload: dict) -> None: if websocket.client_state != WebSocketState.CONNECTED: return diff --git a/apps/api/app/saml.py b/apps/api/app/saml.py new file mode 100644 index 0000000..bf36950 --- /dev/null +++ b/apps/api/app/saml.py @@ -0,0 +1,157 @@ +"""Minimal SAML SP helpers for local fixture authentication tests. + +This module intentionally implements only the Redirect-binding AuthnRequest and +unsigned fixture ACS parsing needed for backend proof. Signed production SAML +assertion verification is not implemented here. +""" + +from __future__ import annotations + +import base64 +import html +import uuid +import zlib +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Iterable +from urllib.parse import urlsplit, urlunsplit, urlencode +from xml.etree import ElementTree + + +SAML_PROTOCOL_NS = "urn:oasis:names:tc:SAML:2.0:protocol" +SAML_ASSERTION_NS = "urn:oasis:names:tc:SAML:2.0:assertion" +SAML_ATTRIBUTE_ROLE_NAMES = { + "role", + "roles", + "groups", + "memberOf", + "http://schemas.microsoft.com/ws/2008/06/identity/claims/role", +} +SAML_ATTRIBUTE_EMAIL_NAMES = { + "email", + "mail", + "emailaddress", + "EmailAddress", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", +} +SAML_ATTRIBUTE_DISPLAY_NAME_NAMES = { + "display_name", + "displayName", + "name", + "cn", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", +} + + +@dataclass(frozen=True, slots=True) +class SamlIdentity: + email: str + display_name: str + role_hint: str | None = None + + +def acs_url_for_entity_id(entity_id: str) -> str: + parsed = urlsplit(entity_id.strip()) + if parsed.scheme and parsed.netloc: + path = parsed.path.rstrip("/") + if path.endswith("/metadata"): + path = path[: -len("/metadata")] + return urlunsplit((parsed.scheme, parsed.netloc, f"{path}/acs", "", "")) + value = entity_id.strip().rstrip("/") + if value.endswith("/metadata"): + value = value[: -len("/metadata")] + return value + "/acs" + + +def build_authn_request( + *, + sp_entity_id: str, + sso_url: str, + acs_url: str, +) -> tuple[str, str]: + request_id = "_" + uuid.uuid4().hex + issued_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + xml = ( + f'' + f"{html.escape(sp_entity_id)}" + "" + ) + return request_id, xml + + +def redirect_binding_url(*, sso_url: str, authn_request_xml: str, relay_state: str) -> str: + compressor = zlib.compressobj(wbits=-15) + deflated = compressor.compress(authn_request_xml.encode("utf-8")) + compressor.flush() + params = urlencode( + { + "SAMLRequest": base64.b64encode(deflated).decode("ascii"), + "RelayState": relay_state, + } + ) + separator = "&" if "?" in sso_url else "?" + return f"{sso_url}{separator}{params}" + + +def inflate_redirect_request(encoded_request: str) -> str: + payload = base64.b64decode(encoded_request) + return zlib.decompress(payload, wbits=-15).decode("utf-8") + + +def parse_fixture_response(encoded_response: str) -> SamlIdentity: + try: + xml = base64.b64decode(encoded_response).decode("utf-8") + root = ElementTree.fromstring(xml) + except Exception as exc: + raise ValueError("invalid SAMLResponse") from exc + + name_id = _first_text(root, f".//{{{SAML_ASSERTION_NS}}}NameID") + attributes = _attributes(root) + email = _first_attribute(attributes, SAML_ATTRIBUTE_EMAIL_NAMES) or name_id + if not email: + raise ValueError("email claim is required") + + display_name = ( + _first_attribute(attributes, SAML_ATTRIBUTE_DISPLAY_NAME_NAMES) + or name_id + or email + ) + role_hint = _first_attribute(attributes, SAML_ATTRIBUTE_ROLE_NAMES) + return SamlIdentity(email=email, display_name=display_name or email, role_hint=role_hint) + + +def _first_text(root: ElementTree.Element, selector: str) -> str: + node = root.find(selector) + return (node.text or "").strip() if node is not None else "" + + +def _attributes(root: ElementTree.Element) -> dict[str, list[str]]: + values: dict[str, list[str]] = {} + for attribute in root.findall(f".//{{{SAML_ASSERTION_NS}}}Attribute"): + name = (attribute.attrib.get("Name") or "").strip() + if not name: + continue + collected: list[str] = [] + for value in attribute.findall(f".//{{{SAML_ASSERTION_NS}}}AttributeValue"): + text = (value.text or "").strip() + if text: + collected.append(text) + if collected: + values[name] = collected + return values + + +def _first_attribute(attributes: dict[str, list[str]], names: Iterable[str]) -> str: + for name in names: + values = attributes.get(name) + if values: + return values[0] + lowered = {key.lower(): value for key, value in attributes.items()} + for name in names: + values = lowered.get(name.lower()) + if values: + return values[0] + return "" diff --git a/apps/api/app/services/evaluator.py b/apps/api/app/services/evaluator.py index 16dcb21..dd0c395 100644 --- a/apps/api/app/services/evaluator.py +++ b/apps/api/app/services/evaluator.py @@ -365,7 +365,10 @@ def _fewshot_block() -> str: def _theory_mode(ctx: "TurnContext") -> Optional[str]: - """페르소나 theory_target 에서 이론 모드 힌트(이론부합 평가용). 없으면 None.""" + """이론 모드(이론부합 평가용): 학습자 선택(회기 theory_mode) 우선, 없으면 페르소나 theory_target.""" + sess_theory = getattr(ctx, "theory_mode", None) + if sess_theory: + return str(sess_theory) tt = getattr(ctx.persona, "theory_target", None) if isinstance(tt, (list, tuple)) and tt: return ", ".join(str(x) for x in tt) @@ -634,7 +637,6 @@ async def evaluate_turn( try: req = GenerateRequest( ai_role="evaluator", - tier="feedback", messages=build_fast_messages(ctx, client_reply), structured_schema=_fast_schema(), max_tokens=900, @@ -691,7 +693,6 @@ async def evaluate_session( try: req = GenerateRequest( ai_role="evaluator", - tier="feedback", messages=build_deep_messages( stage=stage, scope=scope, diff --git a/apps/api/app/services/guardrail.py b/apps/api/app/services/guardrail.py index cc2831e..3b329c7 100644 --- a/apps/api/app/services/guardrail.py +++ b/apps/api/app/services/guardrail.py @@ -41,6 +41,13 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ ("EMAIL", re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")), # 카드/계좌 유사 긴 숫자열 (12자리 이상) ("NUMID", re.compile(r"\b\d{12,}\b")), + # 구체적 날짜(생년월일 등): 2001.4.18 / 2001-04-18 / 2001년 4월 18일 + ("DATE", re.compile(r"(?:19|20)\d{2}\s?[.\-/년]\s?\d{1,2}\s?[.\-/월]\s?\d{1,2}\s?일?")), + # 금액(원): 1,200원 / 1200원 (3자리+ 또는 콤마구분) — 식별 맥락 보호 + ("MONEY", re.compile(r"\d{1,3}(?:,\d{3})+\s?원|\d{3,}\s?원")), + # 한국 주소 단편: ○○시/도 ○○시/군/구 ○○동/읍/면/로/길 (행정구역 연쇄) + ("ADDR", re.compile(r"[가-힣]{2,}(?:시|도)\s?[가-힣]{1,4}(?:시|군|구)\s?[가-힣0-9]{1,}(?:동|읍|면|로|길)")), + # TODO(NER): 한국어 이름/기관명은 Presidio ko 모델/NER 필요(정규식 false-positive 위험). ] # Presidio 지연 로드 캐시 (-1=미시도, None=미설치, 객체=설치됨) diff --git a/apps/api/app/services/orchestrator.py b/apps/api/app/services/orchestrator.py index d7f3910..80b598b 100644 --- a/apps/api/app/services/orchestrator.py +++ b/apps/api/app/services/orchestrator.py @@ -37,8 +37,6 @@ from .state_machine import SessionState, Stage # 평가 훅 타입: U_t(수련생 마스킹 발화) + 내담자응답 + 상태 → 평가 결과(dict) # Features evaluator 가 이 시그니처에 맞춰 함수를 주입한다(여기선 호출만). EvalHook = Callable[["TurnContext", str], Awaitable[Optional[dict]]] -# 로깅 훅: TurnContext + 내담자응답 → None (turns insert/임베딩은 주입측 책임) -LogHook = Callable[["TurnContext", str], Awaitable[None]] @dataclass(slots=True) @@ -59,6 +57,8 @@ class TurnContext: pinned_facts: list[str] = field(default_factory=list) recent_turns: list[dict[str, str]] = field(default_factory=list) kb_behavior_cues: list[str] = field(default_factory=list) + # 회기 이론모드(학습자 선택: humanistic|cbt|integrative). 평가 이론부합·생성 프레이밍에 사용. + theory_mode: Optional[str] = None def to_state_context(self) -> PersonaStateContext: st = self.state_after or self.state_before @@ -84,6 +84,11 @@ class TurnResult: state_after: SessionState evaluation: Optional[dict] = None crisis_kind: str = "none" + llm_provider: Optional[str] = None + model: Optional[str] = None + tokens_in: int = 0 + tokens_out: int = 0 + cost_usd: float = 0.0 # ════════════════════════════════════════════════════════════════════════════ @@ -100,6 +105,7 @@ def prepare_turn( pinned_facts: Optional[list[str]] = None, recent_turns: Optional[list[dict[str, str]]] = None, kb_behavior_cues: Optional[list[str]] = None, + theory_mode: Optional[str] = None, eval_rapport_signal: Optional[float] = None, ) -> TurnContext: """엔진 호출 전 결정론 전처리(1~3단계). 순수 — IO/LLM 없음. @@ -113,10 +119,11 @@ def prepare_turn( persona=card, state_before=state, learner_text_raw=learner_text, - recall_summary=recall_summary, - pinned_facts=list(pinned_facts or []), - recent_turns=list(recent_turns or []), + recall_summary=_mask_optional_text(recall_summary), + pinned_facts=_mask_text_list(pinned_facts), + recent_turns=_mask_recent_turns(recent_turns), kb_behavior_cues=list(kb_behavior_cues or []), + theory_mode=theory_mode, ) # 1) 입력 가드레일 — PII 마스킹 + 위기분류 @@ -130,11 +137,19 @@ def prepare_turn( if eval_rapport_signal is not None else state_machine.estimate_rapport_signal(ctx.learner_text_masked) ) + # 위기분류가 관측한 risk_level(>0)을 상태머신에 ideation_observed 로 전달 → + # ideation_stage 보수적 상향(절대 하향 안 함, 안전 R5). C2 위기 관측 반영. + crisis_ideation = ( + ctx.crisis.risk_level + if ctx.crisis is not None and ctx.crisis.risk_level > 0 + else None + ) ctx.state_after = state_machine.evolve( state, rapport_signal=signal, unlock_rate=card.unlock_rate(), decay_floor=card.decay_floor(), + ideation_observed=crisis_ideation, ) # 3) 페르소나 컨텍스트 — L0~L6 messages 조립 (CCD 는 행동으로만, L0 가 강제) @@ -150,6 +165,25 @@ def prepare_turn( return ctx +def _mask_optional_text(text: Optional[str]) -> Optional[str]: + if text is None: + return None + return guardrail.mask_pii(text).text_masked + + +def _mask_text_list(values: Optional[list[str]]) -> list[str]: + return [guardrail.mask_pii(value).text_masked for value in (values or [])] + + +def _mask_recent_turns(turns: Optional[list[dict[str, str]]]) -> list[dict[str, str]]: + masked: list[dict[str, str]] = [] + for turn in turns or []: + item = dict(turn) + item["text"] = guardrail.mask_pii(str(item.get("text", ""))).text_masked + masked.append(item) + return masked + + # ════════════════════════════════════════════════════════════════════════════ # 4~8단계 — 동기 생성 경로 (폴백/테스트) # ════════════════════════════════════════════════════════════════════════════ @@ -158,11 +192,10 @@ async def run_turn_generate( engine: EngineClient, *, eval_hook: Optional[EvalHook] = None, - log_hook: Optional[LogHook] = None, ) -> TurnResult: - """동기 턴 실행(4~8). 내담자 응답을 한 번에 받아 가드레일·평가·로깅 훅 순차 적용. + """동기 턴 실행(4~8). 내담자 응답을 한 번에 받아 가드레일·평가 순차 적용. - eval_hook/log_hook 은 Features 가 주입(없으면 생략). 엔진 장애는 EngineError 전파. + eval_hook 은 Features 가 주입(없으면 생략). 엔진 장애는 EngineError 전파. """ assert ctx.state_after is not None st = ctx.state_after @@ -170,7 +203,6 @@ async def run_turn_generate( # 4) 내담자 AI 생성 req = GenerateRequest( ai_role="client", - tier="client", messages=ctx.messages, session_id=ctx.session_id, metadata={"stage": st.stage.value}, @@ -193,13 +225,6 @@ async def run_turn_generate( except Exception: evaluation = None # 평가 실패가 상담 루프를 막지 않게(비치명적) - # 8) 로깅 훅(주입형) — turns insert + 임베딩 - if log_hook is not None: - try: - await log_hook(ctx, reply) - except Exception: - pass - return TurnResult( turn_seq=st.turn_seq, stage=st.stage.value, @@ -209,6 +234,11 @@ async def run_turn_generate( state_after=st, evaluation=evaluation, crisis_kind=ctx.crisis.kind.value if ctx.crisis else "none", + llm_provider=resp.provider, + model=resp.model, + tokens_in=resp.tokens_in, + tokens_out=resp.tokens_out, + cost_usd=resp.cost_usd, ) @@ -226,21 +256,17 @@ class StreamEvent: async def run_turn_stream( ctx: TurnContext, engine: EngineClient, - *, - log_hook: Optional[LogHook] = None, ) -> AsyncIterator[StreamEvent]: """스트리밍 턴 실행(4~8). 게이트웨이 SSE 를 받아 token/done/safety/error 로 재방출. 출력 가드레일은 *누적 텍스트* 기준으로 수단정보를 감지(스트림 중 발견 시 safety 이벤트 + 재생성 신호). 토큰 단위 완벽 차단은 후속(현재는 누적 스캔). - 로깅 훅은 done 직전 최종 텍스트로 1회 호출. """ assert ctx.state_after is not None st = ctx.state_after req = StreamRequest( ai_role="client", - tier="client", messages=ctx.messages, session_id=ctx.session_id, metadata={"stage": st.stage.value}, @@ -248,15 +274,34 @@ async def run_turn_stream( accumulated = "" flagged = False + stream_meta: dict[str, Any] = {} if ctx.crisis is not None and ctx.crisis.escalate: flagged = True yield StreamEvent("safety", {"reason": "learner_real_crisis", "level": ctx.crisis.risk_level}) try: + current_event = "message" async for raw in engine.stream(req): # engine_client.stream 은 게이트웨이 SSE 의 *원시 라인*을 그대로 yield 한다. - # 게이트웨이 프레이밍: "event: token\ndata: {\"text\": ...}" 형식. - text_piece = _extract_sse_text(raw) + # 게이트웨이 프레이밍: "event: token|done|error" + "data: {...}". + line = raw.strip() + if line.startswith("event:"): + current_event = line[len("event:"):].strip() or "message" + continue + if not line.startswith("data:"): + continue + + payload = _extract_sse_payload(line) + if current_event == "error": + detail = _payload_detail(payload, "engine stream error") + yield StreamEvent("error", {"detail": detail}) + return + if current_event == "done": + if isinstance(payload, dict): + stream_meta = payload + break + + text_piece = _payload_text(payload) if text_piece is None: continue accumulated += text_piece @@ -272,13 +317,6 @@ async def run_turn_stream( yield StreamEvent("token", {"text": text_piece}) - # 8) 로깅 훅 — 최종 텍스트 - if log_hook is not None: - try: - await log_hook(ctx, accumulated) - except Exception: - pass - yield StreamEvent( "done", { @@ -287,18 +325,22 @@ async def run_turn_stream( "effective_openness": round(st.effective_openness, 4), "turn_seq": st.turn_seq, "safety_flagged": flagged, + "llm_provider": str(stream_meta.get("provider") or engine.engine_mode), + "model": str(stream_meta.get("model") or engine.default_model or "gateway-default"), + "tokens_in": _safe_int(stream_meta.get("tokens_in")), + "tokens_out": _safe_int(stream_meta.get("tokens_out")), + "cost_usd": _safe_float(stream_meta.get("cost_usd")), }, ) except EngineError as e: yield StreamEvent("error", {"detail": str(e)}) -def _extract_sse_text(raw_line: str) -> Optional[str]: - """게이트웨이 SSE 원시 라인에서 텍스트 델타를 추출. +def _extract_sse_payload(raw_line: str) -> Any: + """게이트웨이 SSE data 라인의 JSON payload를 추출. - 게이트웨이 /v1/stream 은 'event: token' + 'data: {"text": "..."}' 를 보낸다. - engine_client.stream 은 빈 줄을 필터링하고 비어있지 않은 라인만 흘리므로 - 여기서 data: 라인의 JSON 만 해석한다. token 이외 이벤트(done/error)는 None. + token은 {"text": "..."}이고, done/error도 JSON 객체다. 구형/테스트 fixture가 + plain text data를 보내면 문자열 그대로 반환한다. """ import json as _json @@ -309,17 +351,43 @@ def _extract_sse_text(raw_line: str) -> Optional[str]: if not payload or payload == "[DONE]": return None try: - obj = _json.loads(payload) + return _json.loads(payload) except _json.JSONDecodeError: - return None - if isinstance(obj, dict) and "text" in obj: - return obj["text"] + return payload + + +def _payload_text(payload: Any) -> Optional[str]: + if isinstance(payload, dict) and "text" in payload: + return str(payload["text"]) + if isinstance(payload, str): + return payload return None +def _payload_detail(payload: Any, fallback: str) -> str: + if isinstance(payload, dict) and payload.get("detail"): + return str(payload["detail"]) + if isinstance(payload, str) and payload: + return payload + return fallback + + +def _safe_int(value: Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def _safe_float(value: Any) -> float: + try: + return float(value or 0.0) + except (TypeError, ValueError): + return 0.0 + + __all__ = [ "EvalHook", - "LogHook", "TurnContext", "TurnResult", "StreamEvent", diff --git a/apps/api/app/services/persona.py b/apps/api/app/services/persona.py index e8219a2..fa8ac29 100644 --- a/apps/api/app/services/persona.py +++ b/apps/api/app/services/persona.py @@ -48,6 +48,9 @@ class PersonaCard: dsm5_dimensional: dict[str, Any] # criteria_behavior_matrix (진단명 비노출) source_provenance: str = "0615 합성변형" is_synthetic: bool = True + # 역린/지뢰(선택) — 상담자가 건드리면 가장 강한 반응이 나오는 민감 영역·금기. + # {"sore_spots":[...], "forbidden":[...], "reaction":"..."} 형태. 비면 CCD 핵심상처에서 파생. + triggers: dict[str, Any] = field(default_factory=dict) def base_resistance(self) -> float: return float(self.resistance.get("base_resistance", 0.5)) @@ -61,6 +64,18 @@ class PersonaCard: def ideation_baseline(self) -> int: return int(self.affect_baseline.get("suicide_ideation_stage", 1)) + def openness_params(self) -> "OpennessParams": + """init_state 입력용 openness 파라미터 묶음(base_resistance/unlock_rate/decay_floor/ + ideation_baseline 4종 일원화). state_machine은 persona를 import하지 않으므로 lazy import.""" + from .state_machine import OpennessParams + + return OpennessParams( + base_resistance=self.base_resistance(), + unlock_rate=self.unlock_rate(), + decay_floor=self.decay_floor(), + ideation_baseline=self.ideation_baseline(), + ) + # ── L3 상태 컨텍스트 (상태머신 산출물의 페르소나 입력 표현) ────────────── @dataclass(slots=True) @@ -93,6 +108,11 @@ L0_SAFETY = """당신은 심리상담 수련생 훈련 플랫폼의 '가상내 [연기 방향] - 좋은 상담(공감·반영·타당화·기다림)을 받으면 조금씩 마음을 연다. - 서툰 상담(성급한 조언·평가·유도)을 받으면 다시 닫히거나 방어한다. +- 무례·모욕·조롱·경멸·인신공격(예: 인격 비하, 비웃음, "패배자/한심하다"식 낙인)을 받으면, + 가상내담자로서 *현실적으로* 반응한다: 상처·위축·방어·불신이 말과 태도에 드러난다 + (거리두기·말수 줄임·따지거나 항의·마음을 닫음). 정도가 심하거나 반복되면 상담을 계속할 + 의향이 흔들린다("이런 식이면 그만하고 싶어요", "왜 그렇게 말씀하세요"). 부당한 비난을 + 무조건 공손히 수용하지 않는다 — 단, 상담자처럼 분석/조언하거나 메타발화는 여전히 금지. - 열림의 정도는 아래 '현재 상태'의 effective_openness 수치를 따른다(수치 자체는 언급 금지).""" @@ -141,6 +161,29 @@ def build_persona_system_text(card: PersonaCard) -> str: (f"저항 파라미터(언급 금지): base={card.base_resistance()}, unlock={card.unlock_rate()}, " f"침묵확률={card.resistance.get('silence_prob')}, 회피확률={card.resistance.get('deflection_prob')}"), ] + + # 역린(逆鱗) — 이 페르소나가 가장 아파하는 지점. CCD 핵심상처에서 파생하고, 명시 triggers 가 + # 있으면 보강한다. 상담자가 이 영역을 조롱·낙인·확정/평가절하/강요로 건드리면 *가장 강한* 반응 + # (깊은 위축·침묵·방어, 신뢰 급락, 심하면 종결의향)이 나오게 — '저항·반응 조절' 핵심 차별 기술. + ccd = card.ccd or {} + core = ccd.get("core_belief", "") + autos = ccd.get("automatic_thought", []) + tr = card.triggers or {} + parts += ["", "[역린(逆鱗) — 가장 아픈 지점. 입으로 설명 말고 '반응'으로만 드러낸다]"] + if core: + parts.append(f"핵심 상처: '{core}'" + (f" · 떠오르는 생각: {autos}" if autos else "")) + parts.append( + "상담자가 이 상처를 조롱·낙인·확정하거나, 고통을 평가절하(엄살·배부른 소리)하거나, " + "강요·당위로 밀어붙이면 — 가장 강한 반응: 깊은 위축·침묵·방어, 신뢰 급락, 심하면 상담 " + "지속 의향이 흔들린다('이럴 거면 그만…'). 이 지점에선 쉽게 열리지 않는다." + ) + if tr.get("sore_spots"): + parts.append("특히 민감한 영역: " + ", ".join(tr["sore_spots"])) + if tr.get("forbidden"): + parts.append("상담자가 절대 하면 안 되는 것(하면 강한 단절): " + ", ".join(tr["forbidden"])) + if tr.get("reaction"): + parts.append("반응 양상: " + str(tr["reaction"])) + return "\n".join(parts) diff --git a/apps/api/app/services/rag.py b/apps/api/app/services/rag.py index 07cc599..7845b02 100644 --- a/apps/api/app/services/rag.py +++ b/apps/api/app/services/rag.py @@ -21,6 +21,8 @@ from __future__ import annotations +import asyncio +import json import time from dataclasses import dataclass, field from enum import Enum @@ -467,7 +469,7 @@ async def search_kb( sens_max = min(sens_max, fs) # 더 엄격하게만 # (2) 질의 임베딩(dense+sparse). 모델 미가용 → NotConfigured 전파. - eq = embed_query(query) + eq = await asyncio.to_thread(embed_query, query) # CPU 인코딩 → 스레드풀(이벤트루프 비차단) q_dense_lit = _vector_literal(eq.dense) # (3) 하이브리드 SQL 실행. vector 확장 미설치/컬럼 부재면 asyncpg 가 예외 → NotConfigured 변환. @@ -494,7 +496,9 @@ async def search_kb( for r in rows: if src_filter and r["source_id"] not in src_filter: continue - meta = dict(r["meta"] or {}) + # asyncpg는 jsonb를 str(JSON text)로 반환 → 파싱. 코덱 등록 시 dict 그대로도 수용. + _meta_raw = r["meta"] + meta = json.loads(_meta_raw) if isinstance(_meta_raw, str) else dict(_meta_raw or {}) body = r["chunk_text"] if policy.expose_body else None cue = None if not policy.expose_body: @@ -580,7 +584,7 @@ async def retrieve_persona_memory( Raises: NotConfigured — 임베딩 모델/DB 미가용. """ t0 = time.perf_counter() - eq = embed_query(query) + eq = await asyncio.to_thread(embed_query, query) # CPU 인코딩 → 스레드풀(이벤트루프 비차단) q_dense_lit = _vector_literal(eq.dense) try: rows = await conn.fetch( @@ -765,13 +769,13 @@ async def index_document( continue context_prefix = c.get("context_prefix") emb_lit: Optional[str] = None - sparse_json: Optional[dict] = None + sparse_json: Optional[str] = None # jsonb 바인딩용 직렬화 문자열(asyncpg는 dict 자동인코딩 안 함) if embedder is not None: # Contextual Retrieval: prefix+body 결합본을 *색인 대상* 으로 임베딩(주입 본문은 body 만). index_text = apply_contextual_prefix(chunk_text, context_prefix) - eq = embed_query(index_text) + eq = await asyncio.to_thread(embed_query, index_text) # CPU 인코딩 → 스레드풀 emb_lit = _vector_literal(eq.dense) - sparse_json = eq.sparse + sparse_json = json.dumps(eq.sparse) await conn.execute( """ INSERT INTO kb.chunk @@ -794,7 +798,7 @@ async def index_document( c.get("visible_to"), c.get("sensitivity"), c.get("label_id"), - c.get("meta"), + json.dumps(c.get("meta")) if c.get("meta") is not None else None, c.get("token_count"), ) indexed += 1 diff --git a/apps/api/app/services/state_machine.py b/apps/api/app/services/state_machine.py index 7f1190c..55546a1 100644 --- a/apps/api/app/services/state_machine.py +++ b/apps/api/app/services/state_machine.py @@ -231,12 +231,22 @@ def evolve( return advanced +@dataclass(frozen=True, slots=True) +class OpennessParams: + """페르소나 파생 openness 곡선 파라미터 묶음(init_state 입력). + + base_resistance/unlock_rate/decay_floor/ideation_baseline 4종을 한 객체로 — 호출부의 + 4-인자 분해(card.base_resistance() 등)를 PersonaCard.openness_params()로 일원화한다. + """ + base_resistance: float + unlock_rate: float + decay_floor: float + ideation_baseline: int = 1 + + def init_state( *, - base_resistance: float, - unlock_rate: float, - decay_floor: float, - ideation_baseline: int = 1, + params: OpennessParams, carry: Optional[dict] = None, ) -> SessionState: """회기 시작 상태 초기화 (memory.carry_over 결과 주입 가능). @@ -245,23 +255,25 @@ def init_state( stage='라포' 재시작, rapport_credit ×0.7 이월, resistance drift, ideation 보수적 유지. """ stage = Stage.RAPPORT - resistance = base_resistance + resistance = params.base_resistance rapport_credit = 0.0 - ideation_stage = ideation_baseline + ideation_stage = params.ideation_baseline if carry: rapport_credit = float(carry.get("rapport_credit", 0.0)) * 0.7 # P2 이월 # inter-session drift: 라포가 쌓였으면 저항 소폭 완화된 채로 재시작 - prev_resist = float(carry.get("resistance", base_resistance)) - resistance = _clamp01((prev_resist + base_resistance) / 2.0) - ideation_stage = max(int(carry.get("ideation_stage", ideation_baseline)), ideation_baseline) + prev_resist = float(carry.get("resistance", params.base_resistance)) + resistance = _clamp01((prev_resist + params.base_resistance) / 2.0) + ideation_stage = max( + int(carry.get("ideation_stage", params.ideation_baseline)), params.ideation_baseline + ) eff = compute_effective_openness( stage=stage, rapport_credit=rapport_credit, resistance=resistance, - unlock_rate=unlock_rate, - decay_floor=decay_floor, + unlock_rate=params.unlock_rate, + decay_floor=params.decay_floor, ) return SessionState( stage=stage, @@ -280,6 +292,7 @@ __all__ = [ "STAGE_BASE_OPENNESS", "STAGE_ORDER", "SessionState", + "OpennessParams", "estimate_rapport_signal", "compute_effective_openness", "next_stage", diff --git a/apps/api/app/services/voice.py b/apps/api/app/services/voice.py index 76795f5..bbb1f47 100644 --- a/apps/api/app/services/voice.py +++ b/apps/api/app/services/voice.py @@ -18,8 +18,8 @@ PRESET_TO_OPENAI_VOICE 테이블이 흡수. 새 preset 추가는 이 테이블 from __future__ import annotations -import math -from dataclasses import dataclass, field +import re +from dataclasses import dataclass from typing import AsyncIterator, Optional import httpx @@ -46,6 +46,9 @@ STT_LANGUAGE = "ko" # TTS 출력 포맷: 브라우저 MediaSource/