{ "patches": [ { "target": "데드코드 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).", "edits": [ { "file": "apps/api/app/services/orchestrator.py", "anchor_before": "EvalHook = Callable[[\"TurnContext\", str], Awaitable[Optional[dict]]]\n# 로깅 훅: TurnContext + 내담자응답 → None (turns insert/임베딩은 주입측 책임)\nLogHook = Callable[[\"TurnContext\", str], Awaitable[None]]", "replacement": "EvalHook = Callable[[\"TurnContext\", str], Awaitable[Optional[dict]]]", "note": "LogHook 타입 정의 제거. caller 0건(grep: log_hook/LogHook은 본 파일에만 존재). Awaitable/Callable 임포트는 EvalHook가 계속 사용하므로 유지." }, { "file": "apps/api/app/services/orchestrator.py", "anchor_before": " eval_hook: Optional[EvalHook] = None,\n log_hook: Optional[LogHook] = None,\n) -> TurnResult:", "replacement": " eval_hook: Optional[EvalHook] = None,\n) -> TurnResult:", "note": "run_turn_generate 시그니처에서 log_hook 키워드 인자 제거. eval_hook는 유지되어 `*,`도 유지됨." }, { "file": "apps/api/app/services/orchestrator.py", "anchor_before": " eval_hook/log_hook 은 Features 가 주입(없으면 생략). 엔진 장애는 EngineError 전파.", "replacement": " eval_hook 은 Features 가 주입(없으면 생략). 엔진 장애는 EngineError 전파.", "note": "docstring 갱신." }, { "file": "apps/api/app/services/orchestrator.py", "anchor_before": " # 8) 로깅 훅(주입형) — turns insert + 임베딩\n if log_hook is not None:\n try:\n await log_hook(ctx, reply)\n except Exception:\n pass\n\n return TurnResult(", "replacement": " return TurnResult(", "note": "run_turn_generate의 8) 로깅 훅 분기 제거." }, { "file": "apps/api/app/services/orchestrator.py", "anchor_before": "async def run_turn_stream(\n ctx: TurnContext,\n engine: EngineClient,\n *,\n log_hook: Optional[LogHook] = None,\n) -> AsyncIterator[StreamEvent]:", "replacement": "async def run_turn_stream(\n ctx: TurnContext,\n engine: EngineClient,\n) -> AsyncIterator[StreamEvent]:", "note": "run_turn_stream에서 log_hook 제거. 남는 키워드 인자가 없으므로 `*,` 마커도 함께 제거해야 SyntaxError 방지. 호출부는 모두 (ctx, engine) 위치인자라 영향 없음." }, { "file": "apps/api/app/services/orchestrator.py", "anchor_before": " 재생성 신호). 토큰 단위 완벽 차단은 후속(현재는 누적 스캔).\n 로깅 훅은 done 직전 최종 텍스트로 1회 호출.\n \"\"\"", "replacement": " 재생성 신호). 토큰 단위 완벽 차단은 후속(현재는 누적 스캔).\n \"\"\"", "note": "run_turn_stream docstring의 로깅 훅 문장 제거." }, { "file": "apps/api/app/services/orchestrator.py", "anchor_before": " # 8) 로깅 훅 — 최종 텍스트\n if log_hook is not None:\n try:\n await log_hook(ctx, accumulated)\n except Exception:\n pass\n\n yield StreamEvent(", "replacement": " yield StreamEvent(", "note": "run_turn_stream의 8) 로깅 훅 분기 제거." }, { "file": "apps/api/app/services/orchestrator.py", "anchor_before": " \"EvalHook\",\n \"LogHook\",\n \"TurnContext\",", "replacement": " \"EvalHook\",\n \"TurnContext\",", "note": "__all__에서 LogHook export 제거." }, { "file": "apps/api/engine_gateway/gateway.py", "anchor_before": " messages: list[GwMessage]\n tier: Literal[\"client\", \"feedback\", \"fast\"] = \"client\"\n model: Optional[str] = None", "replacement": " messages: list[GwMessage]\n model: Optional[str] = None", "note": "GwGenerateReq.tier 제거. gateway 전체에서 req.tier를 읽는 코드 0건(grep). Literal은 AIRole/GwMessage.role이 계속 사용하므로 임포트 유지. pydantic 기본 extra='ignore'라 잔류 클라이언트가 tier를 보내도 422 없음(이 패치에선 클라이언트도 동시 제거)." }, { "file": "apps/api/app/engine_client.py", "anchor_before": " messages: list[EngineMessage]\n # tier 라우팅 힌트: client=Sonnet/Solar, evaluator=Opus, fast=Haiku (마스터플랜 §5)\n tier: Literal[\"client\", \"feedback\", \"fast\"] = \"client\"\n model: Optional[str] = None # 명시 시 게이트웨이 override", "replacement": " messages: list[EngineMessage]\n model: Optional[str] = None # 명시 시 게이트웨이 override", "note": "GenerateRequest.tier 제거(StreamRequest도 상속으로 자동 반영). 게이트웨이가 무시하므로 write-only 데드 필드. Literal은 AIRole/EngineMessage.role이 계속 사용하므로 임포트 유지." }, { "file": "apps/api/app/services/orchestrator.py", "anchor_before": " req = GenerateRequest(\n ai_role=\"client\",\n tier=\"client\",\n messages=ctx.messages,", "replacement": " req = GenerateRequest(\n ai_role=\"client\",\n messages=ctx.messages,", "note": "run_turn_generate의 tier 인자 제거(필드 삭제와 동기화). GenerateRequest( 라인으로 run_turn_stream의 동일 블록과 구별됨." }, { "file": "apps/api/app/services/orchestrator.py", "anchor_before": " req = StreamRequest(\n ai_role=\"client\",\n tier=\"client\",\n messages=ctx.messages,", "replacement": " req = StreamRequest(\n ai_role=\"client\",\n messages=ctx.messages,", "note": "run_turn_stream의 tier 인자 제거." }, { "file": "apps/api/app/services/evaluator.py", "anchor_before": " ai_role=\"evaluator\",\n tier=\"feedback\",\n messages=build_fast_messages(ctx, client_reply),", "replacement": " ai_role=\"evaluator\",\n messages=build_fast_messages(ctx, client_reply),", "note": "fast-loop GenerateRequest의 tier 인자 제거. build_fast_messages로 deep 블록과 구별됨." }, { "file": "apps/api/app/services/evaluator.py", "anchor_before": " ai_role=\"evaluator\",\n tier=\"feedback\",\n messages=build_deep_messages(", "replacement": " ai_role=\"evaluator\",\n messages=build_deep_messages(", "note": "deep-loop GenerateRequest의 tier 인자 제거. build_deep_messages로 fast 블록과 구별됨." }, { "file": "apps/api/app/services/voice.py", "anchor_before": "import math\nfrom dataclasses import dataclass", "replacement": "from dataclasses import dataclass", "note": "math는 estimate_chunk_rms에서만 사용(파일 내 math 참조: import + math.sqrt 1건). 함수 제거 후 미사용이므로 임포트 제거." }, { "file": "apps/api/app/services/voice.py", "anchor_before": " 절대 크래시·무한대기 금지(라우트가 503/close 로 변환).\n - 립싱크 힌트: TTS 오디오 청크를 흘리며 RMS(진폭) 힌트를 같이 산출(설계 §4.3 — viseme 정밀\n 매칭 안 함, RMS 1채널). PCM 디코딩 의존성 없이 바이트 에너지 근사로 임시 RMS 추정.", "replacement": " 절대 크래시·무한대기 금지(라우트가 503/close 로 변환).", "note": "모듈 docstring의 립싱크 RMS 힌트 설명(제거되는 기능) 삭제. 프론트는 Web Audio AnalyserNode로 자체 RMS 산출(useAvatarMotion.ts:53)." }, { "file": "apps/api/app/services/voice.py", "anchor_before": "@dataclass(slots=True)\nclass TTSChunk:\n \"\"\"TTS 스트림 1청크 + 립싱크 힌트(설계 §4.3 RMS 1채널).\"\"\"\n\n audio: bytes\n rms: float = 0.0 # 0~1, 입 열림(scaleY) 매핑용 근사 진폭\n seq: int = 0", "replacement": "@dataclass(slots=True)\nclass TTSChunk:\n \"\"\"TTS 스트림 1청크(오디오 바이트).\"\"\"\n\n audio: bytes", "note": "rms/seq 필드 제거. 두 필드를 읽는 곳은 routes/voice.py:383(tts_chunk 송신)뿐이며 이 패치에서 함께 제거." }, { "file": "apps/api/app/services/voice.py", "anchor_before": "# ════════════════════════════════════════════════════════════════════════════\n# 립싱크 RMS 근사 (설계 §4.3 — 정밀 viseme 안 함, 진폭 1채널)\n# ════════════════════════════════════════════════════════════════════════════\ndef estimate_chunk_rms(chunk: bytes) -> float:\n \"\"\"오디오 청크 바이트 에너지로 RMS(0~1) 근사.\n\n 압축 포맷(mp3) 바이트를 PCM 디코딩 없이 근사한다(의존성 0). 평균 바이트 편차를\n 0~1 로 정규화 → 프론트가 데드존(0.04)·지수평활(τ≈180ms) 적용해 입 열림에 매핑.\n NOTE: 정밀 진폭이 필요하면 프론트 Web Audio AnalyserNode 가 재계산(설계 §4.3 권장).\n 이 힌트는 서버측 보조(네트워크 끊김/저사양 폴백)다.\n \"\"\"\n if not chunk:\n return 0.0\n # 128 중심 편차의 RMS(8bit 가정 근사). mp3 프레임이라 정밀치 아님(상대값).\n n = len(chunk)\n acc = 0\n # 과샘플 비용 회피 — 최대 2048 바이트만 샘플링\n step = max(1, n // 2048)\n cnt = 0\n for i in range(0, n, step):\n d = chunk[i] - 128\n acc += d * d\n cnt += 1\n if cnt == 0:\n return 0.0\n rms = math.sqrt(acc / cnt) / 128.0\n return max(0.0, min(1.0, rms))\n\n\n# ════════════════════════════════════════════════════════════════════════════\n# OpenAI 음성 서비스", "replacement": "# ════════════════════════════════════════════════════════════════════════════\n# OpenAI 음성 서비스", "note": "estimate_chunk_rms 함수 + 섹션 헤더 전체 제거. 다음 섹션 헤더(OpenAI 음성 서비스)를 anchor 끝에 포함해 빈 줄 정합 유지. 참조: 본 파일 yield 2곳 + __all__뿐(외부 import 0건, grep)." }, { "file": "apps/api/app/services/voice.py", "anchor_before": " \"\"\"텍스트 → 음성 스트리밍(OpenAI /audio/speech). 청크 + RMS 힌트 yield.\n\n 설계 §5.2 'speaking' 상태: 오디오 청크를 흘리며 진폭 힌트(립싱크)를 같이 보낸다.\n 키 없으면 VoiceUnavailable. OpenAI 오류는 RuntimeError 전파.\n \"\"\"", "replacement": " \"\"\"텍스트 → 음성 스트리밍(OpenAI /audio/speech). 오디오 청크 yield.\n\n 설계 §5.2 'speaking' 상태: 오디오 청크를 흘려보낸다.\n 키 없으면 VoiceUnavailable. OpenAI 오류는 RuntimeError 전파.\n \"\"\"", "note": "synthesize_stream docstring에서 RMS 힌트 표현 제거." }, { "file": "apps/api/app/services/voice.py", "anchor_before": " seq = 0\n try:\n async with self._http.stream(\"POST\", TTS_ENDPOINT, json=payload) as r:", "replacement": " try:\n async with self._http.stream(\"POST\", TTS_ENDPOINT, json=payload) as r:", "note": "synthesize_stream의 seq 카운터 초기화 제거." }, { "file": "apps/api/app/services/voice.py", "anchor_before": " yield TTSChunk(audio=chunk, rms=estimate_chunk_rms(chunk), seq=seq)\n seq += 1", "replacement": " yield TTSChunk(audio=chunk)", "note": "synthesize_stream 본 경로 yield 단순화(rms/seq 제거)." }, { "file": "apps/api/app/services/voice.py", "anchor_before": " data = r.content\n seq = 0\n for i in range(0, len(data), 4096):\n chunk = data[i : i + 4096]\n yield TTSChunk(audio=chunk, rms=estimate_chunk_rms(chunk), seq=seq)\n seq += 1", "replacement": " data = r.content\n for i in range(0, len(data), 4096):\n chunk = data[i : i + 4096]\n yield TTSChunk(audio=chunk)", "note": "_synthesize_fallback yield 단순화(rms/seq + seq 카운터 제거)." }, { "file": "apps/api/app/services/voice.py", "anchor_before": " \"assess_end_of_turn\",\n \"estimate_chunk_rms\",\n \"EOT_SILENCE_THRESHOLD_MS\",", "replacement": " \"assess_end_of_turn\",\n \"EOT_SILENCE_THRESHOLD_MS\",", "note": "__all__에서 estimate_chunk_rms export 제거." }, { "file": "apps/api/app/routes/voice.py", "anchor_before": " -> state(speaking) -> tts_chunk + binary audio chunks -> tts_end -> state(idle)", "replacement": " -> state(speaking) -> binary audio chunks -> tts_end -> state(idle)", "note": "모듈 docstring 프로토콜 설명에서 tts_chunk 제거." }, { "file": "apps/api/app/routes/voice.py", "anchor_before": " n = 0\n async for ck in voice_service.synthesize_stream(reply, voice_preset):\n # Metadata precedes the binary chunk so the client can pair them.\n await _safe_send_json(\n websocket, {\"type\": \"tts_chunk\", \"seq\": ck.seq, \"rms\": round(ck.rms, 4)}\n )\n await _safe_send_bytes(websocket, ck.audio)\n n += 1\n await _safe_send_json(websocket, {\"type\": \"tts_end\", \"chunks\": n})", "replacement": " n = 0\n async for ck in voice_service.synthesize_stream(reply, voice_preset):\n await _safe_send_bytes(websocket, ck.audio)\n n += 1\n await _safe_send_json(websocket, {\"type\": \"tts_end\", \"chunks\": n})", "note": "프론트가 tts_chunk 메시지를 처리하지 않음(Session.tsx onmessage에 tts_chunk 분기 없음; 바이너리 프레임만 ttsChunksRef에 버퍼링). rms/seq 송신 전체 제거. tts_end·바이너리 청크는 유지." }, { "file": "apps/api/app/test_voice_service.py", "anchor_before": " self.assertEqual([chunk.audio for chunk in chunks], [b\"\\x80\\x80\", b\"\\xff\\x00\"])\n self.assertEqual([chunk.seq for chunk in chunks], [0, 1])", "replacement": " self.assertEqual([chunk.audio for chunk in chunks], [b\"\\x80\\x80\", b\"\\xff\\x00\"])", "note": "TTSChunk.seq 제거에 맞춰 seq 어서션 삭제(audio 어서션은 유지)." }, { "file": "apps/api/app/test_session_turn_persistence.py", "anchor_before": " async def fake_synthesize_stream(text, voice_preset):\n yield TTSChunk(audio=b\"tts-audio\", rms=0.25, seq=0)", "replacement": " async def fake_synthesize_stream(text, voice_preset):\n yield TTSChunk(audio=b\"tts-audio\")", "note": "TTSChunk 생성에서 rms/seq 인자 제거. 동 테스트는 tts_end 송신만 어서트(line 358)하므로 그 외 영향 없음." }, { "file": "apps/web/e2e/voice-success.spec.ts", "anchor_before": " expect.objectContaining({ type: \"reply\", text: \"괜찮아요. 천천히 말해볼게요.\" }),\n expect.objectContaining({ type: \"state\", state: \"speaking\" }),\n expect.objectContaining({ type: \"tts_chunk\", seq: 0 }),\n expect.objectContaining({ type: \"tts_end\" }),", "replacement": " expect.objectContaining({ type: \"reply\", text: \"괜찮아요. 천천히 말해볼게요.\" }),\n expect.objectContaining({ type: \"state\", state: \"speaking\" }),\n expect.objectContaining({ type: \"tts_end\" }),", "note": "서버가 더 이상 tts_chunk 메시지를 보내지 않으므로 해당 기대 제거. result.binaryChunks>0 어서션은 바이너리 오디오가 계속 송신되어 유효." } ], "test_plan": "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가 계속 사용함 확인.", "risk": "medium", "rationale": "세 제거 모두 실제 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/단위 어서션을 건드리기 때문이며, 해당 테스트 편집을 패치에 포함해 회귀를 차단했다.", "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" ] }, { "target": "파라미터 객체화 3종 리팩토링 (모두 읽기전용 분석 → 적용 가능한 정확 패치 스펙):\n(1) state_machine.init_state(base_resistance/unlock_rate/decay_floor/ideation_baseline) → frozen dataclass OpennessParams 단일 인자 + PersonaCard.openness_params() 팩토리.\n(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 단일 필드로.\n(3) auth_sessions.upsert_managed_user / _memory_upsert_managed_user 의 7키워드(email/display_name/role/cohort_ids/user_id/affiliation/reactivate) → pydantic ManagedUserInput.", "edits": [ { "file": "apps/api/app/services/state_machine.py", "note": "OpennessParams(frozen) 신설 + init_state 시그니처를 단일 params 인자로 교체 (기존 init_state 전체 블록 234~275행 치환).", "anchor_before": "def init_state(\n *,\n base_resistance: float,\n unlock_rate: float,\n decay_floor: float,\n ideation_baseline: int = 1,\n carry: Optional[dict] = None,\n) -> SessionState:\n \"\"\"회기 시작 상태 초기화 (memory.carry_over 결과 주입 가능).\n\n carry 가 있으면(이전 회기 end_state) 결정론 carry-over:\n stage='라포' 재시작, rapport_credit ×0.7 이월, resistance drift, ideation 보수적 유지.\n \"\"\"\n stage = Stage.RAPPORT\n resistance = base_resistance\n rapport_credit = 0.0\n ideation_stage = ideation_baseline\n\n if carry:\n rapport_credit = float(carry.get(\"rapport_credit\", 0.0)) * 0.7 # P2 이월\n # inter-session drift: 라포가 쌓였으면 저항 소폭 완화된 채로 재시작\n prev_resist = float(carry.get(\"resistance\", base_resistance))\n resistance = _clamp01((prev_resist + base_resistance) / 2.0)\n ideation_stage = max(int(carry.get(\"ideation_stage\", ideation_baseline)), ideation_baseline)\n\n eff = compute_effective_openness(\n stage=stage,\n rapport_credit=rapport_credit,\n resistance=resistance,\n unlock_rate=unlock_rate,\n decay_floor=decay_floor,\n )\n return SessionState(\n stage=stage,\n turn_seq=0,\n effective_openness=eff,\n rapport_credit=rapport_credit,\n resistance=resistance,\n ideation_stage=ideation_stage,\n turns_in_stage=0,\n affect_state={},\n )", "replacement": "@dataclass(frozen=True, slots=True)\nclass OpennessParams:\n \"\"\"init_state 입력 — 페르소나 저항/개방 파라미터 묶음(frozen).\n\n PersonaCard.openness_params() 팩토리로 생성한다\n (resistance.base_resistance/unlock_rate/decay_floor + affect_baseline.suicide_ideation_stage).\n \"\"\"\n\n base_resistance: float\n unlock_rate: float\n decay_floor: float\n ideation_baseline: int = 1\n\n\ndef init_state(\n params: OpennessParams,\n *,\n carry: Optional[dict] = None,\n) -> SessionState:\n \"\"\"회기 시작 상태 초기화 (memory.carry_over 결과 주입 가능).\n\n carry 가 있으면(이전 회기 end_state) 결정론 carry-over:\n stage='라포' 재시작, rapport_credit ×0.7 이월, resistance drift, ideation 보수적 유지.\n \"\"\"\n stage = Stage.RAPPORT\n resistance = params.base_resistance\n rapport_credit = 0.0\n ideation_stage = params.ideation_baseline\n\n if carry:\n rapport_credit = float(carry.get(\"rapport_credit\", 0.0)) * 0.7 # P2 이월\n # inter-session drift: 라포가 쌓였으면 저항 소폭 완화된 채로 재시작\n prev_resist = float(carry.get(\"resistance\", params.base_resistance))\n resistance = _clamp01((prev_resist + params.base_resistance) / 2.0)\n ideation_stage = max(int(carry.get(\"ideation_stage\", params.ideation_baseline)), params.ideation_baseline)\n\n eff = compute_effective_openness(\n stage=stage,\n rapport_credit=rapport_credit,\n resistance=resistance,\n unlock_rate=params.unlock_rate,\n decay_floor=params.decay_floor,\n )\n return SessionState(\n stage=stage,\n turn_seq=0,\n effective_openness=eff,\n rapport_credit=rapport_credit,\n resistance=resistance,\n ideation_stage=ideation_stage,\n turns_in_stage=0,\n affect_state={},\n )" }, { "file": "apps/api/app/services/state_machine.py", "note": "__all__ 에 OpennessParams 노출.", "anchor_before": "__all__ = [\n \"Stage\",\n \"STAGE_BASE_OPENNESS\",\n \"STAGE_ORDER\",\n \"SessionState\",", "replacement": "__all__ = [\n \"Stage\",\n \"STAGE_BASE_OPENNESS\",\n \"STAGE_ORDER\",\n \"OpennessParams\",\n \"SessionState\"," }, { "file": "apps/api/app/services/persona.py", "note": "OpennessParams import 추가(state_machine 은 persona 를 import 하지 않으므로 순환 없음).", "anchor_before": "from ..engine_client import EngineMessage", "replacement": "from ..engine_client import EngineMessage\nfrom .state_machine import OpennessParams" }, { "file": "apps/api/app/services/persona.py", "note": "PersonaCard.openness_params() 팩토리 추가(ideation_baseline 메서드 바로 뒤).", "anchor_before": " def ideation_baseline(self) -> int:\n return int(self.affect_baseline.get(\"suicide_ideation_stage\", 1))", "replacement": " def ideation_baseline(self) -> int:\n return int(self.affect_baseline.get(\"suicide_ideation_stage\", 1))\n\n def openness_params(self) -> OpennessParams:\n \"\"\"state_machine.init_state 입력용 OpennessParams 팩토리(저항·개방 파라미터 묶음).\"\"\"\n return OpennessParams(\n base_resistance=self.base_resistance(),\n unlock_rate=self.unlock_rate(),\n decay_floor=self.decay_floor(),\n ideation_baseline=self.ideation_baseline(),\n )" }, { "file": "apps/api/app/services/persona.py", "note": "TurnMemory dataclass 신설(PersonaStateContext 직후). prepare_turn ↔ build_turn_messages 공유.", "anchor_before": " affect_state: dict[str, float] = field(default_factory=dict)\n\n\n# ════════════════════════════════════════════════════════════════════════════\n# L0 — 역할 + 안전 가드레일 + 도식노출금지 (전 페르소나 공통, cache 대상)", "replacement": " affect_state: dict[str, float] = field(default_factory=dict)\n\n\n# ── 턴 메모리 묶음 (prepare_turn ↔ build_turn_messages 공유) ────────────────\n@dataclass(slots=True)\nclass TurnMemory:\n \"\"\"한 턴에 주입되는 회상/메모리 묶음(L2 회상·L4 pinned·L6 직전턴·KB 행동단서).\n\n orchestrator.prepare_turn(PII 마스킹·전처리)과 build_turn_messages(L0~L6 조립)가 공유.\n \"\"\"\n\n recall_summary: Optional[str] = None\n pinned_facts: list[str] = field(default_factory=list)\n recent_turns: list[dict[str, str]] = field(default_factory=list)\n kb_behavior_cues: list[str] = field(default_factory=list)\n\n\n# ════════════════════════════════════════════════════════════════════════════\n# L0 — 역할 + 안전 가드레일 + 도식노출금지 (전 페르소나 공통, cache 대상)" }, { "file": "apps/api/app/services/persona.py", "note": "build_turn_messages 시그니처를 memory:TurnMemory 단일 keyword 로 교체.", "anchor_before": "def build_turn_messages(\n card: PersonaCard,\n state: PersonaStateContext,\n learner_text_masked: str,\n *,\n recall_summary: Optional[str] = None,\n pinned_facts: Optional[list[str]] = None,\n recent_turns: Optional[list[dict[str, str]]] = None,\n kb_behavior_cues: Optional[list[str]] = None,\n) -> list[EngineMessage]:", "replacement": "def build_turn_messages(\n card: PersonaCard,\n state: PersonaStateContext,\n learner_text_masked: str,\n *,\n memory: Optional[\"TurnMemory\"] = None,\n) -> list[EngineMessage]:" }, { "file": "apps/api/app/services/persona.py", "note": "docstring Args 4줄을 memory 한 줄로 정리(코스메틱, 정합성 유지).", "anchor_before": " recall_summary : 회기 시작 회상(L2-EP, 큰그림→세부 요약). CCD/정답 미포함.\n pinned_facts : 무손실 사실 hard-pin(L4). \"자기 기억\"으로만 표현.\n recent_turns : [{speaker, text}] 최근 K턴 버퍼(L6 직전 맥락)\n kb_behavior_cues : KB 증상 '행동단서'만(본문 비노출, sensitivity<=1)", "replacement": " memory : TurnMemory(회상 L2 / pinned L4 / 직전턴 L6 / KB 행동단서). 마스킹 후 주입." }, { "file": "apps/api/app/services/persona.py", "note": "본문 머리에 mem 바인딩 추가 + L2 분기를 mem.* 로 치환.", "anchor_before": " messages: list[EngineMessage] = []\n\n # L0+L1 — 정적, cache_control 대상\n messages.append(EngineMessage(role=\"system\", content=build_persona_system_text(card), cache=True))\n\n # L2 — 회상 + KB 행동단서 (회기 내 1회 로드, 캐시 친화)\n l2_parts: list[str] = []\n if recall_summary:\n l2_parts.append(f\"[L2 회상 — 지난 맥락(큰그림→세부, 정답/평가 미포함)]\\n{recall_summary}\")\n if kb_behavior_cues:\n cues = \"\\n\".join(f\"- {c}\" for c in kb_behavior_cues)", "replacement": " mem = memory or TurnMemory()\n messages: list[EngineMessage] = []\n\n # L0+L1 — 정적, cache_control 대상\n messages.append(EngineMessage(role=\"system\", content=build_persona_system_text(card), cache=True))\n\n # L2 — 회상 + KB 행동단서 (회기 내 1회 로드, 캐시 친화)\n l2_parts: list[str] = []\n if mem.recall_summary:\n l2_parts.append(f\"[L2 회상 — 지난 맥락(큰그림→세부, 정답/평가 미포함)]\\n{mem.recall_summary}\")\n if mem.kb_behavior_cues:\n cues = \"\\n\".join(f\"- {c}\" for c in mem.kb_behavior_cues)" }, { "file": "apps/api/app/services/persona.py", "note": "L4 pinned_facts 분기를 mem.* 로 치환.", "anchor_before": " # L4 — pinned fact hard-pin (무손실, \"자기 기억\"으로만)\n if pinned_facts:\n pinned = \"\\n\".join(f\"- {f}\" for f in pinned_facts)", "replacement": " # L4 — pinned fact hard-pin (무손실, \"자기 기억\"으로만)\n if mem.pinned_facts:\n pinned = \"\\n\".join(f\"- {f}\" for f in mem.pinned_facts)" }, { "file": "apps/api/app/services/persona.py", "note": "L6 recent_turns 분기를 mem.* 로 치환.", "anchor_before": " # L6 — 직전 K턴 맥락 (히스토리). 게이트웨이가 단발이면 system 뒤 맥락으로 직렬화.\n if recent_turns:\n for t in recent_turns:", "replacement": " # L6 — 직전 K턴 맥락 (히스토리). 게이트웨이가 단발이면 system 뒤 맥락으로 직렬화.\n if mem.recent_turns:\n for t in mem.recent_turns:" }, { "file": "apps/api/app/services/persona.py", "note": "__all__ 에 TurnMemory 노출.", "anchor_before": "__all__ = [\n \"PersonaCard\",\n \"PersonaStateContext\",\n \"L0_SAFETY\",", "replacement": "__all__ = [\n \"PersonaCard\",\n \"PersonaStateContext\",\n \"TurnMemory\",\n \"L0_SAFETY\"," }, { "file": "apps/api/app/services/orchestrator.py", "note": "persona 에서 TurnMemory 재노출 import.", "anchor_before": "from . import guardrail, persona, state_machine\nfrom .persona import PersonaCard, PersonaStateContext\nfrom .state_machine import SessionState, Stage", "replacement": "from . import guardrail, persona, state_machine\nfrom .persona import PersonaCard, PersonaStateContext, TurnMemory\nfrom .state_machine import SessionState, Stage" }, { "file": "apps/api/app/services/orchestrator.py", "note": "TurnContext 의 4개 메모리 필드를 단일 memory:TurnMemory 로 교체.", "anchor_before": " messages: list[EngineMessage] = field(default_factory=list)\n # 회상/메모리 주입(memory.RecallContext 에서 옴)\n recall_summary: Optional[str] = None\n pinned_facts: list[str] = field(default_factory=list)\n recent_turns: list[dict[str, str]] = field(default_factory=list)\n kb_behavior_cues: list[str] = field(default_factory=list)", "replacement": " messages: list[EngineMessage] = field(default_factory=list)\n # 회상/메모리 주입(memory.RecallContext → 마스킹 후 TurnMemory)\n memory: TurnMemory = field(default_factory=TurnMemory)" }, { "file": "apps/api/app/services/orchestrator.py", "note": "prepare_turn 시그니처를 memory:TurnMemory 단일 keyword 로 교체.", "anchor_before": "def prepare_turn(\n *,\n session_id: str,\n case_id: Optional[str],\n card: PersonaCard,\n state: SessionState,\n learner_text: str,\n recall_summary: Optional[str] = None,\n pinned_facts: Optional[list[str]] = None,\n recent_turns: Optional[list[dict[str, str]]] = None,\n kb_behavior_cues: Optional[list[str]] = None,\n eval_rapport_signal: Optional[float] = None,\n) -> TurnContext:", "replacement": "def prepare_turn(\n *,\n session_id: str,\n case_id: Optional[str],\n card: PersonaCard,\n state: SessionState,\n learner_text: str,\n memory: Optional[TurnMemory] = None,\n eval_rapport_signal: Optional[float] = None,\n) -> TurnContext:" }, { "file": "apps/api/app/services/orchestrator.py", "note": "prepare_turn 본문 — ctx 생성 시 입력 memory 를 마스킹한 TurnMemory 로 빌드.", "anchor_before": " ctx = TurnContext(\n session_id=session_id,\n case_id=case_id,\n persona=card,\n state_before=state,\n learner_text_raw=learner_text,\n recall_summary=_mask_optional_text(recall_summary),\n pinned_facts=_mask_text_list(pinned_facts),\n recent_turns=_mask_recent_turns(recent_turns),\n kb_behavior_cues=list(kb_behavior_cues or []),\n )", "replacement": " mem = memory or TurnMemory()\n ctx = TurnContext(\n session_id=session_id,\n case_id=case_id,\n persona=card,\n state_before=state,\n learner_text_raw=learner_text,\n memory=TurnMemory(\n recall_summary=_mask_optional_text(mem.recall_summary),\n pinned_facts=_mask_text_list(mem.pinned_facts),\n recent_turns=_mask_recent_turns(mem.recent_turns),\n kb_behavior_cues=list(mem.kb_behavior_cues),\n ),\n )" }, { "file": "apps/api/app/services/orchestrator.py", "note": "prepare_turn 본문 — build_turn_messages 호출을 memory=ctx.memory 로 교체.", "anchor_before": " ctx.messages = persona.build_turn_messages(\n card,\n ctx.to_state_context(),\n ctx.learner_text_masked,\n recall_summary=ctx.recall_summary,\n pinned_facts=ctx.pinned_facts,\n recent_turns=ctx.recent_turns,\n kb_behavior_cues=ctx.kb_behavior_cues,\n )\n return ctx", "replacement": " ctx.messages = persona.build_turn_messages(\n card,\n ctx.to_state_context(),\n ctx.learner_text_masked,\n memory=ctx.memory,\n )\n return ctx" }, { "file": "apps/api/app/services/orchestrator.py", "note": "__all__ 에 TurnMemory 노출(eval.py 가 orchestrator 에서 import).", "anchor_before": "__all__ = [\n \"EvalHook\",\n \"LogHook\",\n \"TurnContext\",\n \"TurnResult\",", "replacement": "__all__ = [\n \"EvalHook\",\n \"LogHook\",\n \"TurnMemory\",\n \"TurnContext\",\n \"TurnResult\"," }, { "file": "apps/api/app/session_persistence.py", "note": "init_state 호출부(neutral state) → card.openness_params().", "anchor_before": " return state_machine.init_state(\n base_resistance=card.base_resistance(),\n unlock_rate=card.unlock_rate(),\n decay_floor=card.decay_floor(),\n ideation_baseline=card.ideation_baseline(),\n )", "replacement": " return state_machine.init_state(card.openness_params())" }, { "file": "apps/api/app/routes/sessions.py", "note": "세션 시작 init_state 호출부(carry 포함) → card.openness_params().", "anchor_before": " st = state_machine.init_state(\n base_resistance=card.base_resistance(),\n unlock_rate=card.unlock_rate(),\n decay_floor=card.decay_floor(),\n ideation_baseline=card.ideation_baseline(),\n carry=recall.carry,\n )", "replacement": " st = state_machine.init_state(card.openness_params(), carry=recall.carry)" }, { "file": "apps/api/app/routes/sessions.py", "note": "submit_turn 의 prepare_turn 호출부 → memory=TurnMemory(...). (뒤따르는 try/run_turn_generate 로 stream 핸들러와 구분되는 고유 앵커)", "anchor_before": " recall_summary=recall.recall_summary,\n pinned_facts=recall.pinned_facts,\n recent_turns=sess.recent_turns(visible_to=\"client\"),\n )\n assert ctx.state_after is not None\n\n try:\n result = await orchestrator.run_turn_generate(", "replacement": " memory=orchestrator.TurnMemory(\n recall_summary=recall.recall_summary,\n pinned_facts=recall.pinned_facts,\n recent_turns=sess.recent_turns(visible_to=\"client\"),\n ),\n )\n assert ctx.state_after is not None\n\n try:\n result = await orchestrator.run_turn_generate(" }, { "file": "apps/api/app/routes/sessions.py", "note": "stream_turn 의 prepare_turn 호출부 → memory=TurnMemory(...). (뒤따르는 event_generator 로 submit 핸들러와 구분되는 고유 앵커)", "anchor_before": " recall_summary=recall.recall_summary,\n pinned_facts=recall.pinned_facts,\n recent_turns=sess.recent_turns(visible_to=\"client\"),\n )\n assert ctx.state_after is not None\n\n async def event_generator():", "replacement": " memory=orchestrator.TurnMemory(\n recall_summary=recall.recall_summary,\n pinned_facts=recall.pinned_facts,\n recent_turns=sess.recent_turns(visible_to=\"client\"),\n ),\n )\n assert ctx.state_after is not None\n\n async def event_generator():" }, { "file": "apps/api/app/routes/voice.py", "note": "voice 세션 init_state 호출부 → card.openness_params().", "anchor_before": " st = state_machine.init_state(\n base_resistance=card.base_resistance(),\n unlock_rate=card.unlock_rate(),\n decay_floor=card.decay_floor(),\n ideation_baseline=card.ideation_baseline(),\n )", "replacement": " st = state_machine.init_state(card.openness_params())" }, { "file": "apps/api/app/routes/voice.py", "note": "voice 턴 prepare_turn 호출부 → memory=TurnMemory(...). (learner_text=learner_text 로 고유)", "anchor_before": " learner_text=learner_text,\n recall_summary=recall.recall_summary,\n pinned_facts=recall.pinned_facts,\n recent_turns=sess.recent_turns(visible_to=\"client\"),\n )", "replacement": " learner_text=learner_text,\n memory=orchestrator.TurnMemory(\n recall_summary=recall.recall_summary,\n pinned_facts=recall.pinned_facts,\n recent_turns=sess.recent_turns(visible_to=\"client\"),\n ),\n )" }, { "file": "apps/api/app/test_orchestrator_masking.py", "note": "테스트 _initial_state init_state 호출부 → card.openness_params().", "anchor_before": " return state_machine.init_state(\n base_resistance=card.base_resistance(),\n unlock_rate=card.unlock_rate(),\n decay_floor=card.decay_floor(),\n ideation_baseline=card.ideation_baseline(),\n )", "replacement": " return state_machine.init_state(card.openness_params())" }, { "file": "apps/api/app/test_orchestrator_masking.py", "note": "_prepare_context 의 prepare_turn → memory=TurnMemory(recent_turns=...). (learner_text=RAW_TEXT 로 고유)", "anchor_before": " learner_text=RAW_TEXT,\n recent_turns=[\n {\n \"speaker\": \"counselor\",\n \"text\": \"Previous learner contact was already masked: [PHONE] [EMAIL] [RRN].\",\n }\n ],\n )", "replacement": " learner_text=RAW_TEXT,\n memory=orchestrator.TurnMemory(\n recent_turns=[\n {\n \"speaker\": \"counselor\",\n \"text\": \"Previous learner contact was already masked: [PHONE] [EMAIL] [RRN].\",\n }\n ],\n ),\n )" }, { "file": "apps/api/app/test_orchestrator_masking.py", "note": "masks_raw_pii 테스트의 prepare_turn → memory=TurnMemory(...).", "anchor_before": " learner_text=\"Current text has no identifiers.\",\n recall_summary=f\"Recall mentioned {RAW_PHONE}.\",\n pinned_facts=[f\"Pinned email {RAW_EMAIL}.\"],\n recent_turns=[\n {\"speaker\": \"counselor\", \"text\": f\"Previous raw RRN {RAW_RRN}.\"},\n ],\n )", "replacement": " learner_text=\"Current text has no identifiers.\",\n memory=orchestrator.TurnMemory(\n recall_summary=f\"Recall mentioned {RAW_PHONE}.\",\n pinned_facts=[f\"Pinned email {RAW_EMAIL}.\"],\n recent_turns=[\n {\"speaker\": \"counselor\", \"text\": f\"Previous raw RRN {RAW_RRN}.\"},\n ],\n ),\n )" }, { "file": "apps/api/app/test_state_machine_resistance.py", "note": "_initial_p1_state init_state 호출부 → P1.openness_params().", "anchor_before": " return state_machine.init_state(\n base_resistance=P1.base_resistance(),\n unlock_rate=P1.unlock_rate(),\n decay_floor=P1.decay_floor(),\n ideation_baseline=P1.ideation_baseline(),\n )", "replacement": " return state_machine.init_state(P1.openness_params())" }, { "file": "apps/api/app/test_session_turn_persistence.py", "note": "stream 테스트 prepare_turn 의 recent_turns=[] 제거(memory 기본값이 빈 리스트). learner_text 로 고유.", "anchor_before": " learner_text=\"게이트웨이 스트림 테스트\",\n recent_turns=[],\n )", "replacement": " learner_text=\"게이트웨이 스트림 테스트\",\n )" }, { "file": "apps/api/app/test_session_turn_persistence.py", "note": "error 테스트 prepare_turn 의 recent_turns=[] 제거. learner_text 로 고유.", "anchor_before": " learner_text=\"게이트웨이 오류 테스트\",\n recent_turns=[],\n )", "replacement": " learner_text=\"게이트웨이 오류 테스트\",\n )" }, { "file": "apps/api/app/routes/eval.py", "note": "지연 import 에 TurnMemory 추가.", "anchor_before": " from ..services.orchestrator import TurnContext # 지연 import(소유권 경계)", "replacement": " from ..services.orchestrator import TurnContext, TurnMemory # 지연 import(소유권 경계)" }, { "file": "apps/api/app/routes/eval.py", "note": "평가용 TurnContext 재구성 시 recent_turns 를 memory=TurnMemory(recent_turns=recent) 로.", "anchor_before": " state_after=sess.state, # 조회 시점 상태(정밀 재현은 DB 스냅샷 도입 시)\n recent_turns=recent,\n )", "replacement": " state_after=sess.state, # 조회 시점 상태(정밀 재현은 DB 스냅샷 도입 시)\n memory=TurnMemory(recent_turns=recent),\n )" }, { "file": "apps/api/app/services/evaluator.py", "note": "build_fast_messages 의 ctx.recent_turns 읽기 → ctx.memory.recent_turns.", "anchor_before": " for t in (ctx.recent_turns or [])[-4:]", "replacement": " for t in (ctx.memory.recent_turns or [])[-4:]" }, { "file": "apps/api/app/test_rbac_idor.py", "note": "successful_turn 훅의 ctx.recent_turns 읽기 → ctx.memory.recent_turns.", "anchor_before": " captured_recent_turns = list(ctx.recent_turns)", "replacement": " captured_recent_turns = list(ctx.memory.recent_turns)" }, { "file": "apps/api/app/auth_sessions.py", "note": "pydantic BaseModel import 추가.", "anchor_before": "from typing import Literal\n\nfrom .config import settings", "replacement": "from typing import Literal\n\nfrom pydantic import BaseModel\n\nfrom .config import settings" }, { "file": "apps/api/app/auth_sessions.py", "note": "ManagedUserInput(pydantic) 신설 — InactiveUserError 직후.", "anchor_before": "class InactiveUserError(Exception):\n \"\"\"Raised when an inactive managed user attempts to create a login session.\"\"\"\n\n\n_sessions: dict[str, SessionUser] = {}", "replacement": "class InactiveUserError(Exception):\n \"\"\"Raised when an inactive managed user attempts to create a login session.\"\"\"\n\n\nclass ManagedUserInput(BaseModel):\n \"\"\"upsert_managed_user / _memory_upsert_managed_user 공유 입력(7키워드 중복 제거).\"\"\"\n\n email: str\n display_name: str\n role: str\n cohort_ids: list[str] | None = None\n user_id: str | None = None\n affiliation: str | None = None\n reactivate: bool = False\n\n\n_sessions: dict[str, SessionUser] = {}" }, { "file": "apps/api/app/auth_sessions.py", "note": "_memory_upsert_managed_user 시그니처/본문을 inp:ManagedUserInput 기반으로 교체(함수 전체).", "anchor_before": "def _memory_upsert_managed_user(\n *,\n email: str,\n display_name: str,\n role: str,\n cohort_ids: list[str] | None = None,\n user_id: str | None = None,\n affiliation: str | None = None,\n reactivate: bool = False,\n) -> ManagedUser:\n now = time.time()\n normalized_email = _normalize_email(email)\n if normalized_email in _inactive_emails and not reactivate:\n raise InactiveUserError(\"user is inactive\")\n if reactivate:\n _inactive_emails.discard(normalized_email)\n uid = user_id or _email_index.get(normalized_email) or user_id_from_email(normalized_email)\n current = _users.get(uid)\n user = ManagedUser(\n user_id=uid,\n email=normalized_email,\n display_name=(display_name.strip() if display_name else \"\") or normalized_email,\n role=role,\n cohort_ids=list(cohort_ids or current.cohort_ids if current else cohort_ids or []),\n affiliation=(\n affiliation.strip()\n if affiliation\n else (current.affiliation if current else DEFAULT_AFFILIATION)\n ),\n created_at=current.created_at if current else now,\n last_seen_at=now,\n )\n _users[uid] = user\n _email_index[normalized_email] = uid\n return user", "replacement": "def _memory_upsert_managed_user(inp: ManagedUserInput) -> ManagedUser:\n now = time.time()\n normalized_email = _normalize_email(inp.email)\n if normalized_email in _inactive_emails and not inp.reactivate:\n raise InactiveUserError(\"user is inactive\")\n if inp.reactivate:\n _inactive_emails.discard(normalized_email)\n uid = inp.user_id or _email_index.get(normalized_email) or user_id_from_email(normalized_email)\n current = _users.get(uid)\n user = ManagedUser(\n user_id=uid,\n email=normalized_email,\n display_name=(inp.display_name.strip() if inp.display_name else \"\") or normalized_email,\n role=inp.role,\n cohort_ids=list(inp.cohort_ids or current.cohort_ids if current else inp.cohort_ids or []),\n affiliation=(\n inp.affiliation.strip()\n if inp.affiliation\n else (current.affiliation if current else DEFAULT_AFFILIATION)\n ),\n created_at=current.created_at if current else now,\n last_seen_at=now,\n )\n _users[uid] = user\n _email_index[normalized_email] = uid\n return user" }, { "file": "apps/api/app/auth_sessions.py", "note": "upsert_managed_user 시그니처를 inp:ManagedUserInput 로 교체 + normalized_email 도출.", "anchor_before": "async def upsert_managed_user(\n *,\n email: str,\n display_name: str,\n role: str,\n cohort_ids: list[str] | None = None,\n user_id: str | None = None,\n affiliation: str | None = None,\n reactivate: bool = False,\n) -> ManagedUser:\n normalized_email = _normalize_email(email)", "replacement": "async def upsert_managed_user(inp: ManagedUserInput) -> ManagedUser:\n normalized_email = _normalize_email(inp.email)" }, { "file": "apps/api/app/auth_sessions.py", "note": "upsert_managed_user DB fetchrow 인자들을 inp.* 로 치환.", "anchor_before": " f\"email:{normalized_email}\",\n normalized_email,\n (display_name.strip() if display_name else normalized_email),\n _db_role(role),\n _cohort_value(cohort_ids),\n affiliation or DEFAULT_AFFILIATION,\n reactivate,\n )", "replacement": " f\"email:{normalized_email}\",\n normalized_email,\n (inp.display_name.strip() if inp.display_name else normalized_email),\n _db_role(inp.role),\n _cohort_value(inp.cohort_ids),\n inp.affiliation or DEFAULT_AFFILIATION,\n inp.reactivate,\n )" }, { "file": "apps/api/app/auth_sessions.py", "note": "DB 성공 경로의 _memory_upsert_managed_user 호출을 ManagedUserInput 으로.", "anchor_before": " user = _managed_user_from_row(row)\n _memory_upsert_managed_user(\n email=user.email,\n display_name=user.display_name,\n role=user.role,\n cohort_ids=user.cohort_ids,\n user_id=user.user_id,\n affiliation=user.affiliation,\n reactivate=True,\n )\n return user", "replacement": " user = _managed_user_from_row(row)\n _memory_upsert_managed_user(\n ManagedUserInput(\n email=user.email,\n display_name=user.display_name,\n role=user.role,\n cohort_ids=user.cohort_ids,\n user_id=user.user_id,\n affiliation=user.affiliation,\n reactivate=True,\n )\n )\n return user" }, { "file": "apps/api/app/auth_sessions.py", "note": "fallback 경로의 _memory_upsert_managed_user 호출을 inp 그대로 전달(내부에서 email 정규화).", "anchor_before": " require_runtime_fallback_allowed(\"managed user\")\n return _memory_upsert_managed_user(\n email=normalized_email,\n display_name=display_name,\n role=role,\n cohort_ids=cohort_ids,\n user_id=user_id,\n affiliation=affiliation,\n reactivate=reactivate,\n )", "replacement": " require_runtime_fallback_allowed(\"managed user\")\n return _memory_upsert_managed_user(inp)" }, { "file": "apps/api/app/auth_sessions.py", "note": "update_managed_user 내부 _memory_upsert_managed_user 호출을 ManagedUserInput 으로(reactivate 기본 False 유지).", "anchor_before": " _memory_upsert_managed_user(\n email=next_user.email,\n display_name=next_user.display_name,\n role=next_user.role,\n cohort_ids=next_user.cohort_ids,\n user_id=next_user.user_id,\n affiliation=next_user.affiliation,\n )", "replacement": " _memory_upsert_managed_user(\n ManagedUserInput(\n email=next_user.email,\n display_name=next_user.display_name,\n role=next_user.role,\n cohort_ids=next_user.cohort_ids,\n user_id=next_user.user_id,\n affiliation=next_user.affiliation,\n )\n )" }, { "file": "apps/api/app/auth_sessions.py", "note": "create_session 내부 upsert_managed_user 호출을 ManagedUserInput 으로.", "anchor_before": " managed = await upsert_managed_user(\n email=normalized_email,\n display_name=display_name,\n role=role,\n cohort_ids=cohort_ids,\n user_id=user_id,\n reactivate=False,\n )", "replacement": " managed = await upsert_managed_user(\n ManagedUserInput(\n email=normalized_email,\n display_name=display_name,\n role=role,\n cohort_ids=cohort_ids,\n user_id=user_id,\n reactivate=False,\n )\n )" }, { "file": "apps/api/app/routes/admin.py", "note": "auth_sessions import 블록에 ManagedUserInput 추가.", "anchor_before": "from ..auth_sessions import (\n active_session_count,\n deactivate_managed_user,\n list_managed_users,\n update_managed_user,\n upsert_managed_user,\n)", "replacement": "from ..auth_sessions import (\n active_session_count,\n deactivate_managed_user,\n list_managed_users,\n ManagedUserInput,\n update_managed_user,\n upsert_managed_user,\n)" }, { "file": "apps/api/app/routes/admin.py", "note": "create_user 엔드포인트의 upsert_managed_user 호출을 ManagedUserInput 으로.", "anchor_before": " user = await upsert_managed_user(\n email=_normalize_email(body.email),\n display_name=body.display_name,\n role=body.role,\n affiliation=body.affiliation,\n cohort_ids=body.cohort_ids,\n reactivate=True,\n )", "replacement": " user = await upsert_managed_user(\n ManagedUserInput(\n email=_normalize_email(body.email),\n display_name=body.display_name,\n role=body.role,\n affiliation=body.affiliation,\n cohort_ids=body.cohort_ids,\n reactivate=True,\n )\n )" } ], "test_plan": "작업 디렉터리 D:\\workspace\\vignette\\apps\\api 기준.\n1) 임포트/순환참조 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) 가 깨지지 않는지 확인).\n2) 핵심 회귀 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 비공개 발화를 제외하는지.\n3) 전체 백엔드 단위테스트 77개: `python -m pytest app -q` (auth/admin/login 경로 — create_session→upsert_managed_user(ManagedUserInput), admin create_user 엔드포인트 포함 회귀 확인).\n4) 게이트웨이 영향 없음 확인: `python -m pytest engine_gateway -q` (7개).\n모두 green 이어야 하며, 시그니처 변경분(init_state 단일 인자, prepare_turn/build_turn_messages memory 인자, upsert/_memory_upsert 의 ManagedUserInput)이 호출부와 정합하는지 import 에러/AttributeError(ctx.recall_summary 등 잔존 참조) 부재로 검증.", "risk": "medium", "rationale": "세 리팩토링 모두 \"동작 보존 + 인자 묶음\"으로 부수효과가 없다. (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) ManagedUserInput(pydantic) 는 upsert/_memory_upsert 가 공유하던 동일한 7키워드 중복을 제거하고, fallback 경로는 `_memory_upsert_managed_user(inp)` 로 단순화돼 email 재정규화가 함수 내부에서 멱등 처리된다. 모든 호출부(create_session, admin.create_user, 내부 3곳)를 ManagedUserInput 생성으로 교체했고 외부 엔드포인트/공개 함수(create_session) 시그니처는 불변이라 라우트 회귀가 없다. 가치: 4파라미터 init_state 5호출부, 4파라미터 prepare_turn 7호출부, 7키워드 upsert 5호출부의 시그니처 표류 위험을 타입 객체 하나로 수렴시켜 향후 파라미터 추가 시 호출부 일괄 누락 버그를 구조적으로 차단한다. medium 위험인 이유는 프로덕션 라우트(sessions/voice/admin)·평가기·인증 경로를 동시에 건드리지만, 모두 기계적 치환이고 단위테스트 77개가 경로를 커버한다.", "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 시드는 변경 불필요)" ] }, { "target": "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", "test_plan": "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 로 바꾸는 선택적 후속 편집 가능.", "rationale": "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 사본이 있으나(다른 책임) 이번 스코프 밖으로 두어 변경면을 한정했다 — 후속 통합 후보.", "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": [ { "file": "apps/api/app/turn_runtime.py", "anchor_before": "", "note": "NEW FILE — 이 경로에 아래 전체 내용으로 새 파일 생성. routes 를 import 하지 않아 순환 없음(session_persistence/orchestrator/store/runtime_policy/deps 만 의존).", "replacement": "\"\"\"REST/WS 공용 턴 런타임 헬퍼.\n\nroutes/sessions.py(REST submit/stream)와 routes/voice.py(WS)가 복제하던\n\"세션 로드->오너십->완료 턴 영속화(TurnRecord append)->상태 갱신\" 절차를 한곳으로 모은다.\nstage 라벨 표기도 여기서 일원화한다(이전: sessions=_stage_label 한글, voice=Stage.value).\nStage(str, Enum) 의 .value 는 이미 한글이라 두 표기는 동일 문자열이며, 이 일원화는 순수 리팩토링이다.\n\n이 모듈은 라우트를 import 하지 않는다(순환 방지). 세션 영속/런타임 폴백 정책은\nsession_persistence + runtime_policy 를 그대로 사용한다.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom enum import Enum\nfrom typing import Optional\n\nfrom . import session_persistence\nfrom .deps import Principal\nfrom .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed\nfrom .services import orchestrator, state_machine\nfrom .store import InProcSession, TurnRecord, store\n\n# Stage(str, Enum) 의 .value 는 이미 한글이지만 enum 이 아닌 값(문자열 등)에도\n# 안전하도록 방어적으로 매핑한다. REST/WS 가 같은 라벨을 쓰도록 단일화한 함수.\n_STAGE_LABELS = {\n \"RAPPORT\": \"라포\",\n \"EXPLORE\": \"탐색\",\n \"INTERVENE\": \"개입\",\n \"CLOSE\": \"정리\",\n}\n\n\ndef stage_label(stage: object) -> str:\n name = getattr(stage, \"name\", \"\")\n return _STAGE_LABELS.get(name, str(getattr(stage, \"value\", stage)))\n\n\nclass SessionAccessError(str, Enum):\n \"\"\"load_owned_session 의 거부 사유. 각 라우트가 자신의 표현(HTTP/WS)으로 매핑.\"\"\"\n\n NOT_FOUND = \"not_found\"\n FORBIDDEN = \"forbidden\"\n ENDED = \"ended\"\n\n\nasync def load_owned_session(\n session_id: str,\n principal: Principal,\n *,\n allow_ended: bool = False,\n) -> tuple[InProcSession | None, Optional[SessionAccessError]]:\n \"\"\"학습자 소유 세션 로드 + 오너십/종료 검증(공용 코어).\n\n DB 영속 경로 우선, degraded(dev) 시 in-proc store 폴백. 성공 시 (sess, None),\n 실패 시 (None, 사유코드). 라우트가 사유코드를 HTTPException/WS 에러로 변환.\n \"\"\"\n sess = await session_persistence.load_session(session_id, principal, allow_ended=True)\n if sess is not None:\n store.put(sess)\n elif runtime_fallback_allowed():\n sess = store.get(session_id)\n if sess is None:\n return None, SessionAccessError.NOT_FOUND\n if sess.learner_id != principal.user_id:\n return None, SessionAccessError.FORBIDDEN\n if sess.ended and not allow_ended:\n return None, SessionAccessError.ENDED\n return sess, None\n\n\nasync def append_completed_turn(\n sess: InProcSession,\n turn: TurnRecord,\n *,\n context: str,\n) -> None:\n \"\"\"완료 턴 1건 영속화(+in-proc 미러). DB 실패 시 정책 허용하 런타임 폴백.\n\n context 는 폴백 차단 시 사용자에게 보여줄 기능명(require_runtime_fallback_allowed).\n \"\"\"\n if await session_persistence.append_turn(\n session_id=sess.session_id,\n learner_id=sess.learner_id,\n turn=turn,\n ):\n sess.turns.append(turn)\n store.put(sess)\n return\n require_runtime_fallback_allowed(context)\n store.append_turn(sess.session_id, turn)\n\n\nasync def update_session_state(\n sess: InProcSession,\n state: state_machine.SessionState,\n *,\n context: str,\n) -> None:\n \"\"\"working state 영속화(+in-proc 미러). DB 실패 시 정책 허용하 런타임 폴백.\"\"\"\n if await session_persistence.update_state(\n session_id=sess.session_id,\n learner_id=sess.learner_id,\n state=state,\n ):\n sess.state = state\n store.put(sess)\n return\n require_runtime_fallback_allowed(context)\n store.update_state(sess.session_id, state)\n\n\nasync def record_completed_turn(\n sess: InProcSession,\n ctx: orchestrator.TurnContext,\n result: orchestrator.TurnResult,\n *,\n context_prefix: str,\n counselor_turn: Optional[TurnRecord] = None,\n) -> None:\n \"\"\"run_turn_generate 결과 1턴(상담자 발화 + 내담자 응답)을 영속화하고 상태 갱신.\n\n - counselor_turn 미지정 시 ctx 기준 기본 상담자 TurnRecord 생성(REST 경로).\n 음성 경로는 audio_ref/silence_ms/speech_rate/barge_in 메타를 붙인 레코드를 전달.\n - 내담자 응답은 result.client_reply 가 있을 때만 기록(실패 턴이 학습자 전용 발화를 남기지 않게).\n - context_prefix 로 폴백 차단 메시지 구성: '{prefix} turn append' / '{prefix} state update'.\n \"\"\"\n assert ctx.state_after is not None\n learner_turn = counselor_turn or TurnRecord(\n turn_seq=ctx.state_after.turn_seq,\n speaker=\"counselor\",\n stage=stage_label(ctx.state_after.stage),\n text=ctx.learner_text_raw,\n text_masked=ctx.learner_text_masked,\n evaluation=result.evaluation,\n )\n await append_completed_turn(sess, learner_turn, context=f\"{context_prefix} turn append\")\n if result.client_reply:\n await append_completed_turn(\n sess,\n TurnRecord(\n turn_seq=result.turn_seq,\n speaker=\"client\",\n stage=stage_label(result.state_after.stage),\n text=result.client_reply,\n text_masked=result.client_reply,\n llm_provider=result.llm_provider,\n model=result.model,\n tokens_in=result.tokens_in,\n tokens_out=result.tokens_out,\n cost_usd=result.cost_usd,\n ),\n )\n await update_session_state(sess, result.state_after, context=f\"{context_prefix} state update\")\n\n\n__all__ = [\n \"SessionAccessError\",\n \"stage_label\",\n \"load_owned_session\",\n \"append_completed_turn\",\n \"update_session_state\",\n \"record_completed_turn\",\n]\n" }, { "file": "apps/api/app/routes/sessions.py", "anchor_before": "from ..store import InProcSession, TurnRecord, store\n\nrouter = APIRouter(prefix=\"/sessions\", tags=[\"sessions\"])", "replacement": "from ..store import InProcSession, TurnRecord, store\nfrom ..turn_runtime import (\n SessionAccessError,\n append_completed_turn,\n load_owned_session,\n record_completed_turn,\n stage_label,\n update_session_state,\n)\n\nrouter = APIRouter(prefix=\"/sessions\", tags=[\"sessions\"])", "note": "공용 헬퍼 import 추가. 기존 runtime_policy(require_runtime_fallback_allowed, runtime_fallback_allowed)/session_persistence import 는 그대로 둠 — 다른 함수가 계속 사용하고, runtime_fallback_allowed 는 사용처가 없어져도 test patch 대상으로 살려둔다." }, { "file": "apps/api/app/routes/sessions.py", "anchor_before": "def _stage_label(stage: object) -> str:\n name = getattr(stage, \"name\", \"\")\n return {\n \"RAPPORT\": \"라포\",\n \"EXPLORE\": \"탐색\",\n \"INTERVENE\": \"개입\",\n \"CLOSE\": \"정리\",\n }.get(name, str(getattr(stage, \"value\", stage)))", "replacement": "# stage 라벨은 turn_runtime.stage_label 로 일원화(REST/WS 공용). 기존 호출부 호환 별칭.\n_stage_label = stage_label", "note": "로컬 _stage_label 정의를 공용 stage_label 별칭으로 교체. 나머지 _stage_label(...) 호출부(554,581,603,639,750,773,960,1001)는 무수정 유지. 이 할당은 import 이후 모듈 실행 시점에 평가되므로 stage_label 바인딩 존재." }, { "file": "apps/api/app/routes/sessions.py", "anchor_before": "async def _load_session_or_404(\n session_id: str,\n principal: Principal,\n *,\n allow_ended: bool = False,\n) -> InProcSession:\n sess = await session_persistence.load_session(session_id, principal, allow_ended=True)\n if sess is not None:\n store.put(sess)\n elif runtime_fallback_allowed():\n sess = store.get(session_id)\n if sess is None:\n raise HTTPException(status.HTTP_404_NOT_FOUND, detail=\"session not found\")\n if sess.learner_id != principal.user_id:\n raise HTTPException(status.HTTP_403_FORBIDDEN, detail=\"session does not belong to user\")\n if sess.ended and not allow_ended:\n raise HTTPException(status.HTTP_409_CONFLICT, detail=\"session already ended\")\n return sess", "replacement": "_SESSION_ACCESS_HTTP = {\n SessionAccessError.NOT_FOUND: (status.HTTP_404_NOT_FOUND, \"session not found\"),\n SessionAccessError.FORBIDDEN: (status.HTTP_403_FORBIDDEN, \"session does not belong to user\"),\n SessionAccessError.ENDED: (status.HTTP_409_CONFLICT, \"session already ended\"),\n}\n\n\nasync def _load_session_or_404(\n session_id: str,\n principal: Principal,\n *,\n allow_ended: bool = False,\n) -> InProcSession:\n sess, err = await load_owned_session(session_id, principal, allow_ended=allow_ended)\n if err is not None:\n code, detail = _SESSION_ACCESS_HTTP[err]\n raise HTTPException(code, detail=detail)\n assert sess is not None\n return sess", "note": "래퍼는 시그니처/예외문구/상태코드(404/403/409)를 그대로 보존. test_rbac_idor 의 'does not belong' 403 계약 유지." }, { "file": "apps/api/app/routes/sessions.py", "anchor_before": "async def _append_session_turn(sess: InProcSession, turn: TurnRecord) -> None:\n if await session_persistence.append_turn(\n session_id=sess.session_id,\n learner_id=sess.learner_id,\n turn=turn,\n ):\n sess.turns.append(turn)\n store.put(sess)\n return\n require_runtime_fallback_allowed(\"session turn append\")\n store.append_turn(sess.session_id, turn)\n\n\nasync def _update_session_state(\n sess: InProcSession,\n state: state_machine.SessionState,\n) -> None:\n if await session_persistence.update_state(\n session_id=sess.session_id,\n learner_id=sess.learner_id,\n state=state,\n ):\n sess.state = state\n store.put(sess)\n return\n require_runtime_fallback_allowed(\"session state update\")\n store.update_state(sess.session_id, state)\n\n\nasync def _end_persisted_session(sess: InProcSession, carry: memory.CarryOver) -> None:", "replacement": "async def _end_persisted_session(sess: InProcSession, carry: memory.CarryOver) -> None:", "note": "중복된 _append_session_turn/_update_session_state 제거(공용 helper 로 이전). 모든 호출부는 아래 패치에서 갱신됨." }, { "file": "apps/api/app/routes/sessions.py", "anchor_before": " # 턴별 fast-loop 평가는 학습자(상담자) 발화에 부착(기법 태깅·적절성·의도이탈).\n await _append_session_turn(\n sess,\n TurnRecord(\n turn_seq=ctx.state_after.turn_seq,\n speaker=\"counselor\",\n stage=_stage_label(ctx.state_after.stage),\n text=body.text,\n text_masked=ctx.learner_text_masked,\n evaluation=result.evaluation,\n ),\n )\n\n if result.client_reply:\n await _append_session_turn(\n sess,\n TurnRecord(\n turn_seq=result.turn_seq,\n speaker=\"client\",\n stage=_stage_label(result.state_after.stage),\n text=result.client_reply,\n text_masked=result.client_reply,\n llm_provider=result.llm_provider,\n model=result.model,\n tokens_in=result.tokens_in,\n tokens_out=result.tokens_out,\n cost_usd=result.cost_usd,\n ),\n )\n await _update_session_state(sess, result.state_after)", "replacement": " # 상담자 발화(fast-loop 평가 부착) + 내담자 응답 영속화 + 상태 갱신을 공용 헬퍼로 일원화.\n await record_completed_turn(sess, ctx, result, context_prefix=\"session\")", "note": "submit_turn 의 append/append/update 3블록을 record_completed_turn 1콜로 축약. text=body.text==ctx.learner_text_raw 라 기본 counselor_turn 과 동일. 폴백 context('session turn append'/'session state update') 동일하게 재현. 이후 return TurnResponse(...) 블록은 그대로 유지." }, { "file": "apps/api/app/routes/sessions.py", "anchor_before": " await _append_session_turn(\n sess,\n TurnRecord(\n turn_seq=ctx.state_after.turn_seq,\n speaker=\"counselor\",\n stage=_stage_label(ctx.state_after.stage),\n text=body.text,\n text_masked=ctx.learner_text_masked,\n ),\n )\n await _update_session_state(sess, ctx.state_after)\n if final_reply:\n await _append_session_turn(\n sess,\n TurnRecord(\n turn_seq=ctx.state_after.turn_seq,\n speaker=\"client\",\n stage=_stage_label(ctx.state_after.stage),\n text=final_reply,\n text_masked=final_reply,\n llm_provider=str(ev.data.get(\"llm_provider\") or \"\"),\n model=str(ev.data.get(\"model\") or \"\"),\n tokens_in=int(ev.data.get(\"tokens_in\") or 0),\n tokens_out=int(ev.data.get(\"tokens_out\") or 0),\n cost_usd=float(ev.data.get(\"cost_usd\") or 0.0),\n ),\n )", "replacement": " await append_completed_turn(\n sess,\n TurnRecord(\n turn_seq=ctx.state_after.turn_seq,\n speaker=\"counselor\",\n stage=stage_label(ctx.state_after.stage),\n text=body.text,\n text_masked=ctx.learner_text_masked,\n ),\n context=\"session turn append\",\n )\n await update_session_state(\n sess, ctx.state_after, context=\"session state update\"\n )\n if final_reply:\n await append_completed_turn(\n sess,\n TurnRecord(\n turn_seq=ctx.state_after.turn_seq,\n speaker=\"client\",\n stage=stage_label(ctx.state_after.stage),\n text=final_reply,\n text_masked=final_reply,\n llm_provider=str(ev.data.get(\"llm_provider\") or \"\"),\n model=str(ev.data.get(\"model\") or \"\"),\n tokens_in=int(ev.data.get(\"tokens_in\") or 0),\n tokens_out=int(ev.data.get(\"tokens_out\") or 0),\n cost_usd=float(ev.data.get(\"cost_usd\") or 0.0),\n ),\n context=\"session turn append\",\n )", "note": "stream done 핸들러는 TurnResult 가 없고 SSE yield 와 얽혀 있어 record_completed_turn 대신 공용 primitive(append_completed_turn/update_session_state)로 dedup. 순서(counselor->state->client)와 context 문자열 보존. 같은 done 블록 상단의 data={**ev.data,'stage':_stage_label(...)} 줄은 별칭으로 무수정 유지." }, { "file": "apps/api/app/routes/voice.py", "anchor_before": "from ..store import InProcSession, TurnRecord, store\n\nrouter = APIRouter(prefix=\"/voice\", tags=[\"voice\"])", "replacement": "from ..store import InProcSession, TurnRecord, store\nfrom ..turn_runtime import (\n SessionAccessError,\n record_completed_turn,\n load_owned_session,\n stage_label,\n)\n\nrouter = APIRouter(prefix=\"/voice\", tags=[\"voice\"])", "note": "공용 헬퍼 import 추가. 기존 session_persistence/runtime_policy(require_runtime_fallback_allowed)/store/state_machine import 는 _bind_session 등에서 계속 사용하므로 그대로 둠." }, { "file": "apps/api/app/routes/voice.py", "anchor_before": " reply = result.client_reply or \"\"\n # Persist only after the client reply has been generated. A failed AI turn\n # must not leave a learner-only transcript in review or history.\n await _append_voice_turn(\n sess,\n TurnRecord(\n turn_seq=ctx.state_after.turn_seq,\n speaker=\"counselor\",\n stage=ctx.state_after.stage.value,\n text=learner_text,\n text_masked=ctx.learner_text_masked,\n audio_ref=audio_ref,\n silence_ms=silence_ms,\n speech_rate=speech_rate,\n barge_in=barge_in,\n evaluation=result.evaluation,\n ),\n )\n if reply:\n # Persist the generated client reply before TTS playback.\n await _append_voice_turn(\n sess,\n TurnRecord(\n turn_seq=result.turn_seq,\n speaker=\"client\",\n stage=result.stage,\n text=reply,\n text_masked=reply,\n llm_provider=result.llm_provider,\n model=result.model,\n tokens_in=result.tokens_in,\n tokens_out=result.tokens_out,\n cost_usd=result.cost_usd,\n ),\n )\n await _update_voice_state(sess, result.state_after)", "replacement": " reply = result.client_reply or \"\"\n # Persist only after the client reply has been generated. A failed AI turn\n # must not leave a learner-only transcript in review or history. 상담자 발화에는\n # 음성 메타(audio_ref/silence_ms/speech_rate/barge_in)와 fast-loop 평가를 붙인다.\n await record_completed_turn(\n sess,\n ctx,\n result,\n context_prefix=\"voice session\",\n counselor_turn=TurnRecord(\n turn_seq=ctx.state_after.turn_seq,\n speaker=\"counselor\",\n stage=stage_label(ctx.state_after.stage),\n text=learner_text,\n text_masked=ctx.learner_text_masked,\n audio_ref=audio_ref,\n silence_ms=silence_ms,\n speech_rate=speech_rate,\n barge_in=barge_in,\n evaluation=result.evaluation,\n ),\n )", "note": "voice 의 append/append/update 블록을 record_completed_turn 으로 통합. stage 표기 .value->stage_label 일원화(동일 한글 문자열). context_prefix='voice session' 이 폴백 문자열('voice session turn append'/'voice session state update')을 동일 재현. reply 변수는 이후 reply 메시지/state speaking/TTS 분기에서 계속 사용되므로 유지(이 블록 아래 코드 불변)." }, { "file": "apps/api/app/routes/voice.py", "anchor_before": "async def _load_voice_session(\n session_id: str,\n principal: Principal,\n) -> tuple[InProcSession | None, str | None]:\n sess = await session_persistence.load_session(session_id, principal, allow_ended=True)\n if sess is not None:\n store.put(sess)\n elif runtime_fallback_allowed():\n sess = store.get(session_id)\n if sess is None:\n return None, f\"unknown session {session_id}\"\n if sess.learner_id != principal.user_id:\n return None, \"session does not belong to user\"\n if sess.ended:\n return None, \"session already ended\"\n return sess, None", "replacement": "async def _load_voice_session(\n session_id: str,\n principal: Principal,\n) -> tuple[InProcSession | None, str | None]:\n sess, err = await load_owned_session(session_id, principal)\n if err is None:\n return sess, None\n messages = {\n SessionAccessError.NOT_FOUND: f\"unknown session {session_id}\",\n SessionAccessError.FORBIDDEN: \"session does not belong to user\",\n SessionAccessError.ENDED: \"session already ended\",\n }\n return None, messages[err]", "note": "WS 에러 문구 계약('unknown session {id}'/'does not belong'/'already ended') 그대로 보존. _run_turn_and_speak(:286) 와 _bind_session(:477) 호출부는 무수정(시그니처 동일)." }, { "file": "apps/api/app/routes/voice.py", "anchor_before": "async def _append_voice_turn(sess: InProcSession, turn: TurnRecord) -> None:\n if await session_persistence.append_turn(\n session_id=sess.session_id,\n learner_id=sess.learner_id,\n turn=turn,\n ):\n sess.turns.append(turn)\n store.put(sess)\n return\n require_runtime_fallback_allowed(\"voice session turn append\")\n store.append_turn(sess.session_id, turn)\n\n\nasync def _update_voice_state(\n sess: InProcSession,\n state: state_machine.SessionState,\n) -> None:\n if await session_persistence.update_state(\n session_id=sess.session_id,\n learner_id=sess.learner_id,\n state=state,\n ):\n sess.state = state\n store.put(sess)\n return\n require_runtime_fallback_allowed(\"voice session state update\")\n store.update_state(sess.session_id, state)\n\n\nasync def _principal_from_websocket(websocket: WebSocket) -> Principal | None:", "replacement": "async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:", "note": "중복된 _append_voice_turn/_update_voice_state 제거(record_completed_turn 으로 흡수). 유일 호출처였던 _run_turn_and_speak 는 위 패치에서 교체됨. 테스트/외부 참조 없음." } ] } ], "ragDesign": [ { "target": "apps/api/app/routes/sessions.py — 라이브 턴/회기 파이프라인에 rag.py 배선(회상·KB 행동단서), graceful degradation 포함", "rationale": "현재 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).\n\n핵심 설계 결정:\n1) 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 주석과 정합).\n2) 회상은 sess 생성 후 case_id로 app.session_summary(직전 요약)+retrieve_persona_memory(episodic)를 합쳐 build_recall_context로 조립. init_state의 carry는 기존대로 유지(회귀 0; carry는 현재 session_no=1 고정이라 dormant).\n3) 모든 RAG 경로는 db.get_pool()(미초기화 RuntimeError)·rag.NotConfigured·DB 오류를 삼켜 빈 값 반환 → 상담 루프 비차단. routes/kb.py가 같은 예외를 503으로 올리는 것과 달리 라이브 루프는 graceful degradation이 계약(요구사항 3). 세 RAG 쿼리를 각각 별도 acquire로 분리해, 한 쿼리의 트랜잭션 abort가 다른 쿼리를 오염시키지 않게 함.\n\n주의(검증 필요 가정): (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하게 배선.", "risk": "medium", "test_plan": "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라 통과해야 함).", "edits": [ { "file": "apps/api/app/routes/sessions.py", "anchor_before": "from .. import session_persistence", "replacement": "from .. import db, session_persistence", "note": "db.acquire/get_pool 사용 위해 db 모듈 추가 import" }, { "file": "apps/api/app/routes/sessions.py", "anchor_before": "from ..services import evaluator, memory, orchestrator, state_machine", "replacement": "from ..services import evaluator, memory, orchestrator, rag, state_machine", "note": "rag(search_kb/retrieve_persona_memory/AIRole/NotConfigured) 추가 import" }, { "file": "apps/api/app/routes/sessions.py", "anchor_before": "_RECALL_CACHE: dict[str, memory.RecallContext] = {}\n_LEARNER_VISIBLE_AI_ROLE = \"counselor\"", "replacement": "_RECALL_CACHE: dict[str, memory.RecallContext] = {}\n# 세션별 KB 증상 행동단서(회기 1회 산출·캐시). 빈 list 캐시 = 회기 내 재시도 안 함(안정성).\n_KB_CUES_CACHE: dict[str, list[str]] = {}\n_LEARNER_VISIBLE_AI_ROLE = \"counselor\"\n\n# ────────────────────────────────────────────────────────────────────────────\n# RAG 배선 헬퍼 — 내담자(CLIENT) 뷰. 임베더/KB/DB 풀 미가용 시 빈 값으로 graceful\n# degradation(요구사항 3): 상담 루프를 절대 막지 않는다. routes/kb.py 가 같은 예외를\n# 503 으로 올리는 것과 의도적으로 다르다(라이브 루프는 비차단이 계약).\n# ────────────────────────────────────────────────────────────────────────────\n_RAG_RECALL_K = 5\n_KB_CUES_K = 4\n\n\ndef _persona_kb_query(card) -> str:\n \"\"\"페르소나 증상·호소 → KB 행동단서 검색 질의(임베더/tsquery 입력 전용, LLM 미주입).\n\n 질의는 프롬프트에 들어가지 않는다. 회수된 behavior_cue 만 L2 로 주입되고, CLIENT 정책\n (expose_body=False)이 본문을 잘라 '행동단서'만 돌려준다(R4/M6 CCD 본문 비노출 자동 보존).\n \"\"\"\n parts: list[str] = []\n presenting = getattr(card, \"presenting\", None) or {}\n if presenting.get(\"주호소\"):\n parts.append(str(presenting[\"주호소\"]))\n if presenting.get(\"표층\"):\n parts.append(str(presenting[\"표층\"]))\n dsm = getattr(card, \"dsm5_dimensional\", None) or {}\n parts.extend(str(key) for key in dsm.keys() if key != \"note\")\n return \" \".join(p for p in parts if p).strip()\n\n\nasync def _retrieve_kb_behavior_cues(card) -> list[str]:\n \"\"\"KB 증상 행동단서 회수(CLIENT 정책). 미가용 시 빈 리스트(비차단).\"\"\"\n query = _persona_kb_query(card)\n if not query:\n return []\n try:\n async with db.acquire(ai_view=rag.AIRole.CLIENT.value) as conn:\n result = await rag.search_kb(\n conn,\n query=query,\n role=rag.AIRole.CLIENT,\n k=_KB_CUES_K,\n )\n return [c.behavior_cue for c in result.chunks if c.behavior_cue]\n except Exception:\n # rag.NotConfigured(임베더/KB 미가용) · RuntimeError(풀 미초기화) · DB 오류 포함.\n # 비치명적: 빈 단서로 진행(요구사항 3). CancelledError 는 BaseException 이라 미포착.\n return []\n\n\nasync def _ensure_kb_cues(session_id: str, card) -> list[str]:\n \"\"\"세션별 KB 행동단서(회기 1회 산출·캐시, 서버 재시작/재개 시 lazy 재계산).\"\"\"\n cached = _KB_CUES_CACHE.get(session_id)\n if cached is not None:\n return cached\n cues = await _retrieve_kb_behavior_cues(card)\n _KB_CUES_CACHE[session_id] = cues\n return cues\n\n\nasync def _load_prev_case_summary(case_id: str) -> Optional[dict]:\n \"\"\"직전 회기 요약(case 스코프) → build_recall_context 입력. 미존재/미가용 시 None.\n\n app.session_summary 의 확정 컬럼(digest/open_threads/end_state)만 조회한다.\n \"\"\"\n try:\n async with db.acquire(ai_view=rag.AIRole.CLIENT.value) as conn:\n row = await conn.fetchrow(\n \"\"\"\n SELECT digest, open_threads, end_state\n FROM app.session_summary\n WHERE case_id = $1::uuid\n ORDER BY session_no DESC, created_at DESC\n LIMIT 1\n \"\"\",\n case_id,\n )\n except Exception:\n return None\n if row is None:\n return None\n return {\n \"digest\": row[\"digest\"],\n \"open_threads\": list(row[\"open_threads\"] or []),\n \"end_state\": dict(row[\"end_state\"] or {}),\n }\n\n\nasync def _hydrate_episodic_text(conn, result) -> list[str]:\n \"\"\"retrieve_persona_memory 가 돌려준 turn_id → app.turns 마스킹 본문 조인(내담자 발화).\"\"\"\n turn_ids = [c.meta.get(\"turn_id\") for c in result.chunks if c.meta.get(\"turn_id\")]\n if not turn_ids:\n return []\n rows = await conn.fetch(\n \"\"\"\n SELECT id, text_masked FROM app.turns\n WHERE id = ANY($1::uuid[]) AND speaker = 'client'\n \"\"\",\n turn_ids,\n )\n by_id = {str(r[\"id\"]): r[\"text_masked\"] for r in rows}\n return [by_id[t] for t in turn_ids if by_id.get(t)]\n\n\ndef _recall_query(prev_summary: Optional[dict], card) -> str:\n \"\"\"episodic recall 질의: 직전 open_threads 우선(설계 §2-A), 없으면 주호소.\"\"\"\n if prev_summary:\n threads = prev_summary.get(\"open_threads\") or []\n if threads:\n return \" \".join(str(t) for t in threads)\n presenting = getattr(card, \"presenting\", None) or {}\n return str(presenting.get(\"주호소\") or \"\").strip()\n\n\nasync def _episodic_recall_snippets(case_id: str, query: str) -> list[str]:\n \"\"\"case 스코프 episodic 벡터 recall → 내담자 발화 단편(마스킹본). 미가용 시 [].\"\"\"\n if not query:\n return []\n try:\n async with db.acquire(ai_view=rag.AIRole.CLIENT.value) as conn:\n result = await rag.retrieve_persona_memory(\n conn, case_id=case_id, query=query, k=_RAG_RECALL_K,\n )\n return await _hydrate_episodic_text(conn, result)\n except Exception:\n return []\n\n\nasync def _build_start_recall(*, case_id: str, card) -> memory.RecallContext:\n \"\"\"회기 시작 회상 조립(요구사항 1): prev_summary(case) + episodic recall 을\n memory.build_recall_context 로 합본. 전 구간 graceful(미가용 시 빈 회상).\n \"\"\"\n try:\n db.get_pool() # 풀 미초기화 시 RuntimeError → 첫 회기와 동일한 빈 회상\n except RuntimeError:\n return memory.build_recall_context()\n prev_summary = await _load_prev_case_summary(case_id)\n query = _recall_query(prev_summary, card)\n episodic = await _episodic_recall_snippets(case_id, query)\n # pinned_facts: 압축 파이프라인이 prev_summary 에 적재하면 자동 채워짐(현재는 미적재 → []).\n pinned = list((prev_summary or {}).get(\"pinned_facts\") or [])\n return memory.build_recall_context(\n prev_summary=prev_summary,\n episodic_snippets=episodic,\n pinned_facts=pinned,\n )", "note": "캐시 + RAG 배선 헬퍼 일괄 삽입(전부 graceful). PersonaCard는 duck-typing(getattr)으로 받아 추가 import 회피." }, { "file": "apps/api/app/routes/sessions.py", "anchor_before": " else:\n store.put(sess)\n _RECALL_CACHE[sess.session_id] = recall", "replacement": " else:\n store.put(sess)\n\n # RAG 배선(요구사항 1·2) — case 스코프 episodic 회상 + KB 증상 행동단서를 회기 시작\n # 1회 산출해 캐시(L2 캐시 친화). 미가용 시 빈 값으로 graceful(요구사항 3).\n # NOTE: init_state 의 carry 는 위 build_recall_context() 결과(carry=None)를 그대로 사용\n # — 다회기 수치 carry-over 는 본 패치 범위 밖(회귀 0).\n recall = await _build_start_recall(case_id=sess.case_id, card=card)\n _RECALL_CACHE[sess.session_id] = recall\n _KB_CUES_CACHE[sess.session_id] = await _retrieve_kb_behavior_cues(card)", "note": "sess 생성 직후(case_id 확정 후) 회상 재조립+KB cues 캐시. recall 변수 재할당으로 응답 recall_summary 도 enriched 반영." }, { "file": "apps/api/app/routes/sessions.py", "anchor_before": " \"\"\"Submit one trainee utterance and return the generated client reply.\"\"\"\n _ensure_learner(principal)\n sess = await _load_session_or_404(session_id, principal)\n recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()\n\n ctx = orchestrator.prepare_turn(\n session_id=session_id,\n case_id=sess.case_id,\n card=sess.persona,\n state=sess.state,\n learner_text=body.text,\n recall_summary=recall.recall_summary,\n pinned_facts=recall.pinned_facts,\n recent_turns=sess.recent_turns(visible_to=\"client\"),\n )", "replacement": " \"\"\"Submit one trainee utterance and return the generated client reply.\"\"\"\n _ensure_learner(principal)\n sess = await _load_session_or_404(session_id, principal)\n recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()\n kb_cues = await _ensure_kb_cues(session_id, sess.persona)\n\n ctx = orchestrator.prepare_turn(\n session_id=session_id,\n case_id=sess.case_id,\n card=sess.persona,\n state=sess.state,\n learner_text=body.text,\n recall_summary=recall.recall_summary,\n pinned_facts=recall.pinned_facts,\n recent_turns=sess.recent_turns(visible_to=\"client\"),\n kb_behavior_cues=kb_cues,\n )", "note": "submit_turn(903 부근): docstring 포함 anchor로 stream_turn과 구분. kb_behavior_cues 전달 + 재개 시 lazy 재계산(_ensure_kb_cues)." }, { "file": "apps/api/app/routes/sessions.py", "anchor_before": " \"\"\"Stream a generated client reply for one trainee utterance.\"\"\"\n _ensure_learner(principal)\n sess = await _load_session_or_404(session_id, principal)\n recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()\n\n ctx = orchestrator.prepare_turn(\n session_id=session_id,\n case_id=sess.case_id,\n card=sess.persona,\n state=sess.state,\n learner_text=body.text,\n recall_summary=recall.recall_summary,\n pinned_facts=recall.pinned_facts,\n recent_turns=sess.recent_turns(visible_to=\"client\"),\n )", "replacement": " \"\"\"Stream a generated client reply for one trainee utterance.\"\"\"\n _ensure_learner(principal)\n sess = await _load_session_or_404(session_id, principal)\n recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()\n kb_cues = await _ensure_kb_cues(session_id, sess.persona)\n\n ctx = orchestrator.prepare_turn(\n session_id=session_id,\n case_id=sess.case_id,\n card=sess.persona,\n state=sess.state,\n learner_text=body.text,\n recall_summary=recall.recall_summary,\n pinned_facts=recall.pinned_facts,\n recent_turns=sess.recent_turns(visible_to=\"client\"),\n kb_behavior_cues=kb_cues,\n )", "note": "stream_turn(979 부근): docstring 포함 anchor로 submit_turn과 구분. 동일하게 kb_behavior_cues 전달." }, { "file": "apps/api/app/routes/sessions.py", "anchor_before": " await _end_persisted_session(sess, carry)\n _RECALL_CACHE.pop(session_id, None)\n _schedule_session_evaluation(sess)", "replacement": " await _end_persisted_session(sess, carry)\n _RECALL_CACHE.pop(session_id, None)\n _KB_CUES_CACHE.pop(session_id, None)\n _schedule_session_evaluation(sess)", "note": "회기 종료 시 KB cues 캐시도 정리(메모리 누수 방지)." } ] }, "All schema and policy details confirmed. Here is the seed KB design.\n\n---\n\n# Vignette KB 시드 콘텐츠 설계 — RAG 회수용 (스타터)\n\n## 0. 사실 확인 (코드에서 검증한 계약)\n\n`POST /kb/index` → `rag.index_document(conn, IndexRequest)` 의 **실제 입력 형태**는 `routes/kb.py`의 `IndexRequestIn` / `IndexChunkIn`이다.\n\n```\nIndexRequestIn = { source_id, doc_uri, version=1, content_hash?, chunks: IndexChunkIn[] }\nIndexChunkIn = { seq:int(필수), chunk_text:str(필수,≥1), heading_path?, context_prefix?,\n kb_kind?, visible_to?:str[], sensitivity?:0..3, label_id?, meta?, token_count? }\n```\n\n`index_document`이 `kb.chunk`에 적재하는 컬럼 매핑 (rag.py L775~799):\n- `visible_to` 미지정 → `COALESCE($10, ARRAY['client','counselor','evaluator'])` (전체 공개가 기본 — **반드시 명시**해서 정보비대칭을 강제해야 함)\n- `sensitivity` 미지정 → `COALESCE($11, 0)` (0=공개)\n- `kb_kind` 미지정 → `'theory'`\n- `context_prefix` → 색인 시 `prefix + body` 결합본을 임베딩/BM25 대상으로 쓰되, 런타임 LLM 주입 본문(`chunk_text`)에는 미포함 (Contextual Retrieval)\n\n### 정책 4-튜플이 회수에 거는 제약 (rag.py POLICIES) — 시드 설계의 핵심 제약\n\n| role | kinds 화이트리스트 | sens_max | expose_body | 회수 시 반환 |\n|---|---|---|---|---|\n| **client**(내담자) | diagnostic, theory, technique | **1** | False | `meta.behavior_cue` 우선, 없으면 본문 절단 |\n| **counselor**(상담사 보조) | theory, technique, microskill, ko_context | **0** | True | 본문+예시 |\n| **evaluator**(평가) | (전체) | 2 | True | 본문 + `label_id` + `meta.bias_weight` |\n\n> **결론**: 시드의 `visible_to` + `sensitivity` + `kb_kind` 3개 컬럼이 \"누가 무엇을 회수하느냐\"를 DB WHERE로 강제한다. 시드를 잘못 태깅하면(예: 이론 본문에 `client` 포함) 정보비대칭이 깨진다.\n\n### 선행 조건: `kb.source` 등록 (index_document은 source를 만들지 않음)\n\n`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 저작권 위험 없음**.\n\n---\n\n## 1. `kb.source` 시드 (SQL 또는 부트스트랩 — index 전 1회)\n\n```sql\nINSERT INTO kb.source (source_id, title, kb_kind, license_class, citation, external_llm_ok) VALUES\n ('theory_pct_v1', '인간중심상담 핵심개념(환언 스타터)', 'theory', 'A',\n '임상팀 검수 전 개발 시드 — Rogers PCT 환언, verbatim 아님', TRUE),\n ('theory_cbt_v1', 'CBT 핵심개념(환언 스타터)', 'theory', 'A',\n '임상팀 검수 전 개발 시드 — Beck/Martell 환언', TRUE),\n ('technique_cbt_v1','CBT 기법 절차(환언 스타터)', 'technique', 'A',\n '임상팀 검수 전 개발 시드 — 절차 환언', TRUE),\n ('persona_cues_v1', '시드 페르소나 행동단서(P1~P3)', 'diagnostic', 'A',\n '합성 페르소나 행동 표현 — DSM verbatim 아님, 본문 비노출', TRUE);\n```\n\n> `kb.source.kb_kind`는 source당 1개(CHECK 제약)라 kb_kind별로 source를 분리했다. 행동단서는 증상 표현이므로 `diagnostic`.\n\n---\n\n## 2. 이론 지식 청크 — 상담사/평가 AI 회수용 (`sensitivity=0`, `visible_to=['counselor','evaluator']`)\n\n설계 원칙:\n- `visible_to`에서 **client 제외** → 내담자 AI가 이론을 \"학습\"해 메타발화하는 누설 차단(R4).\n- `sensitivity=0` 필수 → counselor 정책이 `sens_max=0`이라 0만 회수.\n- `context_prefix`로 doc 맥락 1문장 부여(동음이의·짧은 청크 회수 정확도↑).\n\n### 2-A. 인간중심(PCT) — `POST /kb/index` 바디 예시\n\n```json\n{\n \"source_id\": \"theory_pct_v1\",\n \"doc_uri\": \"pct/core-concepts.md\",\n \"version\": 1,\n \"chunks\": [\n {\n \"seq\": 0,\n \"kb_kind\": \"theory\",\n \"heading_path\": \"인간중심상담 > 실현경향성\",\n \"context_prefix\": \"이 청크는 칼 로저스 인간중심상담(PCT)의 핵심 동기 가정인 '실현경향성'을 설명하는 개념 문서의 일부다.\",\n \"chunk_text\": \"실현경향성(actualizing tendency)은 모든 유기체가 자신을 유지·향상시키는 방향으로 잠재력을 실현하려는 타고난 경향이라는 인간중심상담의 기본 가정이다. 상담자는 내담자 안에 이미 성장 방향이 있다고 전제하고, 문제를 '교정'하기보다 그 경향이 다시 작동할 수 있는 관계 조건(안전·수용)을 제공하는 데 초점을 둔다. 임상적 함의: 지시·조언을 앞세우기보다 내담자의 자기탐색을 따라가는 태도가 일관되게 요구된다.\",\n \"visible_to\": [\"counselor\", \"evaluator\"],\n \"sensitivity\": 0,\n \"meta\": {\"theory\": \"person_centered\", \"concept\": \"actualizing_tendency\", \"license_class\": \"A\"},\n \"token_count\": 150\n },\n {\n \"seq\": 1,\n \"kb_kind\": \"theory\",\n \"heading_path\": \"인간중심상담 > 가치의 조건\",\n \"context_prefix\": \"이 청크는 인간중심상담에서 부적응의 발생 기제로 설명되는 '가치의 조건'을 다룬다.\",\n \"chunk_text\": \"가치의 조건(conditions of worth)은 '이러이러할 때만 사랑/인정받는다'는 외부 평가를 내면화하면서, 유기체적 경험(실제 느낌)과 자기개념이 불일치하게 되는 과정을 가리킨다. 가치의 조건이 클수록 자신의 진짜 감정을 부정·왜곡하고 타인의 기대에 자기를 맞춘다. 임상적 함의: 무조건적 긍정적 존중을 통해 이 조건을 완화하는 것이 변화의 통로로 여겨진다. (개별 사례에 대한 단정적 해석은 임상 검수 영역)\",\n \"visible_to\": [\"counselor\", \"evaluator\"],\n \"sensitivity\": 0,\n \"meta\": {\"theory\": \"person_centered\", \"concept\": \"conditions_of_worth\", \"license_class\": \"A\"},\n \"token_count\": 165\n },\n {\n \"seq\": 2,\n \"kb_kind\": \"theory\",\n \"heading_path\": \"인간중심상담 > 촉진적 3조건\",\n \"context_prefix\": \"이 청크는 인간중심상담에서 변화를 촉진하는 상담자의 3가지 핵심 태도를 요약한다.\",\n \"chunk_text\": \"인간중심상담의 촉진적 3조건: (1) 일치성/진솔성(congruence) — 상담자가 관계에서 자신의 경험을 가장하지 않음, (2) 무조건적 긍정적 존중(unconditional positive regard) — 내담자를 평가 없이 있는 그대로 수용, (3) 공감적 이해(empathic understanding) — 내담자의 내적 준거틀을 그 사람의 관점에서 느끼고 전달함. 이 세 조건이 내담자에게 '지각'될 때 치료적 변화의 조건이 갖춰진다고 본다.\",\n \"visible_to\": [\"counselor\", \"evaluator\"],\n \"sensitivity\": 0,\n \"meta\": {\"theory\": \"person_centered\", \"concept\": \"core_conditions\", \"license_class\": \"A\"},\n \"token_count\": 180\n }\n ]\n}\n```\n\n### 2-B. CBT 개념 — `theory_cbt_v1` (인지재구조화·행동활성화 *개념*)\n\n```json\n{\n \"source_id\": \"theory_cbt_v1\",\n \"doc_uri\": \"cbt/core-concepts.md\",\n \"version\": 1,\n \"chunks\": [\n {\n \"seq\": 0,\n \"kb_kind\": \"theory\",\n \"heading_path\": \"CBT > 인지모델 > 인지재구조화\",\n \"context_prefix\": \"이 청크는 CBT 인지모델에서 부정적 자동사고를 다루는 '인지재구조화'의 개념적 정의다.\",\n \"chunk_text\": \"인지재구조화(cognitive restructuring)는 상황을 자동적으로 해석하는 부정적 사고(자동적 사고)를 식별하고, 그 사고를 뒷받침/반박하는 증거를 함께 검토해 더 현실적이고 균형 잡힌 대안 사고로 조정하는 CBT 핵심 과정이다. 전제는 '사건 자체보다 사건에 대한 해석이 정서·행동을 매개한다'는 인지모델이다. 목표는 사고를 '긍정적으로 바꾸기'가 아니라 증거 기반으로 검증·유연화하는 것이다.\",\n \"visible_to\": [\"counselor\", \"evaluator\"],\n \"sensitivity\": 0,\n \"meta\": {\"theory\": \"cbt\", \"concept\": \"cognitive_restructuring\", \"license_class\": \"A\"},\n \"token_count\": 160\n },\n {\n \"seq\": 1,\n \"kb_kind\": \"theory\",\n \"heading_path\": \"CBT > 행동모델 > 행동활성화\",\n \"context_prefix\": \"이 청크는 우울 개입에서 쓰이는 CBT '행동활성화'의 개념적 정의와 근거다.\",\n \"chunk_text\": \"행동활성화(behavioral activation)는 우울에서 흔한 회피·철수와 활동 감소가 '환경적 보상의 단절 → 기분 악화 → 더 큰 철수'의 악순환을 만든다고 보고, 가치 있는/숙달감을 주는 활동을 점진적으로 늘려 이 순환을 역전시키는 개입이다. '기분이 나아지면 활동한다'가 아니라 '활동을 먼저 회복해 기분 변화를 유도한다'는 방향성이 핵심이다.\",\n \"visible_to\": [\"counselor\", \"evaluator\"],\n \"sensitivity\": 0,\n \"meta\": {\"theory\": \"cbt\", \"concept\": \"behavioral_activation\", \"license_class\": \"A\"},\n \"token_count\": 160\n }\n ]\n}\n```\n\n### 2-C. CBT 기법 절차 — `technique_cbt_v1` (counselor가 본문+예시로 회수)\n\n```json\n{\n \"source_id\": \"technique_cbt_v1\",\n \"doc_uri\": \"cbt/procedures.md\",\n \"version\": 1,\n \"chunks\": [\n {\n \"seq\": 0,\n \"kb_kind\": \"technique\",\n \"heading_path\": \"기법 > 인지재구조화 절차(스타터)\",\n \"context_prefix\": \"이 청크는 인지재구조화를 회기에서 진행하는 절차 단계 예시(임상 검수 전 스타터)다.\",\n \"chunk_text\": \"인지재구조화 절차 스타터: (1) 상황-사고-감정 분리해 자동적 사고 포착, (2) 그 사고의 강도와 믿는 정도 척도화, (3) 소크라테스식 질문으로 지지/반박 증거 탐색('그렇게 생각하게 한 근거는?', '다르게 볼 여지는?'), (4) 대안적·균형 사고 구성, (5) 재평가 후 감정 강도 재측정. 주: 단계 수·문구는 사례/접근에 따라 달라지며 정밀 적용은 임상 검수 영역.\",\n \"visible_to\": [\"counselor\", \"evaluator\"],\n \"sensitivity\": 0,\n \"meta\": {\"theory\": \"cbt\", \"technique\": \"cognitive_restructuring\", \"is_starter\": true, \"license_class\": \"A\"},\n \"token_count\": 175\n },\n {\n \"seq\": 1,\n \"kb_kind\": \"technique\",\n \"heading_path\": \"기법 > 행동활성화 절차(스타터)\",\n \"context_prefix\": \"이 청크는 행동활성화를 회기에서 진행하는 절차 단계 예시(임상 검수 전 스타터)다.\",\n \"chunk_text\": \"행동활성화 절차 스타터: (1) 활동-기분 모니터링으로 현재 활동량과 정서 관계 관찰, (2) 가치/즐거움/숙달 기준으로 활동 목록화, (3) 난이도 낮은 것부터 점진적 활동 계획 수립, (4) 회피 패턴과 단기 회피의 장기 비용 다루기, (5) 실행-검토 반복. 주: 위기/안전 이슈가 동반되면 활동 과제보다 안전 평가·구조화가 우선이며, 적용 판단은 임상 검수 영역.\",\n \"visible_to\": [\"counselor\", \"evaluator\"],\n \"sensitivity\": 0,\n \"meta\": {\"theory\": \"cbt\", \"technique\": \"behavioral_activation\", \"is_starter\": true, \"license_class\": \"A\"},\n \"token_count\": 180\n }\n ]\n}\n```\n\n---\n\n## 3. 시드 페르소나 행동단서 — 내담자 AI 회수용 (`kb_behavior_cues`)\n\n설계 원칙 (rag.py + persona.py `build_turn_messages` 검증):\n- **client 정책은 `expose_body=False`** → 회수 시 `chunk_text` 본문이 아니라 **`meta.behavior_cue`가 우선 반환**된다(없으면 본문 120자 절단). 따라서 **주입될 실제 단서는 `meta.behavior_cue`에 넣는다.**\n- `kb_kind=\"diagnostic\"` (client kinds에 포함) · `sensitivity=1` (client `sens_max=1`만 회수, **counselor `sens_max=0`은 못 봄** → 단서 격리) · `visible_to=[\"client\"]` (counselor/evaluator 제외).\n- `chunk_text`는 **관찰 가능한 행동/표현만** 기술(임베딩·BM25 매칭용). **CCD 메타문장(\"핵심신념은 무가치함\") 절대 금지** — 코드(L0 규칙·M6)와 동일하게 *행동으로만*.\n- `meta.persona_code`로 페르소나 스코프 필터(`/kb/search` `kb_kind` 좁힘 또는 호출부 source_id 필터).\n\n```json\n{\n \"source_id\": \"persona_cues_v1\",\n \"doc_uri\": \"persona/behavior-cues.md\",\n \"version\": 1,\n \"chunks\": [\n {\n \"seq\": 0,\n \"kb_kind\": \"diagnostic\",\n \"heading_path\": \"행동단서 > P1(우울/자살사고)\",\n \"context_prefix\": \"이 청크는 시드 페르소나 P1(고2, 우울·무기력)이 회기에서 '행동으로' 드러내는 단서 모음이다. 내부 설정 설명이 아니라 표현 방식만 담는다.\",\n \"chunk_text\": \"짧은 단답과 잦은 침묵, 시선 회피. '그냥요/몰라요/별로'로 받아넘김. 미래·진로 화제에서 어조가 더 가라앉고 한숨이 늘어남. 흥미·재미를 묻는 질문에 무덤덤하게 '딱히 없어요'. 잠 얘기에서 '계속 누워만 있어요' 같은 철수 표현.\",\n \"visible_to\": [\"client\"],\n \"sensitivity\": 1,\n \"meta\": {\n \"persona_code\": \"P1\",\n \"symptom_domain\": \"depression_withdrawal\",\n \"behavior_cue\": \"무기력은 '말을 줄이고 단답·침묵·시선회피'로, 무가치감은 '자기를 낮추는 한두 마디와 한숨'으로만 드러낸다. 진로/미래 화제에서 어조가 더 가라앉는다. 내부 설정·진단·수치는 절대 말로 설명하지 않는다.\",\n \"license_class\": \"A\"\n },\n \"token_count\": 150\n },\n {\n \"seq\": 1,\n \"kb_kind\": \"diagnostic\",\n \"heading_path\": \"행동단서 > P2(범불안/신체화)\",\n \"context_prefix\": \"이 청크는 시드 페르소나 P2(32세, 범불안·신체화)가 회기에서 행동으로 드러내는 단서 모음이다.\",\n \"chunk_text\": \"걱정을 길고 빠르게 늘어놓고 '근데 만약에…'로 최악의 시나리오를 잇는다. 가슴 두근거림·속 불편 같은 신체감각을 자주 언급. 긴장한 웃음, 같은 걱정을 표현만 바꿔 반복. '확실히 해두지 않으면 불안하다'는 식의 점검·재확인 요청.\",\n \"visible_to\": [\"client\"],\n \"sensitivity\": 1,\n \"meta\": {\n \"persona_code\": \"P2\",\n \"symptom_domain\": \"anxiety_somatic\",\n \"behavior_cue\": \"불안은 '장황하고 빠른 말, 최악 가정 잇기, 신체감각 호소, 반복 점검'으로만 드러낸다. 안심을 구하지만 쉽게 안심되지 않는다. 자신의 사고패턴을 메타로 분석하지 않는다.\",\n \"license_class\": \"A\"\n },\n \"token_count\": 145\n },\n {\n \"seq\": 2,\n \"kb_kind\": \"diagnostic\",\n \"heading_path\": \"행동단서 > P3(역할부담/소진)\",\n \"context_prefix\": \"이 청크는 시드 페르소나 P3(28세, 미혼모·소진)가 회기에서 행동으로 드러내는 단서 모음이다.\",\n \"chunk_text\": \"'괜찮아요, 제가 해야죠'로 도움 제안을 부드럽게 사양. 아이 이야기엔 생기가 돌다가 자기 얘기로 오면 옅은 한숨과 함께 말끝을 흐림. 쉬는 것·도움 청하는 것에 대한 미안함을 비침. 지친 기색이지만 '그래도 버텨야죠' 같은 책임 표현.\",\n \"visible_to\": [\"client\"],\n \"sensitivity\": 1,\n \"meta\": {\n \"persona_code\": \"P3\",\n \"symptom_domain\": \"role_strain_burnout\",\n \"behavior_cue\": \"소진·죄책감은 '도움 제안을 사양하기, 자기 얘기에서 말끝 흐리기, 미안함 비치기'로만 드러낸다. 따뜻하지만 지친 톤. '약해지면 안 된다'는 신념을 설명하지 않고 행동(책임 떠안기)으로만 보인다.\",\n \"license_class\": \"A\"\n },\n \"token_count\": 150\n }\n ]\n}\n```\n\n---\n\n## 4. 적용 메모 (임상 소유권·범위 한계)\n\n1. **소유권/단정 회피**: 위 콘텐츠는 전부 **`is_starter`/검수 전 환언 시드**다. 사례별 진단 단정, DSM 진단 기준 verbatim, 정밀 개입 프로토콜은 **임상팀 소유**. source는 license `A`(합성/환언)로만 등록했고, DSM verbatim을 넣을 경우 `license_class='C', external_llm_ok=false`로 source를 분리해 국내 라우팅을 강제해야 한다(rag/source 가드 책임).\n\n2. **범위 밖(이번 시드 제외, 임상팀 작성)**:\n - `kb_kind='taxonomy' / 'supervisor_pattern'` + `label_id`(→ `app.technique_label_def`) **평가 정답 라벨 청크** — evaluator 정책(`include_label=True`, `sens_max=2`)이 채점 근거로 회수. `meta.bias_weight` 동봉. 정답 키라 `sensitivity=2`(평가전용), `visible_to=['evaluator']`.\n - `kb_kind='microskill' / 'ko_context'` — counselor 보조용 한국어 맥락/마이크로스킬.\n\n3. **회수 동작 검증 포인트**:\n - 내담자 AI가 P1 회기에서 `search_kb(role=client, query=\"무기력하고 잠을 못 자요\")` → §3 P1 청크 회수, **`meta.behavior_cue` 문자열이 `kb_behavior_cues`로 주입**(`persona.build_turn_messages` L180~182 → \"[L2 증상 행동단서…]\" 블록). 본문(`chunk_text`)은 노출되지 않음.\n - counselor AI가 `search_kb(role=counselor, query=\"행동활성화 어떻게 시작하죠\")` → §2-C 절차 청크를 **본문+예시로** 회수. P1~P3 행동단서는 `sensitivity=1`이라 **회수 불가**(정보비대칭 보존).\n - top1 점수가 `CRAG_TOP1_THRESHOLD=0.35` 미만이면 호출부가 '관찰 프레이밍'으로 다운그레이드(F-06) — 시드 분량이 적으면 임계 미달이 잦을 수 있어 Phase 3 파일럿에서 임계 재측정 필요(rag.py L151 주석과 일치).\n\n4. **인덱싱 순서**: ① `kb.source` 4행 등록(§1) → ② 문서별 `POST /kb/index`(ADMIN RBAC, §2·§3) 4회 호출. 임베딩 모델 미설치 환경에서도 `embedding NULL` 폴백으로 BM25만 동작(`degraded=true`)하며 적재 자체는 성공한다.\n\n**관련 파일(절대경로)**: `D:\\workspace\\vignette\\apps\\api\\app\\services\\rag.py` (index_document·POLICIES), `D:\\workspace\\vignette\\apps\\api\\app\\routes\\kb.py` (IndexChunkIn 입력 스키마), `D:\\workspace\\vignette\\apps\\api\\app\\services\\persona.py` (kb_behavior_cues 주입 지점 L180~182), `D:\\workspace\\vignette\\infra\\db\\init\\03_kb.sql` (kb.source/document/chunk 컬럼·CHECK)." ], "sequence": "두 핵심 충돌을 코드로 확정했습니다. orchestrator.py `__all__`(399–401: `EvalHook`/`LogHook`)과 sessions.py `prepare_turn`(903·979, 인자 `recall_summary=`/`recent_turns=`)이 실제 겹칩니다. 또한 현재 `prepare_turn` 시그니처(97–107)에 `kb_behavior_cues`가 **이미 분리 키워드로 존재**함을 확인했습니다 — 즉 P4(RAG)는 이 구버전 시그니처를 전제로 작성되어 P2와 정면 충돌합니다.\n\n---\n\n# 리팩토링 4종 적용 순서·의존·리스크 분석\n\n분석 대상 4패치:\n\n| 코드 | 패치 | 성격 | risk |\n|---|---|---|---|\n| **P1** | 데드코드 3종 제거 (LogHook / gateway·client tier / 서버 RMS 힌트) | 순수 삭제 | medium |\n| **P2** | 파라미터 객체화 3종 (`OpennessParams` / `TurnMemory` / `ManagedUserInput`) | 시그니처 리팩토링 | medium |\n| **P3** | `turn_runtime.py` 추출 (REST/WS 턴 처리 공용화) | 코드 이동·래퍼 | medium |\n| **P4** | `routes/sessions.py` RAG 배선 (회상·KB 행동단서) | 기능 추가 | medium |\n\n---\n\n## 1. 파일 겹침 매트릭스\n\n| 파일 | P1 | P2 | P3 | P4 |\n|---|:-:|:-:|:-:|:-:|\n| `services/orchestrator.py` | ● | ● | | |\n| `routes/sessions.py` | | ● | ● | ● |\n| `routes/voice.py` | ● | ● | ● | |\n| `services/voice.py` | ● | | | |\n| `services/evaluator.py` | ● | ● | | |\n| `services/persona.py` / `state_machine.py` | | ● | | |\n| `auth_sessions.py` / `routes/admin.py` | | ● | | |\n| `engine_client.py` / `engine_gateway/gateway.py` | ● | | | |\n| `routes/eval.py` | | ● | | |\n| `turn_runtime.py` (신규) | | | ● | |\n| `test_session_turn_persistence.py` | ● | ● | | |\n| `test_orchestrator_masking.py` / `test_rbac_idor.py` | (●) | ● | | |\n\n가장 뜨거운 파일: **`routes/sessions.py`(P2·P3·P4)**, **`routes/voice.py`(P1·P2·P3)**, **`orchestrator.py`(P1·P2)**.\n\n---\n\n## 2. 충돌·의존 분석 (앵커·시그니처 기준)\n\n### C1. P1 ↔ P2 — orchestrator `__all__` (대칭 앵커 충돌, 경미)\n- 두 패치 모두 `__all__`(399–401)의 `EvalHook`/`LogHook` 3행 창을 건드린다. P1은 `LogHook` 삭제, P2는 `LogHook` 뒤에 `TurnMemory` 삽입.\n- **둘 다 동일 HEAD 기준 병렬 작성**이라, 나중에 적용되는 쪽 1줄을 리베이스해야 한다.\n- 그 외에는 거의 직교: P1은 `LogHook 타입/run_turn_generate·run_turn_stream 훅/GenerateRequest·StreamRequest tier`(엔진요청·실행함수), P2는 `TurnContext 필드/prepare_turn 본문`(별개 함수). **같은 함수 안에서 충돌하는 줄은 없음.**\n\n### C2. P2 ↔ P4 — sessions.py `prepare_turn` (구조적 충돌, 치명)\n- 현 `prepare_turn`은 `recall_summary/pinned_facts/recent_turns/kb_behavior_cues` **분리 키워드**(97–107). P4는 이 시그니처에 맞춰 호출부(903·979)에 `kb_behavior_cues=kb_cues`를 **추가**하도록 작성됨.\n- **P2는 이 4개 키워드를 단일 `memory: TurnMemory`로 교체**한다. 따라서 P2 이후엔:\n - P4의 `prepare_turn(..., kb_behavior_cues=kb_cues)` 호출은 **TypeError**(해당 키워드 소멸).\n - P4의 앵커(`recall_summary=...\\nrecent_turns=...`)도 매칭 실패(이미 `memory=...`로 치환됨).\n- **결론: P4는 반드시 P2 뒤에 오고, 호출부를 다음으로 재작성해야 한다.**\n ```python\n memory=orchestrator.TurnMemory(\n recall_summary=recall.recall_summary,\n pinned_facts=recall.pinned_facts,\n recent_turns=sess.recent_turns(visible_to=\"client\"),\n kb_behavior_cues=kb_cues,\n )\n ```\n- 역순(P4 먼저)도 불가: P4가 `kb_behavior_cues` 줄을 끼워 넣으면 P2 앵커가 깨지고, P2의 sessions.py `TurnMemory(...)` 치환문엔 `kb_behavior_cues`가 없어 **RAG 배선이 통째로 누락**된다.\n\n### C3. P2·P3·P4 — sessions.py 동일 파일, **분리 영역**(병존 가능)\n- P2 = `init_state`(712)·`prepare_turn`(903·979)\n- P3 = `_load_session_or_404`(221)·`_append_session_turn`(241)·`_update_session_state`(254)·완료턴 영속화 블록(928·941·956·1002·1012·1014)\n- P4 = 상단 캐시/헬퍼 신설·세션 시작 회상 재조립·`prepare_turn` 호출부·종료 캐시 정리\n- P2와 P3는 **서로 다른 함수/줄** → 순서 무관(commutative). P4만 P2의 `prepare_turn`과 충돌(C2).\n\n### C4. P1 ↔ P3 / P2 ↔ P3 — voice.py, **분리 영역**\n- P1 = `synthesize_stream` 송신 루프(tts_chunk 제거), P2 = `init_state`/`prepare_turn`, P3 = 완료턴 영속화 블록·`_load_voice_session`·`_append_voice_turn`/`_update_voice_state` 제거. 셋 다 다른 영역 → 병존 가능.\n\n### 하드 제약 요약\n1. **P4는 P2 이후**(+호출부 재작성). ← 유일한 강제 순서.\n2. P1·P2 중 나중 적용분은 orchestrator `__all__` 1줄 리베이스.\n3. P3는 P1·P2와 직교(영역 분리), P2와 commutative.\n\n---\n\n## 3. 권장 적용 순서: **P1 → P2 → P3 → P4**\n\n충돌 최소·안전 우선 근거:\n\n1. **P1 먼저** — 순수 삭제라 가장 안전하고 코드 표면을 줄인다. 독립 파일(`engine_client.py`/`gateway.py`/`services/voice.py`)을 먼저 비워 이후 패치의 변경면을 좁힌다. P2와의 유일 접점은 `__all__` 1줄.\n2. **P2 다음** — 시그니처 리팩토링은 호출부 표류 위험이 크므로, 기능 추가(P4) 전에 **인자 형태를 먼저 확정**한다. P4의 선행 의존이라 반드시 P4보다 앞.\n - 적용 시 orchestrator `__all__`은 P1이 `LogHook`을 이미 지웠으므로, P2의 삽입 앵커를 `\"EvalHook\",\\n \"TurnContext\",` → `\"EvalHook\",\\n \"TurnMemory\",\\n \"TurnContext\",`로 조정(원안의 `LogHook` 포함 앵커/치환문에서 `LogHook` 제거).\n3. **P3** — P2와 영역이 분리되어 순서 자유지만, `prepare_turn` 주변이 P2로 안정된 뒤 영속화/로드 골격을 옮기는 편이 리뷰가 명확. (P2↔P3는 교환 가능 — 일정상 병렬 작업도 가능.)\n4. **P4 마지막** — P2의 `memory=TurnMemory` 신시그니처에 맞춰 **재작성한 버전**으로 적용. RAG는 graceful degradation이라 기능적으로 마지막에 얹어도 회귀 표면이 가장 작다.\n\n> P4를 원안(JSON) 그대로 적용하면 **반드시 깨진다**. 이 분석의 단일 최대 리스크이며, P4 호출부 2곳(submit_turn·stream_turn)을 `TurnMemory(... kb_behavior_cues=kb_cues)`로 바꾸는 재작성이 선행되어야 한다.\n\n---\n\n## 4. 단계별 테스트 (작업 디렉터리 `apps/api`)\n\n### P1 후\n```\npython -m pytest app/ engine_gateway/ -q\ncd ../web && npx playwright test e2e/voice-success.spec.ts\n```\n- 중점: `test_voice_service`(synthesize_stream/fallback), `test_session_turn_persistence`(TTSChunk), `test_orchestrator_masking`(tier 키 제거 무영향), `test_gateway_model`, `test_rbac_idor`.\n- 사후 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` 경유 잔존 확인.\n\n### P2 후\n```\npython -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\"\npython -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\npython -m pytest app -q && python -m pytest engine_gateway -q\n```\n- 중점: 순환참조 부재(`persona→state_machine` 단방향, `orchestrator→persona(TurnMemory)`, `eval→orchestrator(TurnMemory)`), PII 마스킹 불변(`memory=TurnMemory`로 받아도 recall/pinned/recent 마스킹 유지), 저항 곡선 불변, `ctx.memory.recent_turns`가 evaluator-only 비공개 발화 제외, auth/admin 경로(`ManagedUserInput`) 회귀.\n- 잔존 참조 점검: `ctx.recall_summary`/`ctx.pinned_facts`/`ctx.recent_turns`/`ctx.kb_behavior_cues` 가 evaluator.py·eval.py·테스트에 남아있지 않은지(AttributeError 차단).\n\n### P3 후\n```\npython -c \"import app.turn_runtime, app.routes.sessions, app.routes.voice\"\npython -m pytest app/test_session_turn_persistence.py app/test_rbac_idor.py app/test_voice_ws.py -q\npython -m pytest app/ engine_gateway/ -q\n```\n- 중점: 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` 무영향 확인.\n- 주의: `test_rbac_idor`의 `patch.object(sessions,'runtime_fallback_allowed')`는 로드 로직 이동 후 no-op이지만 dev 기본값(True)으로 통과 유지 — 엄밀화하려면 patch 대상을 `turn_runtime.runtime_fallback_allowed`로 바꾸는 선택적 후속.\n\n### P4 후 (재작성본 기준)\n```\npython -c \"import app.main\"\npython -m pytest app -q && python -m pytest engine_gateway -q\n```\n- 중점: DB 풀 미초기화 상태에서 `_retrieve_kb_behavior_cues(P1)`·`_build_start_recall(...)`이 예외 없이 `[]`·빈 `RecallContext` 반환(get_pool RuntimeError 경로, graceful). KB 인덱싱+임베더 환경에서 `ctx.messages` L2에 행동단서 블록 포함, 미설치 환경에선 빈 cues로 200 유지. P2 신시그니처 정합(`prepare_turn`이 `memory=` 만 받는지) 재확인.\n\n---\n\n## 5. 리스크 종합\n\n- **최대 리스크 — P4 ↔ P2 시그니처 불일치(C2):** P4 원안은 구버전 `prepare_turn`(분리 키워드)에 묶여 있어 P2 적용 환경에서 **TypeError + 앵커 미스 + RAG 배선 누락**을 유발. P4는 반드시 P2 뒤에서 `memory=TurnMemory(... kb_behavior_cues=kb_cues)`로 재작성. 이것이 순서를 강제하는 유일·결정적 의존이다.\n- **경미 리스크 — orchestrator `__all__`(C1):** P1·P2 병렬 작성으로 동일 3행 창 충돌. 나중 적용분 1줄 리베이스로 해소.\n- **분산 리스크 — sessions.py/voice.py 다중 패치(C3·C4):** 영역이 분리되어 병존 가능하나, 한 파일에 3패치가 누적되므로 각 단계 후 `import` sanity + 해당 라우트 회귀를 반드시 통과시킨 뒤 다음 단계로.\n- **공통 완화책:** 각 패치가 자체 테스트 편집을 포함하므로 단계별 green을 게이트로 사용. 단계 사이 grep 기반 잔존 식별자 0 검증으로 \"부분 적용\" 누수를 차단.\n\n**한 줄 요약:** `P1(삭제) → P2(시그니처 확정) → P3(추출, P2와 교환 가능) → P4(RAG, P2 신시그니처로 재작성 필수)`. 강제 제약은 **P4-after-P2**, 나머지는 영역 분리로 병존 가능하며, orchestrator `__all__` 1줄과 sessions.py `prepare_turn` 호출부 재작성이 손대야 할 두 접합부다." }