런타임 계약과 학습자 흐름 보강

This commit is contained in:
Yun Chan 2026-06-29 08:12:14 +09:00
parent f456b8997a
commit 206018b088
56 changed files with 4306 additions and 1008 deletions

View file

@ -37,6 +37,21 @@ _COUNSELING_AGREEMENT_WITHDRAWAL_RE = re.compile(
r"\s*이상.*(상담|회기).*(안\s*하|하지\s*않|못\s*하)"
)
_DIGEST_SPEAKERS = {"counselor", "client"}
_LLM_DIGEST_MIN_CHARS = 60
_LLM_DIGEST_INTERNAL_MARKERS = (
"rapport_credit",
"effective_openness",
"end_state",
"evaluation",
"정답",
"점수",
"평가 payload",
"평가점수",
"평가 점수",
"core_belief",
"CCD",
)
_SESSION_DIGEST_PREFIX_RE = re.compile(r"^S(?P<session_no>\d+):")
# ════════════════════════════════════════════════════════════════════════════
@ -139,7 +154,7 @@ class SessionDigestInput:
@dataclass(frozen=True, slots=True)
class SessionDigestResult:
"""Digest writer output contract shared by fallback and future LLM worker."""
"""Digest writer output contract shared by fallback and LLM worker paths."""
session_id: str
case_id: str | None
@ -149,6 +164,35 @@ class SessionDigestResult:
source: Literal["fallback", "llm"] = "fallback"
@dataclass(frozen=True, slots=True)
class DigestQualityAssessment:
"""Local quality gate result before an LLM digest can replace fallback."""
accepted: bool
reason: Literal[
"ok",
"empty",
"too_short",
"forbidden_substring",
"internal_marker",
"wrong_session_prefix",
]
retryable: bool = False
details: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class SessionDigestWorkerOutcome:
"""Accepted LLM digest result or the reason fallback must remain authoritative."""
result: SessionDigestResult | None
quality: DigestQualityAssessment
@property
def fallback_required(self) -> bool:
return self.result is None
@dataclass(slots=True)
class CompressionJob:
"""회기종료 narrative 압축 작업(LLM, 비동기 비블로킹). 큐에 적재될 페이로드.
@ -305,6 +349,101 @@ def build_compression_messages(job: CompressionJob) -> list[dict[str, str]]:
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
def _normalize_digest_text(value: Any) -> str:
return " ".join(str(value or "").split())
def assess_llm_digest_quality(
digest_input: SessionDigestInput,
digest: Any,
*,
forbidden_substrings: tuple[str, ...] = (),
min_chars: int = _LLM_DIGEST_MIN_CHARS,
) -> DigestQualityAssessment:
"""Validate an LLM digest candidate without using raw text or end-state data."""
text = _normalize_digest_text(digest)
if not text:
return DigestQualityAssessment(accepted=False, reason="empty", retryable=True)
prefix_match = _SESSION_DIGEST_PREFIX_RE.match(text)
if prefix_match and int(prefix_match.group("session_no")) != digest_input.session_no:
return DigestQualityAssessment(
accepted=False,
reason="wrong_session_prefix",
retryable=True,
details=(prefix_match.group(0),),
)
forbidden_hits = tuple(
marker
for marker in (str(item).strip() for item in forbidden_substrings)
if marker and marker in text
)
if forbidden_hits:
return DigestQualityAssessment(
accepted=False,
reason="forbidden_substring",
retryable=True,
details=forbidden_hits[:5],
)
lowered = text.lower()
marker_hits = tuple(
marker
for marker in _LLM_DIGEST_INTERNAL_MARKERS
if marker.lower() in lowered
)
if marker_hits:
return DigestQualityAssessment(
accepted=False,
reason="internal_marker",
retryable=True,
details=marker_hits,
)
if len(text) < max(1, int(min_chars)):
return DigestQualityAssessment(accepted=False, reason="too_short", retryable=True)
return DigestQualityAssessment(accepted=True, reason="ok")
def build_llm_digest_worker_outcome(
digest_input: SessionDigestInput,
digest: Any,
*,
forbidden_substrings: tuple[str, ...] = (),
min_chars: int = _LLM_DIGEST_MIN_CHARS,
) -> SessionDigestWorkerOutcome:
"""Coerce a candidate LLM digest into the shared result contract if it passes."""
quality = assess_llm_digest_quality(
digest_input,
digest,
forbidden_substrings=forbidden_substrings,
min_chars=min_chars,
)
if not quality.accepted:
return SessionDigestWorkerOutcome(result=None, quality=quality)
normalized = _normalize_digest_text(digest)
prefix = f"S{digest_input.session_no}:"
if not normalized.startswith(prefix):
normalized = f"{prefix} {normalized}"
return SessionDigestWorkerOutcome(
result=SessionDigestResult(
session_id=digest_input.session_id,
case_id=digest_input.case_id,
session_no=digest_input.session_no,
digest=normalized,
open_threads=digest_input.open_threads,
source="llm",
),
quality=quality,
)
def _compact(value: Any, *, limit: int = _SESSION_DIGEST_EXCERPT_CHARS) -> str:
text = " ".join(str(value or "").split())
if len(text) <= limit:
@ -518,9 +657,13 @@ __all__ = [
"MaskedDigestTurn",
"SessionDigestInput",
"SessionDigestResult",
"DigestQualityAssessment",
"SessionDigestWorkerOutcome",
"make_carry_over",
"build_session_digest_input",
"build_compression_messages",
"assess_llm_digest_quality",
"build_llm_digest_worker_outcome",
"build_fallback_digest_result",
"build_fallback_session_digest",
"PinnedFactCandidate",