vignette/docs/archive/ops/refactor-rag-patches-2026-06-26.md
Yun Chan 778e8526d4 세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터
- 누적 작업트리 커밋: 회기 평가 복구·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
2026-07-02 02:50:36 +09:00

33 KiB
Raw Permalink Blame History

리팩토링 + 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_turnrecall_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 뒤에 오고, 호출부를 다음으로 재작성해야 한다.
    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|GwGenerateReqtier. voice.py math 미참조, orchestrator Awaitable/CallableEvalHook 경유 잔존 확인.

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_idorpatch.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_turnmemory= 만 받는지) 재확인.

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/indexrag.index_document(conn, IndexRequest)실제 입력 형태routes/kb.pyIndexRequestIn / 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_documentkb.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_idkb.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회)

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 바디 예시

{
  "source_id": "theory_pct_v1",
  "doc_uri": "pct/core-concepts.md",
  "version": 1,
  "chunks": [
    {
      "seq": 0,
      "kb_k