"""회기 라이프사이클 메모리 — 시작 회상 + 종료 carry-over (4계층 매핑). MEMORY_KNOWLEDGE_PERSONA_DESIGN §1·§2·§8 + 4대 대원칙(P1~P4): ① WORKING : 상태머신 수치(state_machine.SessionState) — 매 턴 체크포인트 ② EPISODIC : 발화(turns) append-only — 회상 검색 대상 ③ SUMMARY : 회기종료 압축(end_state 무손실 + digest 서사 LLM 압축) ④ SEMANTIC : case_profile 누적 + pinned_fact 핵심 원칙: - P2/P4: 숫자(상태 수치)는 코드가 무손실 복사(carry-over). 서사(narrative)만 LLM 압축. - P3: 회상은 큰그림→세부 순서(case_digest → 직전 summary → episodic recall) 토큰 예산 배분. - 회상 요약(recall_summary)에는 CCD·정답·평가가 절대 들어가지 않는다(M6, 내담자 뷰). 이 모듈은 *순수 조립/룰 로직* + (선택) LLM 압축 *트리거 큐*만 담당한다. 실제 임베딩/하이브리드 검색은 RAG(Features) 소유 → 여기선 인터페이스(주입형)로 추상화한다. DB 미가용(Docker off) 시에도 동작하도록 입력은 plain dict/list 로 받는다. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Callable, Optional from .state_machine import SessionState # ════════════════════════════════════════════════════════════════════════════ # 회기 시작 — 회상 (큰그림 → 세부) # ════════════════════════════════════════════════════════════════════════════ @dataclass(slots=True) class RecallContext: """회기 시작 회상 결과. 내담자 AI system L2 주입용. ⚠️ CCD/정답/평가 미포함(내담자 뷰). digest/open_threads 는 "자기 기억" 표면만. """ recall_summary: Optional[str] = None # UI 카드 + L2 주입(큰그림→세부 합본) pinned_facts: list[str] = field(default_factory=list) # L4 hard-pin(무손실) open_threads: list[str] = field(default_factory=list) carry: Optional[dict] = None # 직전 end_state(결정론 carry-over 입력) def build_recall_context( *, case_digest: Optional[str] = None, # ④ 큰그림 (~400토큰 예산) prev_summary: Optional[dict] = None, # ③ 직전 session_summary {digest, open_threads, homework, end_state} episodic_snippets: Optional[list[str]] = None, # ② recall top-k 세부 (RAG 주입형) pinned_facts: Optional[list[str]] = None, # ④ pinned_fact value[] ) -> RecallContext: """회상 컨텍스트 조립 (P3: 큰그림→세부 순서로 토큰 예산 배분). DB/RAG 가 없으면 인자들이 None → 빈 RecallContext(첫 회기·in-proc fallback). 호출부(sessions.start)가 DB/RAG 가용 시 채워 넣는다. """ lines: list[str] = [] if case_digest: lines.append(f"[케이스 큰그림]\n{case_digest}") prev_end_state: Optional[dict] = None open_threads: list[str] = [] if prev_summary: digest = prev_summary.get("digest") if digest: lines.append(f"[직전 회기 요약]\n{digest}") open_threads = list(prev_summary.get("open_threads") or []) if open_threads: ot = "\n".join(f"- {t}" for t in open_threads) lines.append(f"[미해결 주제]\n{ot}") homework = prev_summary.get("homework") if homework: lines.append(f"[지난 과제]\n{homework}") prev_end_state = prev_summary.get("end_state") if episodic_snippets: snips = "\n".join(f"- {s}" for s in episodic_snippets) lines.append(f"[지난 대화 단편(세부)]\n{snips}") recall_summary = "\n\n".join(lines) if lines else None return RecallContext( recall_summary=recall_summary, pinned_facts=list(pinned_facts or []), open_threads=open_threads, carry=prev_end_state, ) # ════════════════════════════════════════════════════════════════════════════ # 회기 종료 — carry-over (무손실 수치 복사 + 서사 압축 트리거) # ════════════════════════════════════════════════════════════════════════════ @dataclass(slots=True) class CarryOver: """회기 종료 무손실 carry-over (P4: 코드 복사, LLM 미경유). end_state = 상태머신 종료 snapshot(다음 회기 init_state 입력). compression_job = 서사 digest LLM 압축이 *필요한* 입력 묶음(비동기 큐 대상). """ end_state: dict rapport_delta: float = 0.0 compression_job: Optional["CompressionJob"] = None @dataclass(slots=True) class CompressionJob: """회기종료 narrative 압축 작업(LLM, 비동기 비블로킹). 큐에 적재될 페이로드. 입력은 *마스킹된 발화*만(F-03). 실제 LLM 호출/임베딩/DB UPSERT 는 orchestrator/background task 가 engine_client + RAG 로 수행한다(여기선 페이로드만). """ session_id: str case_id: Optional[str] session_no: int masked_turns: list[dict[str, str]] # [{speaker, text}] (text_masked) end_state: dict open_threads: list[str] = field(default_factory=list) def make_carry_over( *, state: SessionState, session_id: str, case_id: Optional[str], session_no: int, masked_turns: list[dict[str, str]], prev_rapport_credit: float = 0.0, open_threads: Optional[list[str]] = None, ) -> CarryOver: """회기 종료 carry-over 생성. (A) 무손실: end_state = state.snapshot() (코드 복사) [P4] (B) rapport_delta = 종료 rapport_credit − 이전 회기 rapport_credit (C) 서사 압축은 CompressionJob 으로 큐잉(LLM, 비동기) — 여기선 페이로드만 만든다 """ end_state = state.snapshot() rapport_delta = round(state.rapport_credit - prev_rapport_credit, 4) job = CompressionJob( session_id=session_id, case_id=case_id, session_no=session_no, masked_turns=masked_turns, end_state=end_state, open_threads=list(open_threads or []), ) return CarryOver(end_state=end_state, rapport_delta=rapport_delta, compression_job=job) def build_compression_messages(job: CompressionJob) -> list[dict[str, str]]: """CompressionJob → 서사 압축용 EngineMessage 평문(dict) 리스트. 실제 호출은 orchestrator/background 가 engine_client.generate(GenerateRequest( ai_role='evaluator', tier='feedback', ...)) 로 수행. 여기선 프롬프트만 조립(IO 없음). pinned 사실 보존·정답 미포함 지시 포함. """ transcript = "\n".join( f"{('상담자' if t.get('speaker') == 'counselor' else '내담자')}: {t.get('text', '')}" for t in job.masked_turns ) threads = "\n".join(f"- {t}" for t in job.open_threads) or "(없음)" system = ( "당신은 상담 회기 종료 요약기다. 아래 마스킹된 축어록을 6~10문장 digest 로 압축한다.\n" "규칙: ① 사실·정서 궤적·미해결 주제를 보존한다. ② 평가/점수/정답 라벨은 절대 포함하지 않는다.\n" "③ 내담자가 실제로 말한 사실은 바꾸지 않는다(무손실). ④ 한국어, 간결한 임상 서술체." ) user = ( f"[회기 번호] {job.session_no}\n" f"[종료 상태(수치, 참고)] {job.end_state}\n" f"[미해결 주제]\n{threads}\n\n" f"[마스킹된 축어록]\n{transcript}\n\n" "위를 digest 6~10문장으로 압축하라." ) return [{"role": "system", "content": system}, {"role": "user", "content": user}] __all__ = [ "RecallContext", "build_recall_context", "CarryOver", "CompressionJob", "make_carry_over", "build_compression_messages", ]