세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터

- 누적 작업트리 커밋: 회기 평가 복구·durable 저장, 라이브 코치 이력/근거, 교수자 학생분석, 음성 비언어 메타, PII 마스킹, 운영 티켓/헬스 등
- 문서: 완료 기록 docs/archive/ 냉동 보관, docs/ 단일 인덱스(docs/README.md)+통합 TODO(docs/TODO.md)로 정리
- 리팩터(행위 보존): Stage enum SSOT(taxonomy 소유·state_machine re-export), store recent/masked_turns 중복 제거, speaker_ko_label 단일 헬퍼, _list_sessions N+1 제거(state/turns 배치 + 턴평가 하이드레이션 배치)
- 검증: 백엔드 pytest 352 passed, _list_sessions E2E chromium-single-run 2 passed
This commit is contained in:
Yun Chan 2026-07-02 02:50:36 +09:00
parent 7c41c3ce79
commit 778e8526d4
108 changed files with 6457 additions and 455 deletions

View file

@ -1,273 +0,0 @@
# 리팩토링 + RAG 활성화 패치 (워크플로우 산출, 2026-06-26)
> 적용 순서·리스크는 아래 'Sequence' 참고. 일부 anchor는 이후 voice.py 수정(speakable_text)으로 stale일 수 있어 적용 전 현재 코드와 대조 필수.
> 현재 상태(2026-06-29): 이 문서는 적용 전 충돌 분석 기록이다. `TurnMemory`는 현재 `persona.py`에 정의되어 `TurnContext.memory`, `prepare_turn(memory=...)`, `build_turn_messages(memory=...)`, REST/SSE/voice/reevaluate 경로에 반영됐다. `ManagedUserUpsertInput`은 auth managed-user create/reactivate 입력 경계로 반영됐고, `ManagedUserMemoryInput`/`ManagedUserPatch`와 역할을 분리한다. `digest_pending`과 LLM digest worker 실행/품질평가/재압축은 여전히 별도 GATE다.
## 적용 순서/리스크 (Sequence)
두 핵심 충돌을 코드로 확정했습니다. orchestrator.py `__all__`(399401: `EvalHook`/`LogHook`)과 sessions.py `prepare_turn`(903·979, 인자 `recall_summary=`/`recent_turns=`)이 실제 겹칩니다. 또한 현재 `prepare_turn` 시그니처(97107)에 `kb_behavior_cues`가 **이미 분리 키워드로 존재**함을 확인했습니다 — 즉 P4(RAG)는 이 구버전 시그니처를 전제로 작성되어 P2와 정면 충돌합니다.
---
# 리팩토링 4종 적용 순서·의존·리스크 분석
분석 대상 4패치:
| 코드 | 패치 | 성격 | risk |
|---|---|---|---|
| **P1** | 데드코드 3종 제거 (LogHook / gateway·client tier / 서버 RMS 힌트) | 순수 삭제 | medium |
| **P2** | 파라미터 객체화 3종 (`OpennessParams` / `TurnMemory` / `ManagedUserUpsertInput`) | 시그니처 리팩토링 | medium |
| **P3** | `turn_runtime.py` 추출 (REST/WS 턴 처리 공용화) | 코드 이동·래퍼 | medium |
| **P4** | `routes/sessions.py` RAG 배선 (회상·KB 행동단서) | 기능 추가 | medium |
---
## 1. 파일 겹침 매트릭스
| 파일 | P1 | P2 | P3 | P4 |
|---|:-:|:-:|:-:|:-:|
| `services/orchestrator.py` | ● | ● | | |
| `routes/sessions.py` | | ● | ● | ● |
| `routes/voice.py` | ● | ● | ● | |
| `services/voice.py` | ● | | | |
| `services/evaluator.py` | ● | ● | | |
| `services/persona.py` / `state_machine.py` | | ● | | |
| `auth_sessions.py` / `routes/admin.py` | | ● | | |
| `engine_client.py` / `engine_gateway/gateway.py` | ● | | | |
| `routes/eval.py` | | ● | | |
| `turn_runtime.py` (신규) | | | ● | |
| `test_session_turn_persistence.py` | ● | ● | | |
| `test_orchestrator_masking.py` / `test_rbac_idor.py` | (●) | ● | | |
가장 뜨거운 파일: **`routes/sessions.py`(P2·P3·P4)**, **`routes/voice.py`(P1·P2·P3)**, **`orchestrator.py`(P1·P2)**.
---
## 2. 충돌·의존 분석 (앵커·시그니처 기준)
### C1. P1 ↔ P2 — orchestrator `__all__` (대칭 앵커 충돌, 경미)
- 두 패치 모두 `__all__`(399401)의 `EvalHook`/`LogHook` 3행 창을 건드린다. P1은 `LogHook` 삭제, P2는 `LogHook` 뒤에 `TurnMemory` 삽입.
- **둘 다 동일 HEAD 기준 병렬 작성**이라, 나중에 적용되는 쪽 1줄을 리베이스해야 한다.
- 그 외에는 거의 직교: P1은 `LogHook 타입/run_turn_generate·run_turn_stream 훅/GenerateRequest·StreamRequest tier`(엔진요청·실행함수), P2는 `TurnContext 필드/prepare_turn 본문`(별개 함수). **같은 함수 안에서 충돌하는 줄은 없음.**
### C2. P2 ↔ P4 — sessions.py `prepare_turn` (구조적 충돌, 치명)
- 현 `prepare_turn``recall_summary/pinned_facts/recent_turns/kb_behavior_cues` **분리 키워드**(97107). P4는 이 시그니처에 맞춰 호출부(903·979)에 `kb_behavior_cues=kb_cues`를 **추가**하도록 작성됨.
- **P2는 이 4개 키워드를 단일 `memory: TurnMemory`로 교체**한다. 따라서 P2 이후엔:
- P4의 `prepare_turn(..., kb_behavior_cues=kb_cues)` 호출은 **TypeError**(해당 키워드 소멸).
- P4의 앵커(`recall_summary=...\nrecent_turns=...`)도 매칭 실패(이미 `memory=...`로 치환됨).
- **결론: P4는 반드시 P2 뒤에 오고, 호출부를 다음으로 재작성해야 한다.**
```python
memory=orchestrator.TurnMemory(
recall_summary=recall.recall_summary,
pinned_facts=recall.pinned_facts,
recent_turns=sess.recent_turns(visible_to="client"),
kb_behavior_cues=kb_cues,
)
```
- 역순(P4 먼저)도 불가: P4가 `kb_behavior_cues` 줄을 끼워 넣으면 P2 앵커가 깨지고, P2의 sessions.py `TurnMemory(...)` 치환문엔 `kb_behavior_cues`가 없어 **RAG 배선이 통째로 누락**된다.
### C3. P2·P3·P4 — sessions.py 동일 파일, **분리 영역**(병존 가능)
- P2 = `init_state`(712)·`prepare_turn`(903·979)
- P3 = `_load_session_or_404`(221)·`_append_session_turn`(241)·`_update_session_state`(254)·완료턴 영속화 블록(928·941·956·1002·1012·1014)
- P4 = 상단 캐시/헬퍼 신설·세션 시작 회상 재조립·`prepare_turn` 호출부·종료 캐시 정리
- P2와 P3는 **서로 다른 함수/줄** → 순서 무관(commutative). P4만 P2의 `prepare_turn`과 충돌(C2).
### C4. P1 ↔ P3 / P2 ↔ P3 — voice.py, **분리 영역**
- P1 = `synthesize_stream` 송신 루프(tts_chunk 제거), P2 = `init_state`/`prepare_turn`, P3 = 완료턴 영속화 블록·`_load_voice_session`·`_append_voice_turn`/`_update_voice_state` 제거. 셋 다 다른 영역 → 병존 가능.
### 하드 제약 요약
1. **P4는 P2 이후**(+호출부 재작성). ← 유일한 강제 순서.
2. P1·P2 중 나중 적용분은 orchestrator `__all__` 1줄 리베이스.
3. P3는 P1·P2와 직교(영역 분리), P2와 commutative.
---
## 3. 권장 적용 순서: **P1 → P2 → P3 → P4**
충돌 최소·안전 우선 근거:
1. **P1 먼저** — 순수 삭제라 가장 안전하고 코드 표면을 줄인다. 독립 파일(`engine_client.py`/`gateway.py`/`services/voice.py`)을 먼저 비워 이후 패치의 변경면을 좁힌다. P2와의 유일 접점은 `__all__` 1줄.
2. **P2 다음** — 시그니처 리팩토링은 호출부 표류 위험이 크므로, 기능 추가(P4) 전에 **인자 형태를 먼저 확정**한다. P4의 선행 의존이라 반드시 P4보다 앞.
- 적용 시 orchestrator `__all__`은 P1이 `LogHook`을 이미 지웠으므로, P2의 삽입 앵커를 `"EvalHook",\n "TurnContext",``"EvalHook",\n "TurnMemory",\n "TurnContext",`로 조정(원안의 `LogHook` 포함 앵커/치환문에서 `LogHook` 제거).
3. **P3** — P2와 영역이 분리되어 순서 자유지만, `prepare_turn` 주변이 P2로 안정된 뒤 영속화/로드 골격을 옮기는 편이 리뷰가 명확. (P2↔P3는 교환 가능 — 일정상 병렬 작업도 가능.)
4. **P4 마지막** — P2의 `memory=TurnMemory` 신시그니처에 맞춰 **재작성한 버전**으로 적용. RAG는 graceful degradation이라 기능적으로 마지막에 얹어도 회귀 표면이 가장 작다.
> P4를 원안(JSON) 그대로 적용하면 **반드시 깨진다**. 이 분석의 단일 최대 리스크이며, P4 호출부 2곳(submit_turn·stream_turn)을 `TurnMemory(... kb_behavior_cues=kb_cues)`로 바꾸는 재작성이 선행되어야 한다.
---
## 4. 단계별 테스트 (작업 디렉터리 `apps/api`)
### P1 후
```
python -m pytest app/ engine_gateway/ -q
cd ../web && npx playwright test e2e/voice-success.spec.ts
```
- 중점: `test_voice_service`(synthesize_stream/fallback), `test_session_turn_persistence`(TTSChunk), `test_orchestrator_masking`(tier 키 제거 무영향), `test_gateway_model`, `test_rbac_idor`.
- 사후 grep(잔존 0 확인): `log_hook`·`LogHook`·`estimate_chunk_rms`·`ck.rms`/`ck.seq`/`tts_chunk`·`GenerateRequest|StreamRequest|GwGenerateReq``tier`. voice.py `math` 미참조, orchestrator `Awaitable`/`Callable``EvalHook` 경유 잔존 확인.
### P2 후
```
python -c "from app.services import persona, orchestrator, state_machine; from app import auth_sessions; from app.routes import admin; from app.routes import eval as _e"
python -m pytest app/test_state_machine_resistance.py app/test_orchestrator_masking.py app/test_session_turn_persistence.py app/test_rbac_idor.py -q
python -m pytest app -q && python -m pytest engine_gateway -q
```
- 중점: 순환참조 부재(`persona→state_machine` 단방향, `orchestrator→persona(TurnMemory)`, `eval→orchestrator(TurnMemory)`), PII 마스킹 불변(`memory=TurnMemory`로 받아도 recall/pinned/recent 마스킹 유지), 저항 곡선 불변, `ctx.memory.recent_turns`가 evaluator-only 비공개 발화 제외, auth/admin 경로(`ManagedUserUpsertInput`) 회귀.
- 잔존 참조 점검: `ctx.recall_summary`/`ctx.pinned_facts`/`ctx.recent_turns`/`ctx.kb_behavior_cues` 가 evaluator.py·eval.py·테스트에 남아있지 않은지(AttributeError 차단).
### P3 후
```
python -c "import app.turn_runtime, app.routes.sessions, app.routes.voice"
python -m pytest app/test_session_turn_persistence.py app/test_rbac_idor.py app/test_voice_ws.py -q
python -m pytest app/ engine_gateway/ -q
```
- 중점: generate/stream 텔레메트리 영속(2턴, client provider/tokens/cost), 엔진 실패 시 `sess.turns==[]`(학습자 전용 발화 미생성), voice 오디오 메타(audio_ref/silence_ms/speech_rate/barge_in), IDOR 403 `does not belong`, WS 컨트랙트(ready/state/pong) 순서·`unknown session {id}` 문구, `turn_runtime`이 routes 미import(순환 없음). `test_runtime_policy` 무영향 확인.
- 주의: `test_rbac_idor``patch.object(sessions,'runtime_fallback_allowed')`는 로드 로직 이동 후 no-op이지만 dev 기본값(True)으로 통과 유지 — 엄밀화하려면 patch 대상을 `turn_runtime.runtime_fallback_allowed`로 바꾸는 선택적 후속.
### P4 후 (재작성본 기준)
```
python -c "import app.main"
python -m pytest app -q && python -m pytest engine_gateway -q
```
- 중점: DB 풀 미초기화 상태에서 `_retrieve_kb_behavior_cues(P1)`·`_build_start_recall(...)`이 예외 없이 `[]`·빈 `RecallContext` 반환(get_pool RuntimeError 경로, graceful). KB 인덱싱+임베더 환경에서 `ctx.messages` L2에 행동단서 블록 포함, 미설치 환경에선 빈 cues로 200 유지. P2 신시그니처 정합(`prepare_turn``memory=` 만 받는지) 재확인.
---
## 5. 리스크 종합
- **최대 리스크 — P4 ↔ P2 시그니처 불일치(C2):** P4 원안은 구버전 `prepare_turn`(분리 키워드)에 묶여 있어 P2 적용 환경에서 **TypeError + 앵커 미스 + RAG 배선 누락**을 유발. P4는 반드시 P2 뒤에서 `memory=TurnMemory(... kb_behavior_cues=kb_cues)`로 재작성. 이것이 순서를 강제하는 유일·결정적 의존이다.
- **경미 리스크 — orchestrator `__all__`(C1):** P1·P2 병렬 작성으로 동일 3행 창 충돌. 나중 적용분 1줄 리베이스로 해소.
- **분산 리스크 — sessions.py/voice.py 다중 패치(C3·C4):** 영역이 분리되어 병존 가능하나, 한 파일에 3패치가 누적되므로 각 단계 후 `import` sanity + 해당 라우트 회귀를 반드시 통과시킨 뒤 다음 단계로.
- **공통 완화책:** 각 패치가 자체 테스트 편집을 포함하므로 단계별 green을 게이트로 사용. 단계 사이 grep 기반 잔존 식별자 0 검증으로 "부분 적용" 누수를 차단.
**한 줄 요약:** `P1(삭제) → P2(시그니처 확정) → P3(추출, P2와 교환 가능) → P4(RAG, P2 신시그니처로 재작성 필수)`. 강제 제약은 **P4-after-P2**, 나머지는 영역 분리로 병존 가능하며, orchestrator `__all__` 1줄과 sessions.py `prepare_turn` 호출부 재작성이 손대야 할 두 접합부다.
## 리팩토링 패치
### 데드코드 3종 제거: (1) orchestrator.py LogHook 타입·log_hook 파라미터·분기·export, (2) engine_gateway/gateway.py tier dead-contract(클라이언트 측 tier 동반 제거), (3) services/voice.py·routes/voice.py 서버 RMS 힌트(tts_chunk.rms/seq + estimate_chunk_rms). (risk=medium)
- 근거: 세 제거 모두 실제 grep으로 참조 0건을 입증했다. (1) LogHook/log_hook: 본 식별자는 orchestrator.py 안에서만 등장하고, run_turn_generate/run_turn_stream의 모든 호출부(sessions.py:916/995, voice.py:307, 테스트들)는 eval_hook 또는 위치인자만 전달하므로 주입 caller가 0 — 순수 데드. run_turn_stream은 키워드 전용 인자가 사라지므로 `*,` 마커도 함께 제거해야 SyntaxError를 막는다(위치 호출부라 영향 없음). (2) gateway tier: 게이트웨이는 req.tier를 어디서도 읽지 않고 모델 선택은 model override→세션모델→DEFAULT_MODEL뿐이다. 게이트웨이가 단일모델 상주 claude -p 엔진(docstring)이라 tier→model 배선은 설계상 귀속처가 없으므로 '배선'이 아니라 '제거'가 옳다. 클라이언트 측 tier는 값이 어떤 동작에도 영향하지 않는 write-only 데드여서 동반 제거하되, 어떤 테스트도 tier를 참조하지 않아 회귀 위험이 낮다(pydantic 기본 extra=ignore라 부분 잔존 시에도 422 없음). (3) 서버 RMS 힌트: 프론트 Session.tsx onmessage에 tts_chunk 분기가 없어 메시지가 통째로 무시되고, 립싱크 진폭은 useAvatarMotion.ts가 Web Audio로 자체 산출한다(voice.py 주석도 이를 명시). 따라서 rms/seq + estimate_chunk_rms(불필요한 바이트 루프 비용)는 dead이며 제거로 핫패스 JSON 직렬화/전송과 CPU를 절감한다. risk=medium은 (3)이 WebSocket 프로토콜과 e2e/단위 어서션을 건드리기 때문이며, 해당 테스트 편집을 패치에 포함해 회귀를 차단했다.
- 검증: 1) 백엔드 단위테스트(레포 루트): `cd apps/api && python -m pytest app/ engine_gateway/ -q` (또는 `python -m unittest discover -s apps/api`). 중점 파일: test_orchestrator_masking.py(마스킹 payload — tier 키 제거가 어서션에 영향 없음), test_session_turn_persistence.py(TTSChunk·run_turn_stream 텔레메트리), test_voice_service.py(synthesize_stream/fallback), test_gateway_model.py(GwGenerateReq는 tier 미사용 — 통과), test_rbac_idor.py(run_turn_generate 패치). 위 test 편집 3건 반영 시 전부 green 기대.\n2) 프론트 e2e: `cd apps/web && npx playwright test e2e/voice-success.spec.ts` — tts_chunk 기대 제거 후 통과, binaryChunks>0 유지 확인. mic UI 시나리오(@single-run)도 회귀 없음 확인.\n3) 사후 grep(read-only)로 잔존 0 확인: `log_hook`, `LogHook`, `estimate_chunk_rms`, voice의 `ck.rms`/`ck.seq`/`tts_chunk`, GenerateRequest/StreamRequest/GwGenerateReq의 `tier`. 모두 0건이어야 함.\n4) import 정합: voice.py에서 `math` 미참조 확인, orchestrator.py에서 `Awaitable`/`Callable`는 EvalHook가 계속 사용함 확인.
- affected_callers: ['apps/api/app/services/orchestrator.py:197 (GenerateRequest tier 인자 제거 — 본 패치 포함)', 'apps/api/app/services/orchestrator.py:272 (StreamRequest tier 인자 제거 — 본 패치 포함)', 'apps/api/app/services/evaluator.py:637 (fast GenerateRequest tier 제거 — 본 패치 포함)', 'apps/api/app/services/evaluator.py:694 (deep GenerateRequest tier 제거 — 본 패치 포함)', 'apps/api/app/routes/voice.py:383 (ck.rms/ck.seq 사용처 — tts_chunk 송신 제거로 함께 정리)', 'LogHook 시그니처 변경 호출부(갱신 불필요, 확인만): apps/api/app/routes/sessions.py:916, apps/api/app/routes/sessions.py:995, apps/api/app/routes/voice.py:307']
- edits: 28건
### 파라미터 객체화 3종 리팩토링 (모두 읽기전용 분석 → 적용 가능한 정확 패치 스펙):
(1) state_machine.init_state(base_resistance/unlock_rate/decay_floor/ideation_baseline) → frozen dataclass OpennessParams 단일 인자 + PersonaCard.openness_params() 팩토리.
(2) orchestrator.prepare_turn / persona.build_turn_messages 의 recall_summary/pinned_facts/recent_turns/kb_behavior_cues → 공유 TurnMemory dataclass (persona.py 정의, orchestrator 재노출). TurnContext 도 memory:TurnMemory 단일 필드로.
(3) auth_sessions.upsert_managed_user 의 create/reactivate 키워드 묶음(email/display_name/role/admin_access/cohort_ids/user_id/external_id/affiliation/account_status/reactivate) → dataclass ManagedUserUpsertInput. `_memory_upsert_managed_user`는 DB→메모리 fallback/store 동기화용 `ManagedUserMemoryInput`, update 계열은 `ManagedUserPatch`로 분리 유지. (risk=medium)
- 근거: 세 리팩토링 모두 "동작 보존 + 인자 묶음"으로 부수효과가 없다. (1) OpennessParams 는 init_state 의 4개 키워드를 frozen dataclass 로 그대로 옮기며 계산식(compute_effective_openness/carry-over 로직)은 한 글자도 바꾸지 않았다. PersonaCard.openness_params() 는 기존 base_resistance()/unlock_rate()/decay_floor()/ideation_baseline() 접근자를 그대로 호출하는 얇은 팩토리라 값이 동일하다. persona→state_machine 은 단방향 import(state_machine 은 어떤 앱 모듈도 import 하지 않음)라 순환참조가 없다. (2) TurnMemory 는 prepare_turn 입력 4종 + TurnContext 저장 + build_turn_messages 입력을 하나로 통일해 "마스킹 1회 → 동일 객체 공유" 흐름을 명확히 한다. 마스킹은 prepare_turn 본문에서 그대로 수행되므로 PII 불변식이 유지된다. TurnContext 필드 축소로 ctx.recent_turns 를 읽던 evaluator.py·eval.py·test_rbac_idor.py 3곳을 ctx.memory.recent_turns 로 동시 갱신해 누락이 없다. (3) ManagedUserUpsertInput(dataclass) 은 upsert_managed_user 의 create/reactivate 입력 묶음을 제거한다. DB→메모리 fallback/store 동기화는 `ManagedUserMemoryInput`, `/users/me`·온보딩·관리자 수정은 `ManagedUserPatch`가 계속 소유하므로 경계가 섞이지 않는다. 모든 호출부(create_session, admin.create_user, 직접 auth 회귀 호출)를 ManagedUserUpsertInput 생성으로 교체했고 외부 엔드포인트/공개 함수(create_session) 시그니처는 불변이라 라우트 회귀가 없다. 가치: 4파라미터 init_state 5호출부, 4파라미터 prepare_turn 7호출부, upsert keyword bag 3호출부의 시그니처 표류 위험을 타입 객체 하나로 수렴시켜 향후 파라미터 추가 시 호출부 일괄 누락 버그를 구조적으로 차단한다. medium 위험인 이유는 프로덕션 라우트(sessions/voice/admin)·평가기·인증 경로를 동시에 건드리지만, 모두 기계적 치환이고 focused tests가 경로를 커버한다.
- 검증: 작업 디렉터리 D:\workspace\vignette\apps\api 기준.
1) 임포트/순환참조 sanity: `python -c "from app.services import persona, orchestrator, state_machine; from app import auth_sessions; from app.routes import admin; from app.routes import eval as _e"` (persona→state_machine 단방향 import, orchestrator→persona(TurnMemory), eval→orchestrator(TurnMemory) 가 깨지지 않는지 확인).
2) 핵심 회귀 4종: `python -m pytest app/test_state_machine_resistance.py app/test_orchestrator_masking.py app/test_session_turn_persistence.py app/test_rbac_idor.py -q` — 특히 (a) test_orchestrator_masking 의 PII 마스킹(prepare_turn 이 memory=TurnMemory 로 받아도 recall/pinned/recent 가 여전히 마스킹되는지), (b) test_state_machine_resistance 의 공감/조언 곡선 불변, (c) test_rbac_idor 의 ctx.memory.recent_turns 가 evaluator-only 비공개 발화를 제외하는지.
3) auth/admin/login focused: `python -m pytest app/test_auth_providers.py app/test_admin_ops.py app/test_rbac_idor.py app/test_runtime_policy.py -q` (create_session→upsert_managed_user(ManagedUserUpsertInput), admin create_user 엔드포인트 포함 회귀 확인).
4) 게이트웨이 영향 없음 확인: `python -m pytest engine_gateway -q` (7개).
모두 green 이어야 하며, 시그니처 변경분(init_state 단일 인자, prepare_turn/build_turn_messages memory 인자, upsert_managed_user 의 ManagedUserUpsertInput)이 호출부와 정합하는지 import 에러/AttributeError(ctx.recall_summary 등 잔존 참조) 부재로 검증.
- affected_callers: ['init_state — apps/api/app/session_persistence.py:101', 'init_state — apps/api/app/routes/sessions.py:712', 'init_state — apps/api/app/routes/voice.py:498', 'init_state — apps/api/app/test_orchestrator_masking.py:32', 'init_state — apps/api/app/test_state_machine_resistance.py:29', 'prepare_turn — apps/api/app/routes/sessions.py:903', 'prepare_turn — apps/api/app/routes/sessions.py:979', 'prepare_turn — apps/api/app/routes/voice.py:293', 'prepare_turn — apps/api/app/test_orchestrator_masking.py:41', 'prepare_turn — apps/api/app/test_orchestrator_masking.py:132', 'prepare_turn — apps/api/app/test_session_turn_persistence.py:178', 'prepare_turn — apps/api/app/test_session_turn_persistence.py:213', 'build_turn_messages — apps/api/app/services/orchestrator.py:146 (유일 호출부)', 'TurnContext.memory(생성) — apps/api/app/services/orchestrator.py:115', 'TurnContext.memory(생성) — apps/api/app/routes/eval.py:176 (+ import 171)', 'TurnContext.memory(읽기 ctx.recent_turns) — apps/api/app/services/evaluator.py:381', 'TurnContext.memory(읽기 ctx.recent_turns) — apps/api/app/test_rbac_idor.py:285', 'upsert_managed_user — apps/api/app/auth_sessions.py:675 (create_session)', 'upsert_managed_user — apps/api/app/routes/admin.py:493 (+ import 12-18)', '_memory_upsert_managed_user — apps/api/app/auth_sessions.py:452 (upsert DB 성공)', '_memory_upsert_managed_user — apps/api/app/auth_sessions.py:466 (upsert fallback)', '_memory_upsert_managed_user — apps/api/app/auth_sessions.py:548 (update_managed_user)', 'PersonaCard.openness_params 신규 — apps/api/app/services/persona.py (P1/P2/P3 시드는 변경 불필요)']
- edits: 44건
### REST(routes/sessions.py submit/stream)와 WS(routes/voice.py)의 턴 처리 중복 제거. 새 공용 모듈 app/turn_runtime.py 추출: stage_label(라벨 일원화) + load_owned_session(로드/오너십/종료 검증 코어) + append_completed_turn/update_session_state(영속화 폴백) + record_completed_turn(상담자 발화+내담자 응답 append+상태 갱신). 각 라우트는 자기 표현(HTTP 예외 / WS 에러문구)으로 매핑하는 얇은 래퍼만 유지. (risk=medium)
- 근거: REST(sessions submit/stream)와 WS(voice)가 '세션 로드->오너십->완료 턴 영속화->상태 갱신' 절차와 폴백 로직, stage 라벨 변환을 각각 복제하고 있었다(append/update 헬퍼 4종, 로드 헬퍼 2종이 거의 동일). 이를 app/turn_runtime.py 공용 모듈로 추출하면 영속/폴백 정책 변경이 한곳에서 끝난다. 안전성 근거: (1) Stage(str, Enum) 의 .value 가 이미 한글이라 stage_label 과 .value 는 byte-identical -> stage 일원화는 순수 리팩토링(동작 무변). (2) 각 라우트의 외부 계약(HTTP 404/403/409 와 detail 문구, WS 'unknown session {id}' 등)은 얇은 매핑 래퍼로 그대로 보존. (3) record_completed_turn 은 'client_reply 있을 때만 내담자 턴 기록' 규칙과 counselor->client 순서, 폴백 context 문자열까지 동일 재현하여 실패 턴이 학습자 전용 발화를 남기지 않는 기존 보호를 유지. (4) 라우트는 여전히 orchestrator.run_turn_generate/run_turn_stream 을 직접 호출하므로 테스트의 sessions.orchestrator/voice_routes.orchestrator patch 가 그대로 적용된다. (5) turn_runtime 은 routes 를 import 하지 않아 순환 없음. 참고: routes/eval.py 에도 별도의 _load_session_or_404 사본이 있으나(다른 책임) 이번 스코프 밖으로 두어 변경면을 한정했다 — 후속 통합 후보.
- 검증: 1) 회귀 핵심: cd apps/api && python -m pytest app/test_session_turn_persistence.py app/test_rbac_idor.py app/test_voice_ws.py -q (현재 baseline 20 passed 확인됨). 검증 포인트 — generate/stream 텔레메트리 영속(2턴, client llm_provider/tokens/cost), 엔진 실패 시 sess.turns==[](학습자 전용 발화 미생성), voice 오디오 메타(audio_ref/silence_ms/speech_rate/barge_in) 부착, IDOR 403 'does not belong', WS 컨트랙트(ready/state/pong) 순서. 2) 전체 백엔드: python -m pytest app/ engine_gateway/ -q (단위 84개 무회귀). 3) 정적: python -c "import app.turn_runtime, app.routes.sessions, app.routes.voice" 로 import/순환 점검. 4) 폴백 정책: test_runtime_policy.py 무영향 확인. 주: test_rbac_idor 의 patch.object(sessions,'runtime_fallback_allowed',True) 는 로드 로직 이동 후 no-op 가 되지만 환경 기본값 environment=='dev' 라 실함수가 True 를 반환해 동일 통과(테스트 수정 불필요). 의미 정합성을 더 엄격히 하려면 해당 patch 대상을 turn_runtime.runtime_fallback_allowed 로 바꾸는 선택적 후속 편집 가능.
- affected_callers: ['apps/api/app/routes/sessions.py:928 submit_turn — _append_session_turn(counselor) 제거, record_completed_turn 으로 대체(같은 패치 포함)', 'apps/api/app/routes/sessions.py:941 submit_turn — _append_session_turn(client) 제거(대체됨)', 'apps/api/app/routes/sessions.py:956 submit_turn — _update_session_state 제거(대체됨)', 'apps/api/app/routes/sessions.py:1002 stream_turn — _append_session_turn->append_completed_turn(context 인자 추가)', 'apps/api/app/routes/sessions.py:1012 stream_turn — _update_session_state->update_session_state(context 인자 추가)', 'apps/api/app/routes/sessions.py:1014 stream_turn — _append_session_turn(client)->append_completed_turn(context 인자 추가)', 'apps/api/app/routes/sessions.py:688,764,900,976,1051 — _load_session_or_404 호출부: 시그니처/예외 동일, 무수정', 'apps/api/app/routes/voice.py:320,337,352 _run_turn_and_speak — _append_voice_turn/_update_voice_state 제거, record_completed_turn 으로 대체', 'apps/api/app/routes/voice.py:286 _run_turn_and_speak, :477 _bind_session — _load_voice_session 호출부: 시그니처 동일, 무수정', "apps/api/app/test_rbac_idor.py:149,191,238,302 patch.object(sessions,'runtime_fallback_allowed') — dev 기본값 덕에 통과 유지, 갱신 불필요(선택적)"]
- edits: 11건
## RAG 활성화 설계
### apps/api/app/routes/sessions.py — 라이브 턴/회기 파이프라인에 rag.py 배선(회상·KB 행동단서), graceful degradation 포함 (risk=medium)
- 현재 sessions.py는 `memory.build_recall_context()`를 인자 없이 호출(항상 빈 회상)하고, prepare_turn에 kb_behavior_cues를 안 넘긴다. rag.py(search_kb/retrieve_persona_memory)는 routes/kb.py에서만 쓰이고 라이브 루프엔 미배선이다. orchestrator.prepare_turn → persona.build_turn_messages는 이미 recall_summary/pinned_facts/kb_behavior_cues를 L2로 주입하도록 end-to-end로 뚫려 있어, 라우트의 호출부만 채우면 활성화된다(시그니처 변경 0).
핵심 설계 결정:
1) KB 행동단서는 페르소나 증상/호소를 질의로 search_kb(role=CLIENT)를 회기 시작 1회 산출해 캐시하고 매 턴 prepare_turn에 전달. CLIENT 정책(expose_body=False)이 본문을 잘라 behavior_cue만 돌려주므로 R4/M6(CCD 본문 비노출) 자동 보존. 질의 텍스트는 임베더/tsquery 입력일 뿐 프롬프트에 안 들어가므로 dsm5 차원 키를 질의로 써도 메타 누설 없음. L2가 cache=True라 회기 1회 로드가 캐시 친화(persona.py 주석과 정합).
2) 회상은 sess 생성 후 case_id로 app.session_summary(직전 요약)+retrieve_persona_memory(episodic)를 합쳐 build_recall_context로 조립. init_state의 carry는 기존대로 유지(회귀 0; carry는 현재 session_no=1 고정이라 dormant).
3) 모든 RAG 경로는 db.get_pool()(미초기화 RuntimeError)·rag.NotConfigured·DB 오류를 삼켜 빈 값 반환 → 상담 루프 비차단. routes/kb.py가 같은 예외를 503으로 올리는 것과 달리 라이브 루프는 graceful degradation이 계약(요구사항 3). 세 RAG 쿼리를 각각 별도 acquire로 분리해, 한 쿼리의 트랜잭션 abort가 다른 쿼리를 오염시키지 않게 함.
주의(검증 필요 가정): (a) M2 다회기 연속성이 실제로 작동하려면 동일 (learner,persona) 세션들이 같은 case_id를 공유해야 하는데, create_session은 세션마다 새 runtime_case_id를 만든다(InProcSession.case_id=runtime_case_id). 따라서 현재는 case 스코프 회상이 '정확하지만 비어 있음'(첫 회기)으로 degrade — 케이스 식별자 통합은 본 과제 범위 밖. (b) app.turn_embedding writer가 아직 없어 episodic recall은 dormant(빈 결과). (c) _hydrate의 app.turns.id(uuid PK) 컬럼명은 가정 — 틀리면 except로 []. (d) pinned_facts는 app.session_summary에 컬럼이 없어 현재 []이며, 압축 파이프라인이 pinned_facts를 적재하면 prev_summary 경유로 자동 채워지도록 forward-compatible하게 배선.
- edits: 7건
- 검증: 1) 회귀: `cd apps/api && python -m pytest app/test_orchestrator_masking.py app/test_session_turn_persistence.py -q` — kb_behavior_cues는 기본값 None인 추가 kwarg라 직접 prepare_turn을 부르는 기존 테스트는 영향 없음(통과 유지). 2) 전체: `cd apps/api && python -m pytest app -q` (77개) + `python -m pytest engine_gateway -q`. 3) graceful 단위 검증(신규 권장): DB 풀 미초기화 상태에서 `_retrieve_kb_behavior_cues(P1)`/`_build_start_recall(case_id=..., card=P1)`이 예외 없이 각각 []·빈 RecallContext를 반환하는지(monkeypatch 불필요 — get_pool RuntimeError 경로). 4) 통합 스모크: KB 인덱싱(POST /kb/index) 후 임베더 설치 환경에서 회기 시작 → submit_turn 응답이 200이고, persona.build_turn_messages가 L2에 '증상 행동단서' 블록을 포함하는지(ctx.messages 검사) — 미설치 환경에선 빈 cues로 200 유지. 5) import 무결성: `python -c "import app.main"`(rag 무거운 의존성은 함수 내부 import라 통과해야 함).
### KB seed 콘텐츠
All schema and policy details confirmed. Here is the seed KB design.
---
# Vignette KB 시드 콘텐츠 설계 — RAG 회수용 (스타터)
## 0. 사실 확인 (코드에서 검증한 계약)
`POST /kb/index``rag.index_document(conn, IndexRequest)` 의 **실제 입력 형태**는 `routes/kb.py``IndexRequestIn` / `IndexChunkIn`이다.
```
IndexRequestIn = { source_id, doc_uri, version=1, content_hash?, chunks: IndexChunkIn[] }
IndexChunkIn = { seq:int(필수), chunk_text:str(필수,≥1), heading_path?, context_prefix?,
kb_kind?, visible_to?:str[], sensitivity?:0..3, label_id?, meta?, token_count? }
```
`index_document``kb.chunk`에 적재하는 컬럼 매핑 (rag.py L775~799):
- `visible_to` 미지정 → `COALESCE($10, ARRAY['client','counselor','evaluator'])` (전체 공개가 기본 — **반드시 명시**해서 정보비대칭을 강제해야 함)
- `sensitivity` 미지정 → `COALESCE($11, 0)` (0=공개)
- `kb_kind` 미지정 → `'theory'`
- `context_prefix` → 색인 시 `prefix + body` 결합본을 임베딩/BM25 대상으로 쓰되, 런타임 LLM 주입 본문(`chunk_text`)에는 미포함 (Contextual Retrieval)
### 정책 4-튜플이 회수에 거는 제약 (rag.py POLICIES) — 시드 설계의 핵심 제약
| role | kinds 화이트리스트 | sens_max | expose_body | 회수 시 반환 |
|---|---|---|---|---|
| **client**(내담자) | diagnostic, theory, technique | **1** | False | `meta.behavior_cue` 우선, 없으면 본문 절단 |
| **counselor**(상담사 보조) | theory, technique, microskill, ko_context | **0** | True | 본문+예시 |
| **evaluator**(평가) | (전체) | 2 | True | 본문 + `label_id` + `meta.bias_weight` |
> **결론**: 시드의 `visible_to` + `sensitivity` + `kb_kind` 3개 컬럼이 "누가 무엇을 회수하느냐"를 DB WHERE로 강제한다. 시드를 잘못 태깅하면(예: 이론 본문에 `client` 포함) 정보비대칭이 깨진다.
### 선행 조건: `kb.source` 등록 (index_document은 source를 만들지 않음)
`kb.document.source_id``kb.source(source_id)` FK다. `index_document`은 source를 생성하지 않으므로 **인덱싱 전에 source 행이 존재해야 한다**. license_class C/D(DSM verbatim·미성년 파생)는 `external_llm_ok=false`로 국내 라우팅 강제 — **아래 시드는 전부 합성/환언(A)이라 verbatim 저작권 위험 없음**.
---
## 1. `kb.source` 시드 (SQL 또는 부트스트랩 — index 전 1회)
```sql
INSERT INTO kb.source (source_id, title, kb_kind, license_class, citation, external_llm_ok) VALUES
('theory_pct_v1', '인간중심상담 핵심개념(환언 스타터)', 'theory', 'A',
'임상팀 검수 전 개발 시드 — Rogers PCT 환언, verbatim 아님', TRUE),
('theory_cbt_v1', 'CBT 핵심개념(환언 스타터)', 'theory', 'A',
'임상팀 검수 전 개발 시드 — Beck/Martell 환언', TRUE),
('technique_cbt_v1','CBT 기법 절차(환언 스타터)', 'technique', 'A',
'임상팀 검수 전 개발 시드 — 절차 환언', TRUE),
('persona_cues_v1', '시드 페르소나 행동단서(P1~P3)', 'diagnostic', 'A',
'합성 페르소나 행동 표현 — DSM verbatim 아님, 본문 비노출', TRUE);
```
> `kb.source.kb_kind`는 source당 1개(CHECK 제약)라 kb_kind별로 source를 분리했다. 행동단서는 증상 표현이므로 `diagnostic`.
---
## 2. 이론 지식 청크 — 상담사/평가 AI 회수용 (`sensitivity=0`, `visible_to=['counselor','evaluator']`)
설계 원칙:
- `visible_to`에서 **client 제외** → 내담자 AI가 이론을 "학습"해 메타발화하는 누설 차단(R4).
- `sensitivity=0` 필수 → counselor 정책이 `sens_max=0`이라 0만 회수.
- `context_prefix`로 doc 맥락 1문장 부여(동음이의·짧은 청크 회수 정확도↑).
### 2-A. 인간중심(PCT) — `POST /kb/index` 바디 예시
```json
{
"source_id": "theory_pct_v1",
"doc_uri": "pct/core-concepts.md",
"version": 1,
"chunks": [
{
"seq": 0,
"kb_k