333 lines
12 KiB
Python
333 lines
12 KiB
Python
"""REST/WS 공용 턴 런타임 헬퍼.
|
|
|
|
세션 로드, 오너십 검증, 완료 턴 영속화, 상태 갱신은 REST 세션 라우트와
|
|
음성 WebSocket 라우트가 같은 규칙을 공유해야 한다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from enum import Enum
|
|
import logging
|
|
from typing import Any, Optional
|
|
|
|
from . import db, session_persistence
|
|
from .deps import Principal
|
|
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
|
|
from .services import guardrail, orchestrator, state_machine
|
|
from .stage_contract import stage_label
|
|
from .store import InProcSession, TurnRecord, store
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_LIVE_COACH_RECHARGE_MIN_RAPPORT = 0.15
|
|
_LIVE_COACH_RECHARGE_NEUTRAL_MIN_RAPPORT = 0.35
|
|
_LIVE_COACH_RECHARGE_MIN_OPENNESS_GAIN = 0.01
|
|
# 페이싱 충전: 평가 신호와 무관하게 N턴마다 1개. 코칭이 가장 필요한(잘 못 풀리는)
|
|
# 학습자가 3개 소진 후 영영 코칭을 못 받는 순감 구조를 막는다(2026-07-13 회의 후속).
|
|
_LIVE_COACH_PACING_RECHARGE_EVERY_TURNS = 6
|
|
|
|
|
|
def _role_mask_evaluation_value(value: Any, sess: InProcSession) -> Any:
|
|
"""Role-tokenize evaluator text before normalized rows or cache can persist it."""
|
|
|
|
if isinstance(value, str):
|
|
return guardrail.mask_role_identities(
|
|
value,
|
|
counselor_identity=sess.learner_label,
|
|
client_identity=sess.persona.display_name,
|
|
synthetic_generated=True,
|
|
).text_masked
|
|
if isinstance(value, dict):
|
|
return {
|
|
key: _role_mask_evaluation_value(child, sess)
|
|
for key, child in value.items()
|
|
}
|
|
if isinstance(value, list):
|
|
return [_role_mask_evaluation_value(child, sess) for child in value]
|
|
if isinstance(value, tuple):
|
|
return [_role_mask_evaluation_value(child, sess) for child in value]
|
|
return value
|
|
|
|
|
|
class SessionAccessError(str, Enum):
|
|
NOT_FOUND = "not_found"
|
|
FORBIDDEN = "forbidden"
|
|
ENDED = "ended"
|
|
|
|
|
|
async def load_owned_session(
|
|
session_id: str,
|
|
principal: Principal,
|
|
*,
|
|
allow_ended: bool = False,
|
|
include_turn_evaluation: bool = False,
|
|
) -> tuple[InProcSession | None, Optional[SessionAccessError]]:
|
|
"""DB 우선으로 학습자 소유 세션을 로드하고 접근 오류를 코드로 반환한다."""
|
|
sess = await session_persistence.load_session(
|
|
session_id,
|
|
principal,
|
|
allow_ended=True,
|
|
include_turn_evaluation=include_turn_evaluation,
|
|
)
|
|
if sess is not None:
|
|
store.put(sess)
|
|
elif runtime_fallback_allowed():
|
|
sess = store.get(session_id)
|
|
if sess is None:
|
|
return None, SessionAccessError.NOT_FOUND
|
|
if sess.learner_id != principal.user_id:
|
|
return None, SessionAccessError.FORBIDDEN
|
|
if sess.ended and not allow_ended:
|
|
return None, SessionAccessError.ENDED
|
|
return sess, None
|
|
|
|
|
|
async def append_completed_turn(
|
|
sess: InProcSession,
|
|
turn: TurnRecord,
|
|
*,
|
|
context: str,
|
|
) -> None:
|
|
"""완료된 턴을 DB와 in-process 미러에 기록한다."""
|
|
if await session_persistence.append_turn(
|
|
session_id=sess.session_id,
|
|
learner_id=sess.learner_id,
|
|
turn=turn,
|
|
):
|
|
sess.turns.append(turn)
|
|
store.put(sess)
|
|
return
|
|
require_runtime_fallback_allowed(context)
|
|
store.append_turn(sess.session_id, turn)
|
|
|
|
|
|
async def update_session_state(
|
|
sess: InProcSession,
|
|
state: state_machine.SessionState,
|
|
*,
|
|
context: str,
|
|
) -> None:
|
|
"""working state를 DB와 in-process 미러에 반영한다."""
|
|
if await session_persistence.update_state(
|
|
session_id=sess.session_id,
|
|
learner_id=sess.learner_id,
|
|
state=state,
|
|
):
|
|
sess.state = state
|
|
store.put(sess)
|
|
return
|
|
require_runtime_fallback_allowed(context)
|
|
store.update_state(sess.session_id, state)
|
|
|
|
|
|
async def record_completed_turn(
|
|
sess: InProcSession,
|
|
ctx: orchestrator.TurnContext,
|
|
result: orchestrator.TurnResult,
|
|
*,
|
|
context_prefix: str,
|
|
counselor_turn: TurnRecord | None = None,
|
|
) -> TurnRecord:
|
|
"""상담자 발화와 내담자 응답을 한 번에 기록하고 상태를 갱신한다."""
|
|
assert ctx.state_after is not None
|
|
learner_turn = counselor_turn or TurnRecord(
|
|
turn_seq=ctx.state_after.turn_seq,
|
|
speaker="counselor",
|
|
stage=stage_label(ctx.state_after.stage),
|
|
text=ctx.learner_text_raw,
|
|
text_masked=ctx.learner_text_masked,
|
|
evaluation=_role_mask_evaluation_value(result.evaluation, sess),
|
|
)
|
|
if counselor_turn is not None:
|
|
learner_turn.evaluation = _role_mask_evaluation_value(
|
|
learner_turn.evaluation,
|
|
sess,
|
|
)
|
|
await append_completed_turn(
|
|
sess,
|
|
learner_turn,
|
|
context=f"{context_prefix} turn append",
|
|
)
|
|
if result.client_reply:
|
|
# result.client_reply는 출력 가드레일을 통과한 합성 내담자 발화다.
|
|
# 입력용의 광범위한 성씨 휴리스틱을 다시 적용하면 "모르겠는데요" 같은
|
|
# 일반 용언이 [NAME]으로 오탐되어 저장·재개 화면에 누출된다.
|
|
client_mask = guardrail.mask_role_identities(
|
|
result.client_reply,
|
|
counselor_identity=sess.learner_label,
|
|
client_identity=sess.persona.display_name,
|
|
synthetic_generated=True,
|
|
)
|
|
await append_completed_turn(
|
|
sess,
|
|
TurnRecord(
|
|
turn_seq=result.turn_seq,
|
|
speaker="client",
|
|
stage=stage_label(result.state_after.stage),
|
|
text=result.client_reply,
|
|
text_masked=client_mask.text_masked,
|
|
llm_provider=result.llm_provider,
|
|
model=result.model,
|
|
tokens_in=result.tokens_in,
|
|
tokens_out=result.tokens_out,
|
|
cost_usd=result.cost_usd,
|
|
),
|
|
context=f"{context_prefix} turn append",
|
|
)
|
|
await update_session_state(
|
|
sess,
|
|
result.state_after,
|
|
context=f"{context_prefix} state update",
|
|
)
|
|
return learner_turn
|
|
|
|
|
|
async def record_safety_event(
|
|
sess: InProcSession,
|
|
ctx: orchestrator.TurnContext,
|
|
result: orchestrator.TurnResult,
|
|
) -> None:
|
|
"""위기 escalate 시 app.safety_events에 교수자 확인용 알림 레코드를 남긴다."""
|
|
crisis = getattr(ctx, "crisis", None)
|
|
if crisis is None or not getattr(crisis, "escalate", False):
|
|
return
|
|
kind = getattr(crisis.kind, "value", None) or str(getattr(crisis, "kind", "crisis"))
|
|
detail = {
|
|
"matched": list(getattr(crisis, "matched", []) or []),
|
|
"stage": getattr(result, "stage", None),
|
|
"turn_seq": getattr(result, "turn_seq", None),
|
|
"conversation_stopped": getattr(result, "conversation_stopped", False),
|
|
"crisis_resource": getattr(result, "crisis_resource", None),
|
|
"alert_status": "teacher_dashboard",
|
|
}
|
|
try:
|
|
async with db.acquire(ai_context=True) as conn:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.safety_events
|
|
(session_id, trigger_type, ko_risk_level, escalated, detail)
|
|
VALUES ($1::uuid, $2, $3, TRUE, $4::jsonb)
|
|
""",
|
|
sess.session_id,
|
|
kind,
|
|
int(getattr(crisis, "risk_level", 0) or 0),
|
|
detail,
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"safety event persistence failed: session_id=%s trigger_type=%s",
|
|
sess.session_id,
|
|
kind,
|
|
)
|
|
require_runtime_fallback_allowed("safety event")
|
|
|
|
|
|
def should_recharge_live_coach_credit(
|
|
evaluation: dict | None,
|
|
before: state_machine.SessionState,
|
|
after: state_machine.SessionState,
|
|
) -> tuple[bool, str]:
|
|
"""좋은 발화 충전 + 진행 페이싱 충전 게이트.
|
|
|
|
기존 3중 AND(appropriateness==pos + rapport>=0.35 + 단계전환/개방도+0.02)는
|
|
실사용에서 충전이 사실상 불가능해 코칭이 3턴 만에 죽었다. 완화된 성과 충전에
|
|
페이싱 충전을 더해, 잘하는 학습자는 빨리·모두가 주기적으로 충전받게 한다.
|
|
"""
|
|
appropriateness = evaluation.get("appropriateness") if isinstance(evaluation, dict) else None
|
|
rapport = 0.0
|
|
if isinstance(evaluation, dict):
|
|
try:
|
|
rapport = float(evaluation.get("rapport_signal") or 0)
|
|
except (TypeError, ValueError):
|
|
rapport = 0.0
|
|
openness_gain = float(after.effective_openness or 0) - float(before.effective_openness or 0)
|
|
stage_changed = after.stage != before.stage
|
|
|
|
if appropriateness == "pos" and rapport >= _LIVE_COACH_RECHARGE_MIN_RAPPORT:
|
|
if stage_changed:
|
|
return True, "좋은 발화로 내담자 단계가 열려 코칭 기회 1개를 충전했습니다."
|
|
if openness_gain >= _LIVE_COACH_RECHARGE_MIN_OPENNESS_GAIN:
|
|
return True, "좋은 발화 뒤 내담자 개방도가 올라 코칭 기회 1개를 충전했습니다."
|
|
if (
|
|
appropriateness == "neutral"
|
|
and rapport >= _LIVE_COACH_RECHARGE_NEUTRAL_MIN_RAPPORT
|
|
and openness_gain > 0
|
|
):
|
|
return True, "안정적인 라포 신호로 코칭 기회 1개를 충전했습니다."
|
|
|
|
# 페이싱 충전 — 평가 실패(evaluation=None)여도 회기 진행 자체로 충전된다.
|
|
turn_seq = int(after.turn_seq or 0)
|
|
if turn_seq > 0 and turn_seq % _LIVE_COACH_PACING_RECHARGE_EVERY_TURNS == 0:
|
|
return (
|
|
True,
|
|
f"회기가 {_LIVE_COACH_PACING_RECHARGE_EVERY_TURNS}턴 진행되어 코칭 기회 1개를 충전했습니다.",
|
|
)
|
|
return False, ""
|
|
|
|
|
|
async def maybe_recharge_live_coach_credit(
|
|
sess: InProcSession,
|
|
ctx: orchestrator.TurnContext,
|
|
result: orchestrator.TurnResult,
|
|
) -> None:
|
|
"""Record one live-coach recharge when a strong learner turn changes client state."""
|
|
assert ctx.state_after is not None
|
|
should_recharge, reason = should_recharge_live_coach_credit(
|
|
result.evaluation,
|
|
ctx.state_before,
|
|
result.state_after,
|
|
)
|
|
if not should_recharge:
|
|
return
|
|
try:
|
|
await session_persistence.record_live_coach_recharge(
|
|
session_id=sess.session_id,
|
|
learner_id=sess.learner_id,
|
|
turn_seq=result.turn_seq,
|
|
stage=stage_label(result.state_after.stage),
|
|
reason=reason,
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"live coach recharge persistence failed: session_id=%s turn_seq=%s",
|
|
sess.session_id,
|
|
result.turn_seq,
|
|
)
|
|
require_runtime_fallback_allowed("live coach recharge")
|
|
|
|
|
|
async def finalize_completed_turn(
|
|
sess: InProcSession,
|
|
ctx: orchestrator.TurnContext,
|
|
result: orchestrator.TurnResult,
|
|
*,
|
|
context_prefix: str,
|
|
counselor_turn: TurnRecord | None = None,
|
|
recharge_live_coach: bool = True,
|
|
) -> TurnRecord:
|
|
"""Persist a completed turn and emit any derived safety alert in route-safe order."""
|
|
learner_turn = await record_completed_turn(
|
|
sess,
|
|
ctx,
|
|
result,
|
|
context_prefix=context_prefix,
|
|
counselor_turn=counselor_turn,
|
|
)
|
|
if recharge_live_coach:
|
|
await maybe_recharge_live_coach_credit(sess, ctx, result)
|
|
await record_safety_event(sess, ctx, result)
|
|
return learner_turn
|
|
|
|
|
|
__all__ = [
|
|
"SessionAccessError",
|
|
"append_completed_turn",
|
|
"finalize_completed_turn",
|
|
"load_owned_session",
|
|
"maybe_recharge_live_coach_credit",
|
|
"record_safety_event",
|
|
"record_completed_turn",
|
|
"should_recharge_live_coach_credit",
|
|
"stage_label",
|
|
"update_session_state",
|
|
]
|