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

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

@ -551,6 +551,7 @@ def _turn_from_row(row, evaluation: dict[str, Any] | None = None) -> TurnRecord:
silence_ms=_row_value(row, "silence_ms"),
speech_rate=_row_value(row, "speech_rate"),
barge_in=_row_value(row, "barge_in"),
provider_events=_dict_items(_row_value(row, "provider_events")),
evaluation=evaluation,
visible_to=tuple(_row_value(row, "visible_to") or DEFAULT_TURN_VISIBLE_TO),
)
@ -1890,7 +1891,7 @@ async def load_session(
"""
SELECT id, seq, speaker, stage, text, text_masked, created_at,
llm_provider, model, tokens_in, tokens_out, cost_usd,
audio_ref, silence_ms, speech_rate, barge_in, visible_to
audio_ref, silence_ms, speech_rate, barge_in, provider_events, visible_to
FROM app.turns
WHERE session_id = $1::uuid
ORDER BY seq
@ -1945,12 +1946,12 @@ async def append_turn(
INSERT INTO app.turns (
session_id, seq, speaker, stage, text, text_masked, actor_kind,
llm_provider, model, tokens_in, tokens_out, cost_usd,
audio_ref, silence_ms, speech_rate, barge_in, visible_to
audio_ref, silence_ms, speech_rate, barge_in, provider_events, visible_to
)
VALUES (
$1::uuid, $2, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $12,
$13, $14, $15, $16, $17::text[]
$13, $14, $15, $16, $17::jsonb, $18::text[]
)
ON CONFLICT (session_id, seq) DO NOTHING
RETURNING id
@ -1971,6 +1972,7 @@ async def append_turn(
turn.silence_ms,
turn.speech_rate,
turn.barge_in,
turn.provider_events or [],
list(turn.visible_to or DEFAULT_TURN_VISIBLE_TO),
)
if inserted_turn_id is None:
@ -2000,13 +2002,176 @@ async def update_state(
return False
async def _insert_pinned_fact_history(
conn: Any,
*,
fact_id: Any,
case_id: str,
old_value: Any,
new_value: Any,
reason: str,
session_no: int,
turn_id: str | None,
) -> None:
await conn.execute(
"""
INSERT INTO app.pinned_fact_history (
fact_id, case_id, old_value, new_value, reason, session_no, turn_id
)
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7::uuid)
""",
fact_id,
case_id,
old_value,
new_value,
reason,
session_no,
turn_id,
)
async def _upsert_pinned_fact_candidates(conn: Any, sess: InProcSession) -> None:
turn_rows = [
{
"speaker": turn.speaker,
"text": turn.text_masked,
"turn_id": turn.turn_id,
}
for turn in sess.turns_visible_to("client")
]
candidates = memory.extract_pinned_fact_candidates(turn_rows)
for fact in candidates:
if fact.status == "contradicted":
row = await conn.fetchrow(
"""
WITH existing AS (
SELECT id, value
FROM app.pinned_fact
WHERE case_id = $1::uuid
AND key = $2
AND status <> 'locked'
FOR UPDATE
),
updated AS (
UPDATE app.pinned_fact
SET value = $3,
fact_type = $4,
status = 'contradicted',
source_turn = COALESCE($5::uuid, app.pinned_fact.source_turn),
confidence = GREATEST(app.pinned_fact.confidence, $6),
version = app.pinned_fact.version + 1,
updated_session_no = $7,
visible_to = $8::text[],
updated_at = now()
FROM existing
WHERE app.pinned_fact.id = existing.id
RETURNING
app.pinned_fact.id,
existing.value AS old_value,
app.pinned_fact.value AS new_value
)
SELECT id, old_value, new_value FROM updated
""",
sess.case_id,
fact.key,
fact.value,
fact.fact_type,
fact.source_turn_id,
fact.confidence,
sess.session_no,
["evaluator"],
)
if not row:
continue
old_value = row["old_value"]
new_value = row["new_value"]
if old_value == new_value:
continue
await _insert_pinned_fact_history(
conn,
fact_id=row["id"],
case_id=sess.case_id,
old_value=old_value,
new_value=new_value,
reason="contradiction",
session_no=sess.session_no,
turn_id=fact.source_turn_id,
)
continue
row = await conn.fetchrow(
"""
WITH existing AS (
SELECT id, value, status
FROM app.pinned_fact
WHERE case_id = $1::uuid AND key = $2
FOR UPDATE
),
upserted AS (
INSERT INTO app.pinned_fact (
case_id, key, value, fact_type, status, source_turn,
confidence, updated_session_no, visible_to, updated_at
)
VALUES (
$1::uuid, $2, $3, $4, $5, $6::uuid,
$7, $8, $9::text[], now()
)
ON CONFLICT (case_id, key) DO UPDATE SET
value = EXCLUDED.value,
fact_type = EXCLUDED.fact_type,
status = EXCLUDED.status,
source_turn = COALESCE(EXCLUDED.source_turn, app.pinned_fact.source_turn),
confidence = GREATEST(app.pinned_fact.confidence, EXCLUDED.confidence),
version = CASE
WHEN app.pinned_fact.value IS DISTINCT FROM EXCLUDED.value
THEN app.pinned_fact.version + 1
ELSE app.pinned_fact.version
END,
updated_session_no = EXCLUDED.updated_session_no,
visible_to = EXCLUDED.visible_to,
updated_at = now()
WHERE app.pinned_fact.status <> 'locked'
RETURNING
app.pinned_fact.id,
(SELECT value FROM existing) AS old_value,
app.pinned_fact.value AS new_value
)
SELECT id, old_value, new_value FROM upserted
""",
sess.case_id,
fact.key,
fact.value,
fact.fact_type,
fact.status,
fact.source_turn_id,
fact.confidence,
sess.session_no,
["client", "evaluator"],
)
if not row:
continue
old_value = row["old_value"]
new_value = row["new_value"]
if old_value is not None and old_value == new_value:
continue
reason = "progression" if old_value is None else "clarification"
await _insert_pinned_fact_history(
conn,
fact_id=row["id"],
case_id=sess.case_id,
old_value=old_value,
new_value=new_value,
reason=reason,
session_no=sess.session_no,
turn_id=fact.source_turn_id,
)
async def end_session(sess: InProcSession, carry: memory.CarryOver) -> bool:
try:
get_pool()
digest = (
f"회기 축어록 {len(sess.turns)}개가 저장되었습니다. 정밀 리뷰는 생성 대기 중입니다."
if sess.turns
else "실제 발화가 없어 요약을 생성하지 않았습니다."
digest = memory.build_fallback_session_digest(
session_no=sess.session_no,
masked_turns=sess.masked_turns(visible_to="client"),
end_state=carry.end_state,
)
async with acquire(role="learner", user_id=sess.learner_id) as conn:
await conn.execute(
@ -2039,6 +2204,51 @@ async def end_session(sess: InProcSession, carry: memory.CarryOver) -> bool:
digest,
list(carry.compression_job.open_threads if carry.compression_job else []),
)
case_row = await conn.fetchrow(
"""
SELECT case_digest, rapport_trajectory, alliance_level
FROM app.case_profile
WHERE case_id = $1::uuid
AND learner_id = $2::uuid
""",
sess.case_id,
sess.learner_id,
)
if case_row is not None:
trajectory_point = memory.rapport_trajectory_point(
session_no=sess.session_no,
end_state=carry.end_state,
)
case_digest = memory.merge_case_digest(
existing_digest=case_row["case_digest"],
session_no=sess.session_no,
session_digest=digest,
)
rapport_trajectory = memory.merge_rapport_trajectory(
case_row["rapport_trajectory"],
trajectory_point,
)
alliance_level = memory.update_alliance_level(
case_row["alliance_level"],
trajectory_point.get("end_rapport"),
)
await conn.execute(
"""
UPDATE app.case_profile
SET case_digest = $3,
rapport_trajectory = $4::jsonb,
alliance_level = $5,
updated_at = now()
WHERE case_id = $1::uuid
AND learner_id = $2::uuid
""",
sess.case_id,
sess.learner_id,
case_digest,
rapport_trajectory,
alliance_level,
)
await _upsert_pinned_fact_candidates(conn, sess)
return True
except Exception:
require_runtime_fallback_allowed("session end")
@ -2110,7 +2320,7 @@ async def list_sessions(
"""
SELECT id, seq, speaker, stage, text, text_masked, created_at,
llm_provider, model, tokens_in, tokens_out, cost_usd,
audio_ref, silence_ms, speech_rate, barge_in, visible_to
audio_ref, silence_ms, speech_rate, barge_in, provider_events, visible_to
FROM app.turns
WHERE session_id = $1::uuid
ORDER BY seq