세션 메모리와 비언어 이벤트 저장
This commit is contained in:
parent
e8e08935ed
commit
50fa4ad432
12 changed files with 2848 additions and 1277 deletions
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue