관리자 감정 관측 기록과 조회 API 추가
This commit is contained in:
parent
d22cd9883d
commit
acb0d26338
16 changed files with 1445 additions and 18 deletions
192
apps/api/app/services/admin_affect.py
Normal file
192
apps/api/app/services/admin_affect.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"""관리자 감정 관측용 영속 조회."""
|
||||
|
||||
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, ClientAffectTraceV1
|
||||
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
|
||||
|
||||
|
||||
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=ClientAffectTraceV1.model_validate(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,
|
||||
)
|
||||
|
|
@ -6,6 +6,12 @@ import math
|
|||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from ..contracts.client_affect import (
|
||||
ClientAffectContextV1,
|
||||
ClientAffectDimensionTraceV1,
|
||||
ClientAffectPolicyV1,
|
||||
ClientAffectTraceV1,
|
||||
)
|
||||
from . import guardrail
|
||||
from .jev_client import AppraisalResult, EMOTION_DIMENSIONS
|
||||
|
||||
|
|
@ -40,7 +46,13 @@ _NEGATIVE_EMOTIONS = frozenset(
|
|||
{"anxiety", "sadness", "anger", "shame", "guilt", "loneliness"}
|
||||
)
|
||||
_POSITIVE_EMOTIONS = frozenset({"relief", "hope", "trust"})
|
||||
_AFFECT_POLICY_VERSION = "jev-affect-v1"
|
||||
_ACCEPTED_ALPHA = 0.35
|
||||
_ACCEPTED_CAP = 0.15
|
||||
_TENTATIVE_ALPHA = 0.15
|
||||
_TENTATIVE_CAP = 0.075
|
||||
_TENTATIVE_CONFIDENCE_FLOOR = 0.35
|
||||
_ADJACENT_PROBABILITY_THRESHOLD = 0.8
|
||||
_PROBABILITY_SUM_TOLERANCE = 0.025000001
|
||||
|
||||
|
||||
|
|
@ -83,7 +95,10 @@ def _tentative_distribution_is_concentrated(probabilities: Any) -> bool:
|
|||
if not math.isclose(total, 1.0, abs_tol=_PROBABILITY_SUM_TOLERANCE):
|
||||
return False
|
||||
normalized = tuple(value / total for value in values if value is not None)
|
||||
return max(normalized[index] + normalized[index + 1] for index in range(4)) >= 0.80
|
||||
return (
|
||||
max(normalized[index] + normalized[index + 1] for index in range(4))
|
||||
>= _ADJACENT_PROBABILITY_THRESHOLD
|
||||
)
|
||||
|
||||
|
||||
def _baseline_value(affect_baseline: Mapping[str, Any], key: str) -> float | None:
|
||||
|
|
@ -163,15 +178,15 @@ def transition_emotions(
|
|||
held.append(dimension)
|
||||
continue
|
||||
if confidence >= threshold:
|
||||
alpha = 0.35
|
||||
cap = 0.15
|
||||
alpha = _ACCEPTED_ALPHA
|
||||
cap = _ACCEPTED_CAP
|
||||
elif (
|
||||
confidence >= _TENTATIVE_CONFIDENCE_FLOOR
|
||||
and _tentative_distribution_is_concentrated(estimate.probabilities)
|
||||
):
|
||||
# confidence는 정답 확률이 아니라 분포 집중도 요약이다.
|
||||
alpha = 0.15
|
||||
cap = 0.075
|
||||
alpha = _TENTATIVE_ALPHA
|
||||
cap = _TENTATIVE_CAP
|
||||
tentative.append(dimension)
|
||||
else:
|
||||
updated[f"emotion_{dimension}"] = old
|
||||
|
|
@ -189,6 +204,94 @@ def transition_emotions(
|
|||
)
|
||||
|
||||
|
||||
def _trace_probabilities(value: Any) -> tuple[float, float, float, float, float] | None:
|
||||
if not isinstance(value, tuple) or len(value) != 5:
|
||||
return None
|
||||
normalized = tuple(_unit_number(item) for item in value)
|
||||
if any(item is None for item in normalized):
|
||||
return None
|
||||
return (
|
||||
normalized[0],
|
||||
normalized[1],
|
||||
normalized[2],
|
||||
normalized[3],
|
||||
normalized[4],
|
||||
)
|
||||
|
||||
|
||||
def build_client_affect_trace(
|
||||
*,
|
||||
affect_state_before: Mapping[str, Any],
|
||||
affect_baseline: Mapping[str, Any],
|
||||
affect_state_after: Mapping[str, Any],
|
||||
appraisal: AppraisalResult,
|
||||
transition: AffectTransition,
|
||||
turn_seq: int,
|
||||
stage: str,
|
||||
resistance: float,
|
||||
effective_openness: float,
|
||||
rapport_credit: float,
|
||||
min_confidence: float,
|
||||
) -> ClientAffectTraceV1:
|
||||
"""전이와 같은 입력으로 관리자 전용 trace를 고정 순서로 만든다."""
|
||||
before = resolve_emotions(affect_state_before, affect_baseline)
|
||||
after = resolve_emotions(affect_state_after, affect_baseline)
|
||||
tentative = set(transition.tentative_dimensions)
|
||||
accepted = set(transition.accepted_dimensions)
|
||||
dimensions: list[ClientAffectDimensionTraceV1] = []
|
||||
for key in EMOTION_DIMENSIONS:
|
||||
estimate = appraisal.emotions.get(key)
|
||||
target = _unit_number(estimate.score) if estimate is not None else None
|
||||
confidence = _unit_number(estimate.confidence) if estimate is not None else None
|
||||
probabilities = (
|
||||
_trace_probabilities(estimate.probabilities) if estimate is not None else None
|
||||
)
|
||||
if key in tentative:
|
||||
decision = "tentative"
|
||||
elif key in accepted:
|
||||
decision = "accepted"
|
||||
else:
|
||||
decision = "held"
|
||||
dimensions.append(
|
||||
ClientAffectDimensionTraceV1(
|
||||
key=key,
|
||||
before=before[key],
|
||||
target=target,
|
||||
after=after[key],
|
||||
confidence=confidence,
|
||||
probabilities=probabilities,
|
||||
decision=decision,
|
||||
)
|
||||
)
|
||||
return ClientAffectTraceV1(
|
||||
schema_version=1,
|
||||
provider=appraisal.provider,
|
||||
model=appraisal.model,
|
||||
latency_ms=appraisal.latency_ms,
|
||||
input_tokens=appraisal.input_tokens,
|
||||
output_tokens=appraisal.output_tokens,
|
||||
cost_usd=appraisal.cost_usd,
|
||||
turn_seq=turn_seq,
|
||||
policy=ClientAffectPolicyV1(
|
||||
version=_AFFECT_POLICY_VERSION,
|
||||
min_confidence=min_confidence,
|
||||
accepted_alpha=_ACCEPTED_ALPHA,
|
||||
accepted_cap=_ACCEPTED_CAP,
|
||||
tentative_alpha=_TENTATIVE_ALPHA,
|
||||
tentative_cap=_TENTATIVE_CAP,
|
||||
tentative_confidence_floor=_TENTATIVE_CONFIDENCE_FLOOR,
|
||||
adjacent_probability_threshold=_ADJACENT_PROBABILITY_THRESHOLD,
|
||||
),
|
||||
context=ClientAffectContextV1(
|
||||
stage=stage,
|
||||
resistance=resistance,
|
||||
effective_openness=effective_openness,
|
||||
rapport_credit=rapport_credit,
|
||||
),
|
||||
dimensions=tuple(dimensions),
|
||||
)
|
||||
|
||||
|
||||
def _mask_text(
|
||||
value: Any,
|
||||
*,
|
||||
|
|
@ -384,6 +487,7 @@ def public_end_state(end_state: Mapping[str, Any]) -> dict[str, Any]:
|
|||
__all__ = [
|
||||
"AffectTransition",
|
||||
"baseline_emotions",
|
||||
"build_client_affect_trace",
|
||||
"build_appraisal_state",
|
||||
"public_end_state",
|
||||
"resolve_emotions",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import time
|
|||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any, AsyncIterator, Awaitable, Callable, Optional
|
||||
|
||||
from ..contracts.client_affect import ClientAffectTraceV1
|
||||
from ..config import settings
|
||||
from ..engine_client import (
|
||||
EngineClient,
|
||||
|
|
@ -94,6 +95,8 @@ class TurnContext:
|
|||
scenario_directive: Optional[rupture_scenario_director.ScenarioDirective] = None
|
||||
# 외부 감정 평가의 안전한 provenance. 원문·점수·확률은 넣지 않는다.
|
||||
client_affect_metadata: Optional[dict[str, Any]] = None
|
||||
# 관리자 관측 전용 Jev 전이 trace. 공개 결과나 provider event에는 넣지 않는다.
|
||||
client_affect_trace: ClientAffectTraceV1 | None = None
|
||||
|
||||
def to_state_context(self) -> PersonaStateContext:
|
||||
st = self.state_after or self.state_before
|
||||
|
|
@ -378,13 +381,27 @@ async def _apply_client_affect(
|
|||
client_identity=ctx.client_identity,
|
||||
)
|
||||
appraisal = await jev_client.appraise(state)
|
||||
state_before_transition = ctx.state_after
|
||||
transition = client_affect.transition_emotions(
|
||||
ctx.state_after.affect_state,
|
||||
state_before_transition.affect_state,
|
||||
ctx.persona.affect_baseline,
|
||||
appraisal,
|
||||
min_confidence=settings.jev_min_confidence,
|
||||
)
|
||||
ctx.state_after = replace(ctx.state_after, affect_state=transition.affect_state)
|
||||
ctx.client_affect_trace = client_affect.build_client_affect_trace(
|
||||
affect_state_before=state_before_transition.affect_state,
|
||||
affect_baseline=ctx.persona.affect_baseline,
|
||||
affect_state_after=ctx.state_after.affect_state,
|
||||
appraisal=appraisal,
|
||||
transition=transition,
|
||||
turn_seq=ctx.state_after.turn_seq,
|
||||
stage=ctx.state_after.stage.value,
|
||||
resistance=ctx.state_after.resistance,
|
||||
effective_openness=ctx.state_after.effective_openness,
|
||||
rapport_credit=ctx.state_after.rapport_credit,
|
||||
min_confidence=settings.jev_min_confidence,
|
||||
)
|
||||
ctx.client_affect_metadata = {
|
||||
"provider": appraisal.provider,
|
||||
"model": appraisal.model,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue