vignette/docs/ops/refactor-rag-patches-2026-06-26.md
2026-06-29 08:12:14 +09:00

273 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 리팩토링 + 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