vignette/apps/api/app/services/session_learning_producer.py
Yun Chan 16e791e044 G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

617 lines
21 KiB
Python

"""회기말 평가에서 G4 처방과 G5 독립 관찰을 파생하는 production worker.
평가 원장은 이미 커밋된 뒤 이 worker가 실행된다. 따라서 파생 원장 장애는 회기
평가 저장을 되돌리지 않는다. 카드/관찰은 durable turn UUID와 구조화된 evaluator
판정이 함께 있을 때만 만들며, 성공·mastery·transfer는 자동 추론하지 않는다.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import re
from dataclasses import dataclass
from typing import Any, Mapping
from uuid import UUID, uuid5
from .. import db
from ..contracts.deliberate_practice import (
CoachingCard,
CompetencyDefinition,
CompetencyGraph,
CompetencyState,
PracticeEvidenceRef,
PracticeTargetSpec,
ReplayActivity,
)
from . import calibration_transfer_store, deliberate_practice_store
logger = logging.getLogger(__name__)
_PRODUCER_NAMESPACE = UUID("20ae3e1e-4a36-5b22-9794-6cb0248b1740")
_PRODUCER_VERSION = "session-learning-producer-v1"
_CALIBRATION_INSTRUMENT_ID = "calibration-mirror-g5"
_CALIBRATION_INSTRUMENT_VERSION = "1.0.0"
_OBSERVATION_UNCERTAINTY = 0.5
_SEVERITY_RANK = {"major": 3, "moderate": 2, "minor": 1}
def _canonical_hash(value: Any) -> str:
encoded = json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
_RULESET_HASH = _canonical_hash(
{
"version": _PRODUCER_VERSION,
"ready_session_evaluation_required": True,
"durable_turn_uuid_required": True,
"fast_and_deep_dimension_agreement_required": True,
"auto_observation_status": "failed_only",
"auto_mastery": False,
"auto_transfer": False,
}
)
@dataclass(frozen=True, slots=True)
class CompetencySpec:
competency_id: str
criterion_id: str
label_ko: str
description: str
observable_behavior: str
@dataclass(frozen=True, slots=True)
class DurableDeviation:
turn_id: UUID
turn_seq: int
response_turn_id: UUID | None
response_turn_seq: int | None
dimension: str
severity: str
spec: CompetencySpec
@property
def evidence_turn_ids(self) -> tuple[UUID, ...]:
if self.response_turn_id is None:
return (self.turn_id,)
return (self.turn_id, self.response_turn_id)
_COMPETENCIES = {
"empathic_reflection": CompetencySpec(
competency_id="competency.empathic_reflection",
criterion_id="criterion.reflect-and-check",
label_ko="공감적 반영",
description="내담자의 핵심 정서를 짧게 반영하고 실제로 맞게 이해했는지 확인하는 미세기술이다.",
observable_behavior="핵심 정서를 한 문장으로 반영한 뒤 내담자에게 이해가 맞는지 확인한다.",
),
"open_question": CompetencySpec(
competency_id="competency.open_question",
criterion_id="criterion.open-question-one-focus",
label_ko="개방형 질문",
description="한 번에 하나의 초점을 유지하며 내담자의 탐색을 넓히는 개방형 질문 기술이다.",
observable_behavior="한 번에 하나의 초점만 담은 개방형 질문으로 내담자의 탐색을 이어간다.",
),
"rupture_repair": CompetencySpec(
competency_id="competency.rupture_repair",
criterion_id="criterion.name-and-repair-rupture",
label_ko="관계 균열 수선",
description="관계의 긴장이나 단절 신호를 알아차리고 명시적으로 확인하여 다시 협력하는 기술이다.",
observable_behavior="관계의 긴장 신호를 짚고 자신의 영향을 확인한 뒤 수선 질문을 한 번 제시한다.",
),
"collaborative_goal": CompetencySpec(
competency_id="competency.collaborative_goal",
criterion_id="criterion.confirm-shared-goal",
label_ko="협력적 목표 합의",
description="상담자의 목표를 앞세우지 않고 내담자의 언어로 회기 목표를 함께 합의하는 기술이다.",
observable_behavior="내담자의 표현을 사용해 이번 대화의 목표가 맞는지 명시적으로 합의한다.",
),
}
def _dimension_key(value: object) -> str:
return re.sub(r"[^a-z0-9가-힣]+", "_", str(value or "").strip().lower()).strip("_")
def _spec_for_dimension(value: object) -> CompetencySpec | None:
key = _dimension_key(value)
if not key:
return None
if any(
token in key
for token in (
"reflection",
"empathy",
"empathic",
"공감",
"정서반영",
"감정반영",
)
):
return _COMPETENCIES["empathic_reflection"]
if any(
token in key
for token in ("open_question", "openquestion", "개방형질문", "열린질문")
):
return _COMPETENCIES["open_question"]
if any(token in key for token in ("rupture", "repair", "균열", "수선", "관계회복")):
return _COMPETENCIES["rupture_repair"]
if any(
token in key
for token in (
"collaborative_goal",
"goal_collaboration",
"goal_alignment",
"공동목표",
"협력적목표",
"목표합의",
)
):
return _COMPETENCIES["collaborative_goal"]
return None
def _value(row: Mapping[str, Any] | Any, key: str, default: Any = None) -> Any:
try:
return row[key]
except (KeyError, TypeError):
return getattr(row, key, default)
def _deep_competency_ids(payload: Mapping[str, Any]) -> set[str]:
identifiers: set[str] = set()
deviations = payload.get("intent_deviations")
if not isinstance(deviations, list):
return identifiers
for item in deviations:
if not isinstance(item, Mapping):
continue
spec = _spec_for_dimension(item.get("dimension"))
if spec is not None:
identifiers.add(spec.competency_id)
return identifiers
async def _load_ready_source(
conn: Any,
*,
session_id: UUID,
) -> tuple[Mapping[str, Any], tuple[DurableDeviation, ...]] | None:
evaluation = await conn.fetchrow(
"""
SELECT e.status, e.scope, e.payload, s.learner_id
FROM app.session_evaluation e
JOIN app.sessions s ON s.id = e.session_id
WHERE e.session_id = $1
""",
session_id,
)
if evaluation is None:
return None
if (
_value(evaluation, "status") != "ready"
or _value(evaluation, "scope") != "session_end"
):
return None
payload = _value(evaluation, "payload", {})
if not isinstance(payload, Mapping):
return None
deep_competencies = _deep_competency_ids(payload)
if not deep_competencies:
return evaluation, ()
rows = await conn.fetch(
"""
SELECT t.id AS turn_id, t.seq AS turn_seq, c.intent_deviation,
response.id AS response_turn_id, response.seq AS response_turn_seq
FROM app.turns t
JOIN LATERAL (
SELECT sc.intent_deviation
FROM app.supervisor_comment sc
WHERE sc.turn_id = t.id AND sc.intent_deviation IS NOT NULL
ORDER BY sc.created_at DESC, sc.id DESC
LIMIT 1
) c ON TRUE
LEFT JOIN LATERAL (
SELECT next_turn.id, next_turn.seq
FROM app.turns next_turn
WHERE next_turn.session_id = t.session_id
AND next_turn.speaker = 'client'
AND next_turn.seq > t.seq
ORDER BY next_turn.seq
LIMIT 1
) response ON TRUE
WHERE t.session_id = $1 AND t.speaker = 'counselor'
ORDER BY t.seq
""",
session_id,
)
signals: list[DurableDeviation] = []
for row in rows:
deviation = _value(row, "intent_deviation", {})
if not isinstance(deviation, Mapping):
continue
spec = _spec_for_dimension(deviation.get("dimension"))
if spec is None or spec.competency_id not in deep_competencies:
continue
try:
turn_id = UUID(str(_value(row, "turn_id")))
response_value = _value(row, "response_turn_id")
response_turn_id = UUID(str(response_value)) if response_value else None
severity = str(deviation.get("severity") or "minor")
if severity not in _SEVERITY_RANK:
severity = "minor"
signals.append(
DurableDeviation(
turn_id=turn_id,
turn_seq=int(_value(row, "turn_seq")),
response_turn_id=response_turn_id,
response_turn_seq=(
int(_value(row, "response_turn_seq"))
if response_turn_id is not None
else None
),
dimension=str(deviation.get("dimension") or ""),
severity=severity,
spec=spec,
)
)
except (TypeError, ValueError):
continue
signals.sort(key=lambda item: (-_SEVERITY_RANK[item.severity], -item.turn_seq))
return evaluation, tuple(signals)
def _coaching_card(
session_id: UUID,
signal: DurableDeviation,
*,
difficulty_level: int,
) -> CoachingCard:
token = hashlib.sha256(
f"{session_id}:{signal.turn_id}:{signal.spec.competency_id}".encode("utf-8")
).hexdigest()[:20]
scene_id = f"session-{session_id.hex}-turn-{signal.turn_seq}"
evidence_refs = [
PracticeEvidenceRef(
ref_id=str(signal.turn_id),
scene_id=scene_id,
turn_index=signal.turn_seq,
actor="learner",
kind="learner_behavior",
)
]
if signal.response_turn_id is not None and signal.response_turn_seq is not None:
evidence_refs.append(
PracticeEvidenceRef(
ref_id=str(signal.response_turn_id),
scene_id=scene_id,
turn_index=signal.response_turn_seq,
actor="client",
kind="client_response",
)
)
return CoachingCard(
card_id=f"oas-g4-card-auto-{token}",
scene_id=scene_id,
coach_claim=(
f"{signal.spec.label_ko} 이탈이 확인된 장면을 다시 열어 "
f"{signal.spec.observable_behavior}"
),
evidence_refs=tuple(evidence_refs),
source_refs=(
f"session-evaluation:{session_id}:session_end",
f"turn-evaluation:{signal.turn_id}",
f"producer:{_PRODUCER_VERSION}",
),
uncertainty=_OBSERVATION_UNCERTAINTY,
counterevidence=(
f"intent_deviation:{_dimension_key(signal.dimension)}:{signal.severity}",
"unseen_transfer_not_verified",
),
targets=(
PracticeTargetSpec(
prescription_id=f"oas-g4-practice-auto-{token}",
competency_id=signal.spec.competency_id,
criterion_id=signal.spec.criterion_id,
observable_behavior=signal.spec.observable_behavior,
activity=ReplayActivity(
scenario_variant_id=f"session-{session_id.hex}-turn-{signal.turn_seq}-replay",
scenario_novelty="familiar",
difficulty_level=difficulty_level,
pause_at_evidence_ref=str(signal.turn_id),
),
),
),
)
def _initial_graph() -> CompetencyGraph:
specs = tuple(_COMPETENCIES.values())
return CompetencyGraph(
definitions=tuple(
CompetencyDefinition(
competency_id=spec.competency_id,
label_ko=spec.label_ko,
description=spec.description,
)
for spec in specs
),
states=tuple(
CompetencyState(
competency_id=spec.competency_id,
band="unassessed",
forgetting_risk=0.0,
uncertainty=1.0,
attempt_count=0,
familiar_demonstrations=0,
unseen_transfer_demonstrations=0,
highest_familiar_difficulty=0,
evidence_refs=(),
counterevidence=("unseen_transfer_not_verified",),
)
for spec in specs
),
)
async def _produce_g4(conn: Any, *, session_id: UUID) -> dict[str, Any]:
source = await _load_ready_source(conn, session_id=session_id)
if source is None:
return {"status": "skipped", "reason": "ready_session_evaluation_missing"}
evaluation, signals = source
if not signals:
return {"status": "skipped", "reason": "durable_actionable_deviation_missing"}
submission_id = uuid5(_PRODUCER_NAMESPACE, f"g4-prescription:{session_id}")
existing_snapshot = await conn.fetchrow(
"""
SELECT graph_payload
FROM app.competency_graph_snapshot
WHERE source_prescription_submission_id = $1
""",
submission_id,
)
latest_snapshot = existing_snapshot
if latest_snapshot is None:
latest_snapshot = await conn.fetchrow(
"""
SELECT graph_payload
FROM app.competency_graph_snapshot
WHERE learner_id = $1
ORDER BY snapshot_no DESC
LIMIT 1
""",
UUID(str(_value(evaluation, "learner_id"))),
)
graph = (
CompetencyGraph.model_validate(_value(latest_snapshot, "graph_payload"))
if latest_snapshot is not None
else None
)
state_by_competency = None
if graph is not None:
state_by_competency = {
state.competency_id: state
for state in graph.states
if state.band != "transfer_verified"
and not (
state.familiar_demonstrations >= 2
and state.highest_familiar_difficulty >= 5
)
}
signal = next(
(
item
for item in signals
if state_by_competency is None
or item.spec.competency_id in state_by_competency
),
None,
)
if signal is None:
return {
"status": "skipped",
"reason": "compatible_unmastered_competency_missing",
}
if graph is None:
graph = _initial_graph()
state = next(
item for item in graph.states if item.competency_id == signal.spec.competency_id
)
difficulty_level = (
min(5, state.highest_familiar_difficulty + 1)
if state.familiar_demonstrations >= 2
else 1
)
result = await deliberate_practice_store.append_prescription_submission(
conn=conn,
session_id=session_id,
submission_id=submission_id,
coaching_cards=(
_coaching_card(
session_id,
signal,
difficulty_level=difficulty_level,
),
),
graph=graph,
evidence_turn_ids=signal.evidence_turn_ids,
)
return {"status": "ready", **result}
async def _ensure_observation_model_run(
conn: Any,
*,
session_id: UUID,
history_id: UUID,
signal: DurableDeviation,
evaluation_payload: Mapping[str, Any],
) -> UUID:
input_payload = {
"session_id": str(session_id),
"history_id": str(history_id),
"evaluation_hash": _canonical_hash(evaluation_payload),
"competency_id": signal.spec.competency_id,
"dimension": _dimension_key(signal.dimension),
"severity": signal.severity,
"evidence_turn_ids": [str(item) for item in signal.evidence_turn_ids],
}
input_hash = _canonical_hash(input_payload)
model_run_id = uuid5(
_PRODUCER_NAMESPACE,
f"g5-observation-model:{history_id}:{input_hash}",
)
await conn.execute(
"""
INSERT INTO audit.model_run (
model_run_id, session_id, turn_id, agent_role, provider, model,
prompt_bundle_id, prompt_bundle_version, prompt_bundle_hash,
structured_schema_version, input_evidence_hash, status, metadata
) VALUES (
$1,$2,$3,'evaluator','vignette-runtime','session-evaluation-observation-adapter',
'session-learning-producer',$4,$5,
'calibration-performance-observation-1',$6,'ready',$7::jsonb
)
ON CONFLICT (model_run_id) DO NOTHING
""",
model_run_id,
session_id,
signal.turn_id,
_PRODUCER_VERSION,
_RULESET_HASH,
input_hash,
{
"source": "ready_session_evaluation_and_fast_turn_evaluation",
"dimension": _dimension_key(signal.dimension),
"severity": signal.severity,
"auto_mastery": False,
"auto_transfer": False,
},
)
return model_run_id
async def _produce_g5(conn: Any, *, session_id: UUID) -> dict[str, Any]:
source = await _load_ready_source(conn, session_id=session_id)
if source is None:
return {"status": "skipped", "reason": "ready_session_evaluation_missing"}
evaluation, signals = source
if not signals:
return {"status": "skipped", "reason": "durable_actionable_deviation_missing"}
rows = await conn.fetch(
"""
SELECT h.history_id, h.competency_id, l.locked_sequence
FROM app.calibration_prediction_history h
JOIN app.calibration_prediction_lock l ON l.history_id = h.history_id
LEFT JOIN app.calibration_performance_observation o ON o.history_id = h.history_id
WHERE h.session_id = $1 AND o.history_id IS NULL
ORDER BY h.created_at, h.history_id
""",
session_id,
)
if not rows:
return {"status": "skipped", "reason": "locked_prediction_missing"}
produced: list[dict[str, Any]] = []
for row in rows:
competency_id = str(_value(row, "competency_id"))
signal = next(
(item for item in signals if item.spec.competency_id == competency_id),
None,
)
if signal is None:
continue
history_id = UUID(str(_value(row, "history_id")))
model_run_id = await _ensure_observation_model_run(
conn,
session_id=session_id,
history_id=history_id,
signal=signal,
evaluation_payload=_value(evaluation, "payload", {}),
)
result = await calibration_transfer_store.append_performance_observation(
conn=conn,
submission_id=uuid5(
_PRODUCER_NAMESPACE, f"g5-observation-submission:{history_id}"
),
observation_id=uuid5(_PRODUCER_NAMESPACE, f"g5-observation:{history_id}"),
history_id=history_id,
status="failed",
source_kind="model_inferred",
perspective="independent_observer",
model_run_id=model_run_id,
instrument_id=_CALIBRATION_INSTRUMENT_ID,
instrument_version=_CALIBRATION_INSTRUMENT_VERSION,
uncertainty=_OBSERVATION_UNCERTAINTY,
evidence_turn_ids=signal.evidence_turn_ids,
counterevidence=(
f"intent_deviation:{_dimension_key(signal.dimension)}:{signal.severity}",
),
revealed_sequence=int(_value(row, "locked_sequence")) + 1,
)
produced.append(result)
if not produced:
return {"status": "skipped", "reason": "locked_competency_evidence_mismatch"}
return {"status": "ready", "observations": produced}
async def produce_session_learning_artifacts(session_id: str | UUID) -> dict[str, Any]:
"""G4/G5를 독립 트랜잭션으로 실행해 한쪽 장애를 다른 쪽과 격리한다."""
session_uuid = UUID(str(session_id))
results: dict[str, Any] = {}
for key, producer in (("g4", _produce_g4), ("g5", _produce_g5)):
try:
async with db.acquire(ai_view="evaluator", ai_context=True) as conn:
results[key] = await producer(conn, session_id=session_uuid)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.exception(
"session learning producer failed: track=%s session_id=%s",
key,
session_uuid,
)
results[key] = {"status": "failed", "error": type(exc).__name__}
return results
async def produce_locked_prediction_history(history_id: str | UUID) -> dict[str, Any]:
"""잠금이 평가보다 늦게 생기는 UI 흐름도 같은 session worker로 수렴시킨다."""
history_uuid = UUID(str(history_id))
try:
async with db.acquire(ai_view="evaluator", ai_context=True) as conn:
session_id = await conn.fetchval(
"SELECT session_id FROM app.calibration_prediction_history WHERE history_id = $1",
history_uuid,
)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.exception(
"locked prediction session lookup failed: history_id=%s", history_uuid
)
return {"status": "failed", "error": type(exc).__name__}
if session_id is None:
return {"status": "skipped", "reason": "prediction_history_missing"}
return await produce_session_learning_artifacts(UUID(str(session_id)))
__all__ = [
"produce_locked_prediction_history",
"produce_session_learning_artifacts",
]