상담자 발화 판정(A)·감정(B)·표현(C) 20문항 질문 세트, 감쇠 없는 이번 턴 반응과 비대칭 기분 전이, 개방도 게이트로 생성 지시를 만들고 ccd.coping_strategy 전달 누락을 고친다. trace v2와 고정 문구 속마음 요약(migration 24, AI 경로 차단 RLS)을 같은 트랜잭션에 저장하고 피드백 정책이 켜진 경우에만 done·TurnResponse·음성 reply·리뷰로 노출한다. 회기 화면 속마음 보기 토글, 리뷰 접힘 블록, 관리자 감정 관측 v2 표시를 추가한다.
347 lines
12 KiB
Python
347 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,
|
|
)
|
|
client_turn = 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,
|
|
)
|
|
if ctx.client_affect_trace is not None:
|
|
await session_persistence.append_client_turn_with_affect_trace(
|
|
session_id=sess.session_id,
|
|
learner_id=sess.learner_id,
|
|
turn=client_turn,
|
|
state=result.state_after,
|
|
trace=ctx.client_affect_trace,
|
|
inner_reaction=ctx.client_inner_reaction,
|
|
)
|
|
sess.turns.append(client_turn)
|
|
sess.state = result.state_after
|
|
store.put(sess)
|
|
return learner_turn
|
|
await append_completed_turn(
|
|
sess,
|
|
client_turn,
|
|
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",
|
|
]
|