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 산출물은 커밋에서 제외했다.
This commit is contained in:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
272
apps/api/app/test_session_learning_producer.py
Normal file
272
apps/api/app/test_session_learning_producer.py
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from .routes import calibration_transfer, sessions
|
||||
from .services import evaluator, session_learning_producer, state_machine
|
||||
from .services.persona import P1
|
||||
from .store import InProcSession, TurnRecord
|
||||
|
||||
|
||||
SESSION_ID = UUID("00000000-0000-0000-0000-00000000a401")
|
||||
LEARNER_ID = UUID("00000000-0000-0000-0000-00000000a402")
|
||||
COUNSELOR_TURN_ID = UUID("00000000-0000-0000-0000-00000000a403")
|
||||
CLIENT_TURN_ID = UUID("00000000-0000-0000-0000-00000000a404")
|
||||
|
||||
|
||||
class FakeProducerConnection:
|
||||
def __init__(self, *, locked_history: bool = False) -> None:
|
||||
self.locked_history = locked_history
|
||||
self.executed: list[tuple[str, tuple[Any, ...]]] = []
|
||||
self.evaluation = {
|
||||
"status": "ready",
|
||||
"scope": "session_end",
|
||||
"learner_id": LEARNER_ID,
|
||||
"payload": {
|
||||
"loop": "deep",
|
||||
"scope": "session_end",
|
||||
"intent_deviations": [
|
||||
{
|
||||
"dimension": "reflection",
|
||||
"expected": "정서를 반영하고 이해를 확인한다",
|
||||
"actual": "바로 다음 질문으로 이동했다",
|
||||
"severity": "moderate",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any] | None:
|
||||
if "FROM app.session_evaluation" in query:
|
||||
return self.evaluation
|
||||
if "FROM app.competency_graph_snapshot" in query:
|
||||
return None
|
||||
raise AssertionError(f"unexpected fetchrow: {query}")
|
||||
|
||||
async def fetch(self, query: str, *args: Any) -> list[dict[str, Any]]:
|
||||
if "FROM app.turns t" in query:
|
||||
return [
|
||||
{
|
||||
"turn_id": COUNSELOR_TURN_ID,
|
||||
"turn_seq": 3,
|
||||
"response_turn_id": CLIENT_TURN_ID,
|
||||
"response_turn_seq": 4,
|
||||
"intent_deviation": {
|
||||
"dimension": "공감적 반영",
|
||||
"expected": "정서를 반영하고 이해를 확인한다",
|
||||
"actual": "바로 다음 질문으로 이동했다",
|
||||
"severity": "moderate",
|
||||
},
|
||||
}
|
||||
]
|
||||
if "FROM app.calibration_prediction_history h" in query:
|
||||
if not self.locked_history:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"history_id": UUID("00000000-0000-0000-0000-00000000a405"),
|
||||
"competency_id": "competency.empathic_reflection",
|
||||
"locked_sequence": 2,
|
||||
}
|
||||
]
|
||||
raise AssertionError(f"unexpected fetch: {query}")
|
||||
|
||||
async def execute(self, query: str, *args: Any) -> str:
|
||||
self.executed.append((query, args))
|
||||
return "INSERT 0 1"
|
||||
|
||||
|
||||
class SessionLearningProducerTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_g4_uses_durable_turns_and_replays_stable_submission(self) -> None:
|
||||
conn = FakeProducerConnection()
|
||||
append = AsyncMock(
|
||||
side_effect=[
|
||||
{"submission_id": uuid4(), "idempotent_replay": False},
|
||||
{"submission_id": uuid4(), "idempotent_replay": True},
|
||||
]
|
||||
)
|
||||
with patch.object(
|
||||
session_learning_producer.deliberate_practice_store,
|
||||
"append_prescription_submission",
|
||||
append,
|
||||
):
|
||||
first = await session_learning_producer._produce_g4(
|
||||
conn, session_id=SESSION_ID
|
||||
)
|
||||
second = await session_learning_producer._produce_g4(
|
||||
conn, session_id=SESSION_ID
|
||||
)
|
||||
|
||||
self.assertEqual(first["status"], "ready")
|
||||
self.assertEqual(second["status"], "ready")
|
||||
self.assertEqual(append.await_count, 2)
|
||||
first_call = append.await_args_list[0].kwargs
|
||||
second_call = append.await_args_list[1].kwargs
|
||||
self.assertEqual(first_call["submission_id"], second_call["submission_id"])
|
||||
self.assertEqual(first_call["coaching_cards"], second_call["coaching_cards"])
|
||||
self.assertEqual(
|
||||
tuple(first_call["evidence_turn_ids"]),
|
||||
(COUNSELOR_TURN_ID, CLIENT_TURN_ID),
|
||||
)
|
||||
card = first_call["coaching_cards"][0]
|
||||
self.assertEqual(
|
||||
tuple(item.ref_id for item in card.evidence_refs),
|
||||
(str(COUNSELOR_TURN_ID), str(CLIENT_TURN_ID)),
|
||||
)
|
||||
graph = first_call["graph"]
|
||||
self.assertEqual(len(graph.states), 4)
|
||||
self.assertTrue(all(state.band == "unassessed" for state in graph.states))
|
||||
self.assertTrue(all(state.attempt_count == 0 for state in graph.states))
|
||||
self.assertTrue(
|
||||
all(state.unseen_transfer_demonstrations == 0 for state in graph.states)
|
||||
)
|
||||
|
||||
async def test_g5_does_not_reveal_before_prediction_lock(self) -> None:
|
||||
conn = FakeProducerConnection(locked_history=False)
|
||||
append = AsyncMock()
|
||||
with patch.object(
|
||||
session_learning_producer.calibration_transfer_store,
|
||||
"append_performance_observation",
|
||||
append,
|
||||
):
|
||||
result = await session_learning_producer._produce_g5(
|
||||
conn, session_id=SESSION_ID
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
result, {"status": "skipped", "reason": "locked_prediction_missing"}
|
||||
)
|
||||
append.assert_not_awaited()
|
||||
self.assertFalse(
|
||||
any("INSERT INTO audit.model_run" in query for query, _ in conn.executed)
|
||||
)
|
||||
|
||||
async def test_g5_locked_history_gets_failed_observation_with_real_provenance(
|
||||
self,
|
||||
) -> None:
|
||||
conn = FakeProducerConnection(locked_history=True)
|
||||
append = AsyncMock(
|
||||
return_value={"observation_id": uuid4(), "idempotent_replay": False}
|
||||
)
|
||||
with patch.object(
|
||||
session_learning_producer.calibration_transfer_store,
|
||||
"append_performance_observation",
|
||||
append,
|
||||
):
|
||||
result = await session_learning_producer._produce_g5(
|
||||
conn, session_id=SESSION_ID
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "ready")
|
||||
self.assertTrue(
|
||||
any("INSERT INTO audit.model_run" in query for query, _ in conn.executed)
|
||||
)
|
||||
kwargs = append.await_args.kwargs
|
||||
self.assertEqual(kwargs["status"], "failed")
|
||||
self.assertEqual(kwargs["source_kind"], "model_inferred")
|
||||
self.assertEqual(kwargs["perspective"], "independent_observer")
|
||||
self.assertIsInstance(kwargs["model_run_id"], UUID)
|
||||
self.assertEqual(
|
||||
tuple(kwargs["evidence_turn_ids"]),
|
||||
(COUNSELOR_TURN_ID, CLIENT_TURN_ID),
|
||||
)
|
||||
self.assertEqual(kwargs["revealed_sequence"], 3)
|
||||
self.assertNotIn("master", repr(kwargs).lower())
|
||||
self.assertNotIn("transfer_verified", repr(kwargs))
|
||||
|
||||
async def test_ready_evaluation_is_not_rolled_back_when_producer_fails(
|
||||
self,
|
||||
) -> None:
|
||||
state = state_machine.init_state(params=P1.openness_params())
|
||||
sess = InProcSession(
|
||||
session_id=str(SESSION_ID),
|
||||
case_id=str(uuid4()),
|
||||
learner_id=str(LEARNER_ID),
|
||||
persona_code=P1.code,
|
||||
theory_mode="humanistic",
|
||||
persona=P1,
|
||||
state=state,
|
||||
turns=[
|
||||
TurnRecord(
|
||||
turn_seq=1,
|
||||
speaker="counselor",
|
||||
stage=state.stage.value,
|
||||
text="상담자 발화",
|
||||
text_masked="상담자 발화",
|
||||
)
|
||||
],
|
||||
ended=True,
|
||||
)
|
||||
ready = evaluator.SessionEvaluation(
|
||||
session_id=str(SESSION_ID),
|
||||
stage=state.stage.value,
|
||||
scope="session_end",
|
||||
improvements=["정서를 반영한 뒤 이해를 확인한다."],
|
||||
)
|
||||
save = AsyncMock(return_value=True)
|
||||
notify = AsyncMock()
|
||||
with (
|
||||
patch.object(
|
||||
sessions.evaluator, "evaluate_session", AsyncMock(return_value=ready)
|
||||
),
|
||||
patch.object(sessions.session_persistence, "save_session_evaluation", save),
|
||||
patch.object(
|
||||
sessions.session_learning_producer,
|
||||
"produce_session_learning_artifacts",
|
||||
AsyncMock(side_effect=RuntimeError("producer offline")),
|
||||
),
|
||||
patch.object(
|
||||
sessions, "_enqueue_session_review_ready_notification", notify
|
||||
),
|
||||
):
|
||||
await sessions._generate_and_save_session_evaluation(sess)
|
||||
|
||||
save.assert_awaited_once()
|
||||
self.assertEqual(save.await_args.args[0].status, "ready")
|
||||
notify.assert_awaited_once_with(str(SESSION_ID))
|
||||
|
||||
async def test_prediction_lock_invokes_same_session_worker(self) -> None:
|
||||
history_id = uuid4()
|
||||
body = calibration_transfer.PredictionLockRequest(
|
||||
submission_id=uuid4(),
|
||||
lock_id=uuid4(),
|
||||
prediction_revision_id=uuid4(),
|
||||
locked_sequence=2,
|
||||
)
|
||||
payload = {
|
||||
"submission_id": body.submission_id,
|
||||
"history_id": history_id,
|
||||
"lock_id": body.lock_id,
|
||||
"idempotent_replay": False,
|
||||
}
|
||||
principal = calibration_transfer.Principal(
|
||||
user_id=str(LEARNER_ID),
|
||||
role=calibration_transfer.Role.LEARNER,
|
||||
cohort_ids=["g5-test"],
|
||||
)
|
||||
worker = AsyncMock(return_value={"g5": {"status": "ready"}})
|
||||
with (
|
||||
patch.object(
|
||||
calibration_transfer.calibration_transfer_store,
|
||||
"append_prediction_lock",
|
||||
AsyncMock(return_value=payload),
|
||||
),
|
||||
patch.object(
|
||||
calibration_transfer.session_learning_producer,
|
||||
"produce_locked_prediction_history",
|
||||
worker,
|
||||
),
|
||||
):
|
||||
response = await calibration_transfer.lock_prediction_history(
|
||||
history_id, body, principal
|
||||
)
|
||||
|
||||
self.assertEqual(response.history_id, history_id)
|
||||
worker.assert_awaited_once_with(history_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue