세션 메모리와 비언어 이벤트 저장

This commit is contained in:
Yun Chan 2026-06-28 20:12:50 +09:00
parent e8e08935ed
commit 50fa4ad432
12 changed files with 2848 additions and 1277 deletions

View file

@ -18,12 +18,26 @@ DB 미가용(Docker off) 시에도 동작하도록 입력은 plain dict/list 로
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from .state_machine import SessionState
_SESSION_DIGEST_EXCERPT_CHARS = 90
_CASE_DIGEST_MAX_ENTRIES = 12
_RAPPORT_TRAJECTORY_MAX_ENTRIES = 24
_PINNED_FACT_MAX_VALUE_CHARS = 160
_COUNSELING_AGREEMENT_RE = re.compile(r"(상담|회기).*(주\s*\d+\s*회|매주|약속|계속|이어)|"
r"(주\s*\d+\s*회|매주).*(상담|회기)")
_COUNSELING_AGREEMENT_WITHDRAWAL_RE = re.compile(
r"(상담|회기).*(그만|중단|취소|철회|안\s*하|하지\s*않|못\s*하|이어\s*가지\s*않|계속\s*하지\s*않)|"
r"(약속).*(취소|철회|못\s*지키|지키지\s*않|안\s*지키)|"
r"\s*이상.*(상담|회기).*(안\s*하|하지\s*않|못\s*하)"
)
# ════════════════════════════════════════════════════════════════════════════
# 회기 시작 — 회상 (큰그림 → 세부)
# ════════════════════════════════════════════════════════════════════════════
@ -114,6 +128,18 @@ class CompressionJob:
open_threads: list[str] = field(default_factory=list)
@dataclass(frozen=True, slots=True)
class PinnedFactCandidate:
"""Rule-derived client-visible fact candidate for app.pinned_fact."""
key: str
value: str
fact_type: str
status: str = "stable"
confidence: float = 0.7
source_turn_id: str | None = None
def make_carry_over(
*,
state: SessionState,
@ -170,6 +196,174 @@ def build_compression_messages(job: CompressionJob) -> list[dict[str, str]]:
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
def _compact(value: Any, *, limit: int = _SESSION_DIGEST_EXCERPT_CHARS) -> str:
text = " ".join(str(value or "").split())
if len(text) <= limit:
return text
return text[: max(0, limit - 3)].rstrip() + "..."
def build_fallback_session_digest(
*,
session_no: int,
masked_turns: list[dict[str, str]],
end_state: dict,
) -> str:
"""마스킹 축어록 기반 임시 회기 digest.
LLM 압축/embedding writer가 붙기 전에도 다음 회기 recall이 문자열로 남지 않도록
client-visible 마스킹 발화와 결정론 상태 수치만 사용한다.
"""
if not masked_turns:
return f"S{session_no}: 실제 발화가 없어 요약을 생성하지 않았다."
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"]
client_count = len(client_turns)
last_client = _compact(client_turns[-1].get("text") if client_turns else "")
stage = str(end_state.get("stage") or "미확인")
openness = end_state.get("effective_openness")
rapport = end_state.get("rapport_credit")
status_bits = [f"종료 단계 {stage}"]
if openness is not None:
status_bits.append(f"개방도 {openness}")
if rapport is not None:
status_bits.append(f"라포 {rapport}")
status = ", ".join(status_bits)
if last_client:
return (
f"S{session_no}: 마스킹 축어록 기준 상담자 {counselor_count}회, "
f"내담자 {client_count}회 발화. 마지막 내담자 반응은 \"{last_client}\". {status}."
)
return (
f"S{session_no}: 마스킹 축어록 기준 상담자 {counselor_count}회, "
f"내담자 {client_count}회 발화. {status}."
)
def _fact_value(text: Any) -> str:
return _compact(text, limit=_PINNED_FACT_MAX_VALUE_CHARS)
def extract_pinned_fact_candidates(
masked_turns: list[dict[str, Any]],
) -> list[PinnedFactCandidate]:
"""Extract conservative pinned facts from masked client-visible text.
The first pass intentionally avoids clinical inference. It only preserves
explicit facts already surfaced by the client AI and already masked for
learner visibility.
"""
by_key: dict[str, PinnedFactCandidate] = {}
for turn in masked_turns:
if turn.get("speaker") != "client":
continue
text = _fact_value(turn.get("text"))
if not text:
continue
source_turn_id = turn.get("turn_id")
if "[NAME]" in text:
by_key["identity:name"] = PinnedFactCandidate(
key="identity:name",
value="[NAME]",
fact_type="identity",
confidence=0.85,
source_turn_id=str(source_turn_id) if source_turn_id else None,
)
if "[ORG]" in text:
by_key["identity:org"] = PinnedFactCandidate(
key="identity:org",
value="[ORG]",
fact_type="identity",
confidence=0.85,
source_turn_id=str(source_turn_id) if source_turn_id else None,
)
if _COUNSELING_AGREEMENT_WITHDRAWAL_RE.search(text):
by_key["agreement:counseling"] = PinnedFactCandidate(
key="agreement:counseling",
value=text,
fact_type="agreement",
status="contradicted",
confidence=0.8,
source_turn_id=str(source_turn_id) if source_turn_id else None,
)
elif _COUNSELING_AGREEMENT_RE.search(text):
by_key["agreement:counseling"] = PinnedFactCandidate(
key="agreement:counseling",
value=text,
fact_type="agreement",
confidence=0.75,
source_turn_id=str(source_turn_id) if source_turn_id else None,
)
return list(by_key.values())
def merge_case_digest(
*,
existing_digest: str | None,
session_no: int,
session_digest: str,
max_entries: int = _CASE_DIGEST_MAX_ENTRIES,
) -> str:
"""case_profile.case_digest를 session_no 기준으로 idempotent append한다."""
prefix = f"S{session_no}:"
lines = [
line.strip()
for line in str(existing_digest or "").splitlines()
if line.strip() and not line.strip().startswith(prefix)
]
next_line = session_digest.strip()
if next_line and not next_line.startswith(prefix):
next_line = f"{prefix} {next_line}"
if next_line:
lines.append(next_line)
return "\n".join(lines[-max_entries:])
def rapport_trajectory_point(*, session_no: int, end_state: dict) -> dict[str, Any]:
"""case_profile.rapport_trajectory에 저장할 최소 무손실 수치 포인트."""
return {
"session_no": int(session_no),
"stage": end_state.get("stage"),
"end_rapport": end_state.get("rapport_credit"),
"end_openness": end_state.get("effective_openness"),
"resistance": end_state.get("resistance"),
}
def merge_rapport_trajectory(
existing: Any,
point: dict[str, Any],
*,
max_entries: int = _RAPPORT_TRAJECTORY_MAX_ENTRIES,
) -> list[dict[str, Any]]:
"""session_no 기준으로 trajectory를 덮어쓰기 가능하게 append한다."""
session_no = point.get("session_no")
merged: list[dict[str, Any]] = []
if isinstance(existing, list):
for item in existing:
if not isinstance(item, dict):
continue
if item.get("session_no") == session_no:
continue
merged.append(dict(item))
merged.append(dict(point))
return merged[-max_entries:]
def update_alliance_level(previous: Any, end_rapport: Any) -> float:
"""case_profile.alliance_level EWMA. 이전 값이 없으면 schema default 0.2 기준."""
try:
prev = float(previous)
except (TypeError, ValueError):
prev = 0.2
try:
rapport = float(end_rapport)
except (TypeError, ValueError):
rapport = prev
return round(max(0.0, min(1.0, prev * 0.7 + rapport * 0.3)), 4)
__all__ = [
"RecallContext",
"build_recall_context",
@ -177,4 +371,11 @@ __all__ = [
"CompressionJob",
"make_carry_over",
"build_compression_messages",
"build_fallback_session_digest",
"PinnedFactCandidate",
"extract_pinned_fact_candidates",
"merge_case_digest",
"rapport_trajectory_point",
"merge_rapport_trajectory",
"update_alliance_level",
]

View file

@ -19,7 +19,7 @@ PRESET_TO_OPENAI_VOICE 테이블이 흡수. 새 preset 추가는 이 테이블
from __future__ import annotations
import re
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, AsyncIterator, Mapping, Optional
@ -137,6 +137,7 @@ class TranscriptResult:
language: Optional[str] = None
model: str = STT_MODEL
duration: Optional[float] = None
provider_events: list[dict[str, object]] = field(default_factory=list)
@dataclass(frozen=True, slots=True)