세션 계약과 메모리 경계 보강
This commit is contained in:
parent
391639c1de
commit
2bb052f624
12 changed files with 836 additions and 116 deletions
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue