세션 계약과 메모리 경계 보강

This commit is contained in:
Yun Chan 2026-06-28 23:52:18 +09:00
parent 391639c1de
commit 2bb052f624
12 changed files with 836 additions and 116 deletions

View file

@ -20,7 +20,7 @@ from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from typing import Any, Callable, Literal, Optional
from .state_machine import SessionState
@ -36,6 +36,7 @@ _COUNSELING_AGREEMENT_WITHDRAWAL_RE = re.compile(
r"(약속).*(취소|철회|못\s*지키|지키지\s*않|안\s*지키)|"
r"\s*이상.*(상담|회기).*(안\s*하|하지\s*않|못\s*하)"
)
_DIGEST_SPEAKERS = {"counselor", "client"}
# ════════════════════════════════════════════════════════════════════════════
@ -112,6 +113,42 @@ class CarryOver:
compression_job: Optional["CompressionJob"] = None
@dataclass(frozen=True, slots=True)
class MaskedDigestTurn:
"""A single client-visible, masked turn allowed into narrative digest input."""
speaker: str
text: str
turn_id: str | None = None
@dataclass(frozen=True, slots=True)
class SessionDigestInput:
"""Narrative-only digest boundary for future LLM compression.
This object intentionally excludes raw text, evaluation payloads, CCD, and
deterministic carry-over state. Numeric carry stays in CarryOver.end_state.
"""
session_id: str
case_id: str | None
session_no: int
masked_turns: tuple[MaskedDigestTurn, ...]
open_threads: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class SessionDigestResult:
"""Digest writer output contract shared by fallback and future LLM worker."""
session_id: str
case_id: str | None
session_no: int
digest: str
open_threads: tuple[str, ...] = ()
source: Literal["fallback", "llm"] = "fallback"
@dataclass(slots=True)
class CompressionJob:
"""회기종료 narrative 압축 작업(LLM, 비동기 비블로킹). 큐에 적재될 페이로드.
@ -120,12 +157,30 @@ class CompressionJob:
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)
digest_input: SessionDigestInput
@property
def session_id(self) -> str:
return self.digest_input.session_id
@property
def case_id(self) -> str | None:
return self.digest_input.case_id
@property
def session_no(self) -> int:
return self.digest_input.session_no
@property
def masked_turns(self) -> list[dict[str, str]]:
return [
{"speaker": turn.speaker, "text": turn.text}
for turn in self.digest_input.masked_turns
]
@property
def open_threads(self) -> list[str]:
return list(self.digest_input.open_threads)
@dataclass(frozen=True, slots=True)
@ -159,16 +214,71 @@ def make_carry_over(
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 []),
digest_input=build_session_digest_input(
session_id=session_id,
case_id=case_id,
session_no=session_no,
masked_turns=masked_turns,
open_threads=open_threads,
),
)
return CarryOver(end_state=end_state, rapport_delta=rapport_delta, compression_job=job)
def _is_client_visible(turn: dict[str, Any]) -> bool:
visible_to = turn.get("visible_to")
if visible_to is None:
return True
if isinstance(visible_to, str):
return visible_to == "client"
try:
return "client" in {str(item) for item in visible_to}
except TypeError:
return False
def _masked_digest_turn(turn: dict[str, Any]) -> MaskedDigestTurn | None:
if not _is_client_visible(turn):
return None
speaker = str(turn.get("speaker") or "").strip()
if speaker not in _DIGEST_SPEAKERS:
return None
text = str(turn.get("text_masked") or turn.get("text") or "").strip()
if not text:
return None
turn_id = turn.get("turn_id")
return MaskedDigestTurn(
speaker=speaker,
text=text,
turn_id=str(turn_id) if turn_id else None,
)
def build_session_digest_input(
*,
session_id: str,
case_id: str | None,
session_no: int,
masked_turns: list[dict[str, Any]],
open_threads: Optional[list[str]] = None,
) -> SessionDigestInput:
"""Normalize the only payload shape allowed into narrative digest workers."""
turns = tuple(
digest_turn
for raw_turn in masked_turns
if (digest_turn := _masked_digest_turn(raw_turn)) is not None
)
threads = tuple(str(thread).strip() for thread in (open_threads or []) if str(thread).strip())
return SessionDigestInput(
session_id=session_id,
case_id=case_id,
session_no=int(session_no),
masked_turns=turns,
open_threads=threads,
)
def build_compression_messages(job: CompressionJob) -> list[dict[str, str]]:
"""CompressionJob → 서사 압축용 EngineMessage 평문(dict) 리스트.
@ -177,10 +287,10 @@ def build_compression_messages(job: CompressionJob) -> list[dict[str, str]]:
pinned 사실 보존·정답 미포함 지시 포함.
"""
transcript = "\n".join(
f"{('상담자' if t.get('speaker') == 'counselor' else '내담자')}: {t.get('text', '')}"
for t in job.masked_turns
f"{('상담자' if t.speaker == 'counselor' else '내담자')}: {t.text}"
for t in job.digest_input.masked_turns
)
threads = "\n".join(f"- {t}" for t in job.open_threads) or "(없음)"
threads = "\n".join(f"- {t}" for t in job.digest_input.open_threads) or "(없음)"
system = (
"당신은 상담 회기 종료 요약기다. 아래 마스킹된 축어록을 6~10문장 digest 로 압축한다.\n"
"규칙: ① 사실·정서 궤적·미해결 주제를 보존한다. ② 평가/점수/정답 라벨은 절대 포함하지 않는다.\n"
@ -188,7 +298,6 @@ def build_compression_messages(job: CompressionJob) -> list[dict[str, str]]:
)
user = (
f"[회기 번호] {job.session_no}\n"
f"[종료 상태(수치, 참고)] {job.end_state}\n"
f"[미해결 주제]\n{threads}\n\n"
f"[마스킹된 축어록]\n{transcript}\n\n"
"위를 digest 6~10문장으로 압축하라."
@ -214,8 +323,36 @@ def build_fallback_session_digest(
LLM 압축/embedding writer가 붙기 전에도 다음 회기 recall이 문자열로 남지 않도록
client-visible 마스킹 발화와 결정론 상태 수치만 사용한다.
"""
digest_input = build_session_digest_input(
session_id="",
case_id=None,
session_no=session_no,
masked_turns=masked_turns,
)
return build_fallback_digest_result(digest_input, end_state=end_state).digest
def build_fallback_digest_result(
digest_input: SessionDigestInput,
*,
end_state: dict,
) -> SessionDigestResult:
"""Build the current deterministic digest through the explicit contract."""
masked_turns = [
{"speaker": turn.speaker, "text": turn.text}
for turn in digest_input.masked_turns
]
session_no = digest_input.session_no
if not masked_turns:
return f"S{session_no}: 실제 발화가 없어 요약을 생성하지 않았다."
return SessionDigestResult(
session_id=digest_input.session_id,
case_id=digest_input.case_id,
session_no=session_no,
digest=f"S{session_no}: 실제 발화가 없어 요약을 생성하지 않았다.",
open_threads=digest_input.open_threads,
source="fallback",
)
counselor_count = sum(1 for turn in masked_turns if turn.get("speaker") == "counselor")
client_turns = [turn for turn in masked_turns if turn.get("speaker") == "client"]
@ -231,13 +368,22 @@ def build_fallback_session_digest(
status_bits.append(f"라포 {rapport}")
status = ", ".join(status_bits)
if last_client:
return (
digest = (
f"S{session_no}: 마스킹 축어록 기준 상담자 {counselor_count}회, "
f"내담자 {client_count}회 발화. 마지막 내담자 반응은 \"{last_client}\". {status}."
)
return (
f"S{session_no}: 마스킹 축어록 기준 상담자 {counselor_count}회, "
f"내담자 {client_count}회 발화. {status}."
else:
digest = (
f"S{session_no}: 마스킹 축어록 기준 상담자 {counselor_count}회, "
f"내담자 {client_count}회 발화. {status}."
)
return SessionDigestResult(
session_id=digest_input.session_id,
case_id=digest_input.case_id,
session_no=session_no,
digest=digest,
open_threads=digest_input.open_threads,
source="fallback",
)
@ -369,8 +515,13 @@ __all__ = [
"build_recall_context",
"CarryOver",
"CompressionJob",
"MaskedDigestTurn",
"SessionDigestInput",
"SessionDigestResult",
"make_carry_over",
"build_session_digest_input",
"build_compression_messages",
"build_fallback_digest_result",
"build_fallback_session_digest",
"PinnedFactCandidate",
"extract_pinned_fact_candidates",