관리자 감정 관측 기록과 조회 API 추가

This commit is contained in:
Yun Chan 2026-09-23 04:45:50 +09:00
parent d22cd9883d
commit acb0d26338
16 changed files with 1445 additions and 18 deletions

View file

@ -14,6 +14,7 @@ from typing import Any, Awaitable, Callable, Iterable, Literal
from .db import acquire, get_pool
from .deps import Principal
from .config import settings
from .contracts.client_affect import ClientAffectTraceV1
from .persona_repository import (
SEED_VERSION,
card_from_row,
@ -44,6 +45,10 @@ class SessionCreationPersistenceError(RuntimeError):
"""fail-closed 환경에서 영속 세션 생성이 실패했다."""
class ClientAffectTracePersistenceError(RuntimeError):
"""내담자 턴·감정 trace·상태를 같은 트랜잭션으로 기록하지 못했다."""
class ActiveSessionExistsError(RuntimeError):
"""같은 learner-persona 전체에 미종료 회기가 이미 존재한다."""
@ -2892,6 +2897,100 @@ async def append_turn(
return False
async def append_client_turn_with_affect_trace(
*,
session_id: str,
learner_id: str,
turn: TurnRecord,
state: state_machine.SessionState,
trace: ClientAffectTraceV1,
) -> bool:
"""성공 내담자 턴·Jev trace·전이 상태를 하나의 DB 트랜잭션에 기록한다."""
inserted_turn_id: str | None = None
try:
get_pool()
async with acquire(role="learner", user_id=learner_id) as conn:
async with conn.transaction():
locked = await conn.fetchval(
"""
SELECT id
FROM app.sessions
WHERE id = $1::uuid
AND learner_id = $2::uuid
FOR UPDATE
""",
session_id,
learner_id,
)
if locked is None:
raise ClientAffectTracePersistenceError("owned session not found")
seq = int(
await conn.fetchval(
"SELECT COALESCE(MAX(seq), 0) + 1 FROM app.turns WHERE session_id = $1::uuid",
session_id,
)
or 1
)
inserted = await conn.fetchval(
"""
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, provider_events, visible_to
)
VALUES (
$1::uuid, $2, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $12,
$13, $14, $15, $16, $17::jsonb, $18::text[]
)
ON CONFLICT (session_id, seq) DO NOTHING
RETURNING id
""",
session_id,
seq,
turn.speaker,
turn.stage,
turn.text_masked,
turn.text_masked,
"client_ai",
turn.llm_provider,
turn.model,
turn.tokens_in,
turn.tokens_out,
turn.cost_usd,
turn.audio_ref,
turn.silence_ms,
turn.speech_rate,
turn.barge_in,
turn.provider_events or [],
list(turn.visible_to or DEFAULT_TURN_VISIBLE_TO),
)
if inserted is None:
raise ClientAffectTracePersistenceError("client turn insert conflict")
inserted_turn_id = str(inserted)
await conn.execute(
"""
INSERT INTO app.client_affect_trace (turn_id, session_id, trace)
VALUES ($1::uuid, $2::uuid, $3::jsonb)
""",
inserted_turn_id,
session_id,
trace.model_dump(mode="json"),
)
await _upsert_state(conn, session_id, state)
except ClientAffectTracePersistenceError:
raise
except Exception as exc:
logger.exception(
"client affect trace persistence failed: session_id=%s", session_id
)
raise ClientAffectTracePersistenceError(
"client affect trace persistence failed"
) from exc
turn.turn_id = inserted_turn_id
return True
async def update_state(
*,
session_id: str,