vignette/apps/api/app/services/admin_affect.py
Yun Chan 29c406d89f Jev 내담자 평가·표현 v2와 속마음 공개
상담자 발화 판정(A)·감정(B)·표현(C) 20문항 질문 세트, 감쇠 없는 이번 턴 반응과 비대칭 기분 전이, 개방도 게이트로 생성 지시를 만들고 ccd.coping_strategy 전달 누락을 고친다.

trace v2와 고정 문구 속마음 요약(migration 24, AI 경로 차단 RLS)을 같은 트랜잭션에 저장하고 피드백 정책이 켜진 경우에만 done·TurnResponse·음성 reply·리뷰로 노출한다. 회기 화면 속마음 보기 토글, 리뷰 접힘 블록, 관리자 감정 관측 v2 표시를 추가한다.
2026-09-30 13:23:09 +09:00

209 lines
7 KiB
Python

"""관리자 감정 관측용 영속 조회."""
from __future__ import annotations
import math
from typing import Any
from uuid import UUID
from pydantic import ValidationError
from ..config import settings
from ..contracts.admin_affect import (
AdminAffectRuntimeResponse,
AdminAffectSessionDetailResponse,
AdminAffectSessionListResponse,
AdminAffectSessionSummary,
AdminAffectTraceRecord,
)
from ..contracts.client_affect import (
CLIENT_AFFECT_DIMENSIONS,
ClientAffectTrace,
ClientAffectTraceV1,
ClientAffectTraceV2,
)
from ..db import acquire
from .jev_client import jev_client
class AdminAffectSessionNotFoundError(LookupError):
"""요청한 회기가 존재하지 않는다."""
class AdminAffectPersistenceError(RuntimeError):
"""관리자 감정 관측용 영속 조회를 완료할 수 없다."""
def runtime_snapshot() -> AdminAffectRuntimeResponse:
"""현재 Jev 클라이언트 설정만 노출한다. 연결 검증 결과는 포함하지 않는다."""
return AdminAffectRuntimeResponse(
enabled=settings.client_affect_provider == "jev",
provider=jev_client.provider,
model=jev_client.model,
configured=jev_client.configured,
)
def _current_emotions(affect_state: Any) -> dict[str, float | None]:
state = affect_state if isinstance(affect_state, dict) else {}
emotions: dict[str, float | None] = {}
for dimension in CLIENT_AFFECT_DIMENSIONS:
value = state.get(f"emotion_{dimension}")
if isinstance(value, bool) or not isinstance(value, (int, float)):
emotions[dimension] = None
continue
number = float(value)
emotions[dimension] = number if math.isfinite(number) and 0.0 <= number <= 1.0 else None
return emotions
def _parse_trace(raw: Any) -> ClientAffectTrace:
"""schema_version으로 v1/v2를 구분해 검증한다. 알 수 없는 버전은 예외로 503 처리된다."""
if not isinstance(raw, dict):
raise ValueError("client affect trace must be an object")
version = raw.get("schema_version")
if version == 1:
return ClientAffectTraceV1.model_validate(raw)
if version == 2:
return ClientAffectTraceV2.model_validate(raw)
raise ValueError("unsupported client affect trace schema_version")
async def list_sessions(
*,
user_id: str,
limit: int,
offset: int,
) -> AdminAffectSessionListResponse:
"""최근 회기 순으로 민감 식별자 없이 감정 trace 수를 조회한다."""
try:
async with acquire(role="admin", user_id=user_id) as conn:
total_row = await conn.fetchrow(
"""
SELECT count(*)::int AS total
FROM app.sessions
"""
)
rows = await conn.fetch(
"""
SELECT
s.id AS session_id,
COALESCE(s.persona_code, '') AS persona_code,
s.started_at,
s.ended_at IS NOT NULL AS ended,
COALESCE(trace_count.trace_count, 0)::int AS trace_count
FROM app.sessions AS s
LEFT JOIN (
SELECT session_id, count(*)::int AS trace_count
FROM app.client_affect_trace
GROUP BY session_id
) AS trace_count ON trace_count.session_id = s.id
ORDER BY s.started_at DESC, s.id DESC
LIMIT $1 OFFSET $2
""",
limit,
offset,
)
except Exception as exc:
raise AdminAffectPersistenceError("admin affect sessions are unavailable") from exc
sessions = [
AdminAffectSessionSummary(
session_id=str(row["session_id"]),
persona_code=str(row["persona_code"]),
started_at=row["started_at"],
ended=bool(row["ended"]),
trace_count=int(row["trace_count"]),
)
for row in rows
]
return AdminAffectSessionListResponse(
runtime=runtime_snapshot(),
sessions=sessions,
total=int(total_row["total"] if total_row is not None else 0),
limit=limit,
offset=offset,
)
async def get_session_detail(
*,
user_id: str,
session_id: UUID,
limit: int,
before_seq: int | None,
) -> AdminAffectSessionDetailResponse:
"""저장된 trace와 현재 snapshot만 조회하며 과거 상태를 재구성하지 않는다."""
try:
async with acquire(role="admin", user_id=user_id) as conn:
session = await conn.fetchrow(
"""
SELECT
s.id AS session_id,
COALESCE(s.persona_code, '') AS persona_code,
state.affect_state
FROM app.sessions AS s
LEFT JOIN app.session_state AS state ON state.session_id = s.id
WHERE s.id = $1::uuid
""",
session_id,
)
if session is None:
raise AdminAffectSessionNotFoundError("admin affect session not found")
total_row = await conn.fetchrow(
"""
SELECT count(*)::int AS total_traces
FROM app.client_affect_trace
WHERE session_id = $1::uuid
""",
session_id,
)
trace_rows = await conn.fetch(
"""
SELECT
turn.id AS turn_id,
turn.seq,
trace.created_at,
trace.trace
FROM app.client_affect_trace AS trace
JOIN app.turns AS turn ON turn.id = trace.turn_id
WHERE trace.session_id = $1::uuid
AND ($2::int IS NULL OR turn.seq < $2)
ORDER BY turn.seq DESC, turn.id DESC
LIMIT $3
""",
session_id,
before_seq,
limit + 1,
)
except AdminAffectSessionNotFoundError:
raise
except Exception as exc:
raise AdminAffectPersistenceError("admin affect session is unavailable") from exc
has_more = len(trace_rows) > limit
selected_rows = list(trace_rows[:limit])
try:
traces = [
AdminAffectTraceRecord(
turn_id=str(row["turn_id"]),
seq=int(row["seq"]),
created_at=row["created_at"],
trace=_parse_trace(row["trace"]),
)
for row in reversed(selected_rows)
]
except (KeyError, TypeError, ValidationError, ValueError) as exc:
raise AdminAffectPersistenceError("admin affect trace is invalid") from exc
return AdminAffectSessionDetailResponse(
session_id=str(session["session_id"]),
persona_code=str(session["persona_code"]),
current_emotions=_current_emotions(session["affect_state"]),
traces=traces,
total_traces=int(total_row["total_traces"] if total_row is not None else 0),
has_more=has_more,
)