1423 lines
55 KiB
Python
1423 lines
55 KiB
Python
import ast
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test")
|
|
|
|
from src.api import server
|
|
from src.api import training as training_api
|
|
from src.services.training import TrainingCaseService
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
TRAINING_PRODUCT_DOC = ROOT / "docs" / "training_console_public_surface_v1.md"
|
|
INTERNAL_FIELD = "answer" + "_key"
|
|
PRIVATE_MARKER = "trainer-only-choice"
|
|
PRIVATE_RUBRIC_MARKER = "trainer-only-rubric"
|
|
NESTED_PRIVATE_MARKERS = (
|
|
"m17-private-choice",
|
|
"m17-private-close",
|
|
"m17-private-rubric",
|
|
)
|
|
M20_PRIVATE_MARKERS = (
|
|
"m20-solution-side",
|
|
"m20-solution-close",
|
|
"m20-solution-rubric",
|
|
)
|
|
M21_PRIVATE_MARKERS = (
|
|
"m21-solution-side",
|
|
"m21-solution-close",
|
|
"m21-solution-rubric",
|
|
)
|
|
PRIVATE_FIELD_TEXT = "public text mentions " + INTERNAL_FIELD
|
|
M20_FIELD_TEXT = "m20 public text mentions " + INTERNAL_FIELD
|
|
M21_FIELD_TEXT = "m21 public text mentions " + INTERNAL_FIELD
|
|
|
|
DOCUMENTED_TRAINING_ROUTES = (
|
|
("GET", "/training/cases"),
|
|
("GET", "/training/cases/{case_id}"),
|
|
("GET", "/training/cases/export"),
|
|
("POST", "/training/cases/import"),
|
|
("POST", "/training/attempts"),
|
|
("POST", "/training/attempts/{attempt_id}/annotations"),
|
|
("POST", "/training/attempts/{attempt_id}/submit"),
|
|
("GET", "/training/progress"),
|
|
)
|
|
|
|
PUBLIC_PRIVACY_CONTRACT_TEST_SURFACES = (
|
|
"service public export",
|
|
"service public list/detail",
|
|
"route list/detail",
|
|
"route export",
|
|
"route import",
|
|
"route progress",
|
|
"route submit",
|
|
"route create-attempt",
|
|
"route annotation lifecycle",
|
|
)
|
|
|
|
PUBLIC_VALIDATION_ERROR_CONTRACT_TEST_SURFACES = (
|
|
"invalid path identifiers",
|
|
"invalid and blank limit query values",
|
|
"malformed/non-object JSON bodies",
|
|
"attempt request field validation",
|
|
"annotation request field validation",
|
|
"submit request field validation",
|
|
"import request field validation",
|
|
"not_found behavior",
|
|
"internal_server_error masking",
|
|
"route error contract consistency",
|
|
)
|
|
|
|
VALIDATION_ERROR_CONTRACT_TEST_FILE = ROOT / "tests" / "unit" / "test_training_api_error_contract_m9.py"
|
|
|
|
PUBLIC_PRIVACY_CONTRACT_TEST_INVENTORY = (
|
|
{
|
|
"surface": "service public export",
|
|
"file": ROOT / "tests" / "unit" / "test_training_public_export_privacy_m15.py",
|
|
"class_name": "TrainingPublicExportPrivacyM15Tests",
|
|
"test_names": ("test_service_public_export_scrubs_nested_solution_material",),
|
|
},
|
|
{
|
|
"surface": "service public list/detail",
|
|
"file": ROOT / "tests" / "unit" / "test_training_public_export_privacy_m15.py",
|
|
"class_name": "TrainingPublicExportPrivacyM15Tests",
|
|
"test_names": ("test_service_public_list_and_detail_scrub_nested_solution_material",),
|
|
},
|
|
{
|
|
"surface": "route list/detail",
|
|
"file": ROOT / "tests" / "unit" / "test_training_public_api_contract_m8.py",
|
|
"class_name": "TrainingPublicApiContractM8Tests",
|
|
"test_names": ("test_public_training_list_detail_routes_scrub_nested_solution_material",),
|
|
},
|
|
{
|
|
"surface": "route export",
|
|
"file": ROOT / "tests" / "unit" / "test_training_public_api_contract_m8.py",
|
|
"class_name": "TrainingPublicApiContractM8Tests",
|
|
"test_names": ("test_public_training_export_route_scrubs_nested_solution_material",),
|
|
},
|
|
{
|
|
"surface": "route import",
|
|
"file": ROOT / "tests" / "unit" / "test_training_public_api_contract_m8.py",
|
|
"class_name": "TrainingPublicApiContractM8Tests",
|
|
"test_names": ("test_public_training_import_route_scrubs_nested_solution_material_from_response",),
|
|
},
|
|
{
|
|
"surface": "route progress",
|
|
"file": ROOT / "tests" / "unit" / "test_training_public_api_contract_m8.py",
|
|
"class_name": "TrainingPublicApiContractM8Tests",
|
|
"test_names": ("test_public_training_progress_route_scrubs_nested_solution_material",),
|
|
},
|
|
{
|
|
"surface": "route submit",
|
|
"file": ROOT / "tests" / "unit" / "test_training_public_api_contract_m8.py",
|
|
"class_name": "TrainingPublicApiContractM8Tests",
|
|
"test_names": ("test_public_training_submit_route_scrubs_unexpected_service_solution_material",),
|
|
},
|
|
{
|
|
"surface": "route create-attempt",
|
|
"file": ROOT / "tests" / "unit" / "test_training_public_api_contract_m8.py",
|
|
"class_name": "TrainingPublicApiContractM8Tests",
|
|
"test_names": ("test_public_training_create_attempt_route_scrubs_unexpected_service_solution_material",),
|
|
},
|
|
{
|
|
"surface": "route annotation lifecycle",
|
|
"file": ROOT / "tests" / "unit" / "test_training_public_api_contract_m8.py",
|
|
"class_name": "TrainingPublicApiContractM8Tests",
|
|
"test_names": ("test_public_training_annotation_route_scrubs_unexpected_service_solution_material",),
|
|
},
|
|
)
|
|
|
|
PUBLIC_VALIDATION_ERROR_CONTRACT_TEST_INVENTORY = (
|
|
{
|
|
"surface": "invalid path identifiers",
|
|
"file": VALIDATION_ERROR_CONTRACT_TEST_FILE,
|
|
"class_name": "TrainingApiErrorContractM9Tests",
|
|
"test_names": (
|
|
"test_invalid_case_path_identifier_returns_public_400",
|
|
"test_non_positive_path_identifiers_are_rejected_before_training_handlers_run",
|
|
"test_invalid_post_path_identifiers_are_rejected_before_body_parsing",
|
|
"test_missing_and_invalid_annotation_fields_return_public_400",
|
|
"test_invalid_submit_fields_return_public_400",
|
|
),
|
|
},
|
|
{
|
|
"surface": "invalid and blank limit query values",
|
|
"file": VALIDATION_ERROR_CONTRACT_TEST_FILE,
|
|
"class_name": "TrainingApiErrorContractM9Tests",
|
|
"test_names": (
|
|
"test_invalid_limits_are_rejected_before_training_handlers_run",
|
|
"test_route_request_helper_rejects_blank_training_limits",
|
|
),
|
|
},
|
|
{
|
|
"surface": "malformed/non-object JSON bodies",
|
|
"file": VALIDATION_ERROR_CONTRACT_TEST_FILE,
|
|
"class_name": "TrainingApiErrorContractM9Tests",
|
|
"test_names": (
|
|
"test_malformed_or_non_object_bodies_return_public_400",
|
|
"test_error_responses_are_minimal_json_and_privacy_safe",
|
|
),
|
|
},
|
|
{
|
|
"surface": "attempt request field validation",
|
|
"file": VALIDATION_ERROR_CONTRACT_TEST_FILE,
|
|
"class_name": "TrainingApiErrorContractM9Tests",
|
|
"test_names": (
|
|
"test_create_attempt_rejects_invalid_case_identifier_before_side_effects",
|
|
"test_missing_and_invalid_attempt_fields_return_public_400",
|
|
"test_error_responses_are_minimal_json_and_privacy_safe",
|
|
),
|
|
},
|
|
{
|
|
"surface": "annotation request field validation",
|
|
"file": VALIDATION_ERROR_CONTRACT_TEST_FILE,
|
|
"class_name": "TrainingApiErrorContractM9Tests",
|
|
"test_names": ("test_missing_and_invalid_annotation_fields_return_public_400",),
|
|
},
|
|
{
|
|
"surface": "submit request field validation",
|
|
"file": VALIDATION_ERROR_CONTRACT_TEST_FILE,
|
|
"class_name": "TrainingApiErrorContractM9Tests",
|
|
"test_names": ("test_invalid_submit_fields_return_public_400",),
|
|
},
|
|
{
|
|
"surface": "import request field validation",
|
|
"file": VALIDATION_ERROR_CONTRACT_TEST_FILE,
|
|
"class_name": "TrainingApiErrorContractM9Tests",
|
|
"test_names": ("test_missing_and_invalid_import_fields_return_public_400",),
|
|
},
|
|
{
|
|
"surface": "not_found behavior",
|
|
"file": VALIDATION_ERROR_CONTRACT_TEST_FILE,
|
|
"class_name": "TrainingApiErrorContractM9Tests",
|
|
"test_names": (
|
|
"test_missing_case_returns_public_not_found",
|
|
"test_error_responses_are_minimal_json_and_privacy_safe",
|
|
),
|
|
},
|
|
{
|
|
"surface": "internal_server_error masking",
|
|
"file": VALIDATION_ERROR_CONTRACT_TEST_FILE,
|
|
"class_name": "TrainingApiErrorContractM9Tests",
|
|
"test_names": (
|
|
"test_handler_internal_error_does_not_echo_raw_detail",
|
|
"test_error_responses_are_minimal_json_and_privacy_safe",
|
|
),
|
|
},
|
|
{
|
|
"surface": "route error contract consistency",
|
|
"file": VALIDATION_ERROR_CONTRACT_TEST_FILE,
|
|
"class_name": "TrainingApiErrorContractM9Tests",
|
|
"test_names": ("test_documented_training_routes_expose_consistent_public_error_contract",),
|
|
},
|
|
)
|
|
|
|
|
|
class _TrainingRouteProbe:
|
|
def __init__(self) -> None:
|
|
self.calls = []
|
|
|
|
def dependencies(self) -> dict:
|
|
return {
|
|
"training_cases": self.training_cases,
|
|
"training_case": self.training_case,
|
|
"training_export_cases": self.training_export_cases,
|
|
"training_import_cases": self.training_import_cases,
|
|
"training_create_attempt": self.training_create_attempt,
|
|
"training_add_annotation": self.training_add_annotation,
|
|
"training_submit_attempt": self.training_submit_attempt,
|
|
"training_progress": self.training_progress,
|
|
}
|
|
|
|
def training_cases(self, **kwargs):
|
|
self.calls.append(("training_cases", kwargs))
|
|
return [{"id": 7}]
|
|
|
|
def training_case(self, **kwargs):
|
|
self.calls.append(("training_case", kwargs))
|
|
return {"id": kwargs["case_id"]}
|
|
|
|
def training_export_cases(self, **kwargs):
|
|
self.calls.append(("training_export_cases", kwargs))
|
|
return {"version": 1, "items": []}
|
|
|
|
def training_import_cases(self, body):
|
|
self.calls.append(("training_import_cases", {"body": body}))
|
|
return {"version": 1, "imported": 1, "items": []}
|
|
|
|
def training_create_attempt(self, body):
|
|
self.calls.append(("training_create_attempt", {"body": body}))
|
|
return {"id": 101}
|
|
|
|
def training_add_annotation(self, attempt_id, body):
|
|
self.calls.append(("training_add_annotation", {"attempt_id": attempt_id, "body": body}))
|
|
return {"id": 202}
|
|
|
|
def training_submit_attempt(self, attempt_id, body):
|
|
self.calls.append(("training_submit_attempt", {"attempt_id": attempt_id, "body": body}))
|
|
return {"id": attempt_id, "status": "submitted"}
|
|
|
|
def training_progress(self, **kwargs):
|
|
self.calls.append(("training_progress", kwargs))
|
|
return [{"user_id": kwargs["user_id"], "skill_tag": kwargs["skill_tag"]}]
|
|
|
|
|
|
class _HandlerHarness(server.APIRequestHandler):
|
|
def __init__(self, path: str, dependencies: dict, method: str = "GET", body: bytes = b"") -> None:
|
|
self.path = path
|
|
self.dependencies = dependencies
|
|
self.headers = {"Content-Length": str(len(body))}
|
|
self.rfile = io.BytesIO(body)
|
|
self.wfile = io.BytesIO()
|
|
self.status = None
|
|
self.response_headers = {}
|
|
self._method = method
|
|
|
|
def send_response(self, code, message=None): # noqa: D401
|
|
self.status = code
|
|
|
|
def send_header(self, keyword, value):
|
|
self.response_headers[keyword] = value
|
|
|
|
def end_headers(self):
|
|
return None
|
|
|
|
def log_message(self, format, *args):
|
|
return None
|
|
|
|
def dispatch(self) -> None:
|
|
if self._method == "POST":
|
|
self.do_POST()
|
|
else:
|
|
self.do_GET()
|
|
|
|
def json_body(self) -> dict:
|
|
return json.loads(self.wfile.getvalue().decode("utf-8"))
|
|
|
|
|
|
class _ExecutionServiceStub:
|
|
def build_execution_dashboard_items(self, instrument_id: int, limit: int = 20) -> list:
|
|
return []
|
|
|
|
|
|
class _PublicTrainingRepository:
|
|
def __init__(self) -> None:
|
|
self.get_case_args = None
|
|
self.created_cases = []
|
|
self.created_tasks = []
|
|
|
|
def list_cases(self, status=None, skill_tag=None, instrument_id=None, limit=100):
|
|
return [self._case_row(7)]
|
|
|
|
def get_case(self, case_id: int, **kwargs):
|
|
self.get_case_args = {
|
|
"case_id": case_id,
|
|
"include_" + INTERNAL_FIELD: kwargs.get("include_" + INTERNAL_FIELD, False),
|
|
}
|
|
return self._case_row(case_id)
|
|
|
|
def create_case(self, **kwargs):
|
|
self.created_cases.append(kwargs)
|
|
return 41
|
|
|
|
def add_task(self, **kwargs):
|
|
self.created_tasks.append(kwargs)
|
|
return 201
|
|
|
|
def _case_row(self, case_id: int) -> dict:
|
|
return {
|
|
"id": case_id,
|
|
"instrument_id": 1,
|
|
"timeframe": "15m",
|
|
"case_type": "sweep",
|
|
"skill_tag": "sweep",
|
|
"start_ts": "2026-04-01T09:00:00Z",
|
|
"decision_ts": "2026-04-01T10:00:00Z",
|
|
"end_ts": "2026-04-01T11:00:00Z",
|
|
"difficulty": "medium",
|
|
"status": "published",
|
|
"source_event_refs": [{"kind": "sweep_event", "id": 9}],
|
|
INTERNAL_FIELD: {
|
|
"answer": {"side": PRIVATE_MARKER},
|
|
"rubric": [{"category": PRIVATE_RUBRIC_MARKER, "max_score": 5}],
|
|
},
|
|
"meta": {
|
|
"title": "Public sweep practice",
|
|
"skill_tags": ["sweep", "liquidity_pool"],
|
|
},
|
|
}
|
|
|
|
|
|
class _NestedPublicTrainingRepository:
|
|
def __init__(self) -> None:
|
|
self.get_case_args = None
|
|
|
|
def list_cases(self, status=None, skill_tag=None, instrument_id=None, limit=100):
|
|
return [self._case_row(17)][:limit]
|
|
|
|
def get_case(self, case_id: int, **kwargs):
|
|
self.get_case_args = {
|
|
"case_id": case_id,
|
|
"include_" + INTERNAL_FIELD: kwargs.get("include_" + INTERNAL_FIELD, False),
|
|
}
|
|
return self._case_row(case_id)
|
|
|
|
def _case_row(self, case_id: int) -> dict:
|
|
return {
|
|
"id": case_id,
|
|
"instrument_id": 1,
|
|
"timeframe": "15m",
|
|
"case_type": "sweep",
|
|
"skill_tag": "sweep",
|
|
"start_ts": "2026-04-21T09:00:00Z",
|
|
"decision_ts": "2026-04-21T10:00:00Z",
|
|
"end_ts": "2026-04-21T11:00:00Z",
|
|
"difficulty": "easy",
|
|
"status": "published",
|
|
"source_event_refs": [{"kind": "sweep_event", "id": 17}],
|
|
INTERNAL_FIELD: {
|
|
"answer": {"side": NESTED_PRIVATE_MARKERS[0], "close_back_inside": NESTED_PRIVATE_MARKERS[1]},
|
|
"rubric": [{"category": NESTED_PRIVATE_MARKERS[2], "max_score": 5}],
|
|
},
|
|
"meta": {
|
|
"title": "Nested public route privacy case",
|
|
"skill_tags": ["sweep", "context_reading"],
|
|
"public_note": PRIVATE_FIELD_TEXT,
|
|
"tasks": [
|
|
{
|
|
"task_type": "short_answer",
|
|
"skill_tag": "sweep",
|
|
"prompt": "Describe the public confirmation.",
|
|
"required_annotation_types": ["text"],
|
|
"max_score": 5,
|
|
"meta": {
|
|
"public_hint": "Focus on context.",
|
|
"public_note": PRIVATE_FIELD_TEXT,
|
|
"nested": {
|
|
"answer": NESTED_PRIVATE_MARKERS[1],
|
|
INTERNAL_FIELD: NESTED_PRIVATE_MARKERS[2],
|
|
},
|
|
},
|
|
}
|
|
],
|
|
"rubric": [
|
|
{
|
|
"category": "context",
|
|
"max_score": 3,
|
|
"answer": NESTED_PRIVATE_MARKERS[0],
|
|
INTERNAL_FIELD: {"rubric": NESTED_PRIVATE_MARKERS[2]},
|
|
}
|
|
],
|
|
"metadata_note": {
|
|
"public_label": "visible",
|
|
"public_note": PRIVATE_FIELD_TEXT,
|
|
"answer": NESTED_PRIVATE_MARKERS[0],
|
|
INTERNAL_FIELD: NESTED_PRIVATE_MARKERS[1],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
class _M20ProgressRepository:
|
|
def __init__(self) -> None:
|
|
self.progress_args = None
|
|
|
|
def get_progress(self, user_id="local", skill_tag=None, limit=100):
|
|
self.progress_args = {"user_id": user_id, "skill_tag": skill_tag, "limit": limit}
|
|
return [
|
|
{
|
|
"user_id": user_id,
|
|
"skill_tag": skill_tag or "sweep",
|
|
"attempt_count": 3,
|
|
"avg_score": 8.5,
|
|
"weakness_tags": ["entry_timing"],
|
|
"meta": {
|
|
"public_summary": "Needs more context reps",
|
|
"public_note": M20_FIELD_TEXT,
|
|
"answer": {"side": M20_PRIVATE_MARKERS[0]},
|
|
INTERNAL_FIELD: {"rubric": M20_PRIVATE_MARKERS[1]},
|
|
"nested": {
|
|
"visible": "kept",
|
|
"answer": M20_PRIVATE_MARKERS[2],
|
|
},
|
|
},
|
|
}
|
|
][:limit]
|
|
|
|
|
|
class _M20SubmitService:
|
|
def __init__(self) -> None:
|
|
self.calls = []
|
|
|
|
def submit_attempt(self, attempt_id: int, total_score=None) -> dict:
|
|
self.calls.append({"attempt_id": attempt_id, "total_score": total_score})
|
|
return {
|
|
"id": attempt_id,
|
|
"status": "submitted",
|
|
"summary": {
|
|
"public_result": "recorded",
|
|
"public_note": M20_FIELD_TEXT,
|
|
"answer": {"side": M20_PRIVATE_MARKERS[0]},
|
|
INTERNAL_FIELD: {"rubric": M20_PRIVATE_MARKERS[1]},
|
|
"nested": {
|
|
"visible": "kept",
|
|
"answer": M20_PRIVATE_MARKERS[2],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
class _M21AttemptLifecycleService:
|
|
def __init__(self) -> None:
|
|
self.calls = []
|
|
|
|
def create_attempt(
|
|
self,
|
|
case_id: int,
|
|
user_id: str = "local",
|
|
current_frame_index: int = 0,
|
|
meta=None,
|
|
) -> dict:
|
|
self.calls.append(
|
|
{
|
|
"method": "create_attempt",
|
|
"case_id": case_id,
|
|
"user_id": user_id,
|
|
"current_frame_index": current_frame_index,
|
|
"meta": meta,
|
|
}
|
|
)
|
|
return {
|
|
"id": 808,
|
|
"status": "started",
|
|
"summary": {
|
|
"public_result": "attempt created",
|
|
"public_note": M21_FIELD_TEXT,
|
|
"answer": {"side": M21_PRIVATE_MARKERS[0]},
|
|
INTERNAL_FIELD: {"rubric": M21_PRIVATE_MARKERS[1]},
|
|
"nested": {
|
|
"visible": "kept",
|
|
"answer": M21_PRIVATE_MARKERS[2],
|
|
},
|
|
},
|
|
}
|
|
|
|
def add_annotation(
|
|
self,
|
|
attempt_id: int,
|
|
annotation_type: str,
|
|
payload: dict,
|
|
user_id: str = "local",
|
|
task_id=None,
|
|
) -> dict:
|
|
self.calls.append(
|
|
{
|
|
"method": "add_annotation",
|
|
"attempt_id": attempt_id,
|
|
"annotation_type": annotation_type,
|
|
"payload": payload,
|
|
"user_id": user_id,
|
|
"task_id": task_id,
|
|
}
|
|
)
|
|
return {
|
|
"id": 909,
|
|
"status": "recorded",
|
|
"summary": {
|
|
"public_result": "annotation added",
|
|
"public_note": M21_FIELD_TEXT,
|
|
"answer": {"side": M21_PRIVATE_MARKERS[0]},
|
|
INTERNAL_FIELD: {"rubric": M21_PRIVATE_MARKERS[1]},
|
|
"nested": {
|
|
"visible": "kept",
|
|
"answer": M21_PRIVATE_MARKERS[2],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
class _WorkflowContractRepository:
|
|
def __init__(self) -> None:
|
|
self.calls = []
|
|
|
|
def get_case(self, case_id: int, **kwargs):
|
|
self.calls.append(
|
|
(
|
|
"get_case",
|
|
{
|
|
"case_id": case_id,
|
|
"include_" + INTERNAL_FIELD: kwargs.get("include_" + INTERNAL_FIELD, False),
|
|
},
|
|
)
|
|
)
|
|
return {
|
|
"id": case_id,
|
|
"case_type": "sweep",
|
|
"skill_tag": "sweep",
|
|
"status": "published",
|
|
"meta": {
|
|
"title": "Workflow contract case",
|
|
"public_note": "visible",
|
|
"answer": M21_PRIVATE_MARKERS[0],
|
|
INTERNAL_FIELD: M21_PRIVATE_MARKERS[1],
|
|
},
|
|
INTERNAL_FIELD: {"rubric": M21_PRIVATE_MARKERS[2]},
|
|
}
|
|
|
|
def create_attempt(
|
|
self,
|
|
case_id: int,
|
|
user_id: str = "local",
|
|
status: str = "started",
|
|
current_frame_index: int = 0,
|
|
meta=None,
|
|
) -> int:
|
|
self.calls.append(
|
|
(
|
|
"create_attempt",
|
|
{
|
|
"case_id": case_id,
|
|
"user_id": user_id,
|
|
"status": status,
|
|
"current_frame_index": current_frame_index,
|
|
"meta": meta,
|
|
},
|
|
)
|
|
)
|
|
return 501
|
|
|
|
def submit_attempt(self, attempt_id: int, total_score=None, status: str = "submitted") -> None:
|
|
self.calls.append(
|
|
(
|
|
"submit_attempt",
|
|
{"attempt_id": attempt_id, "total_score": total_score, "status": status},
|
|
)
|
|
)
|
|
|
|
def get_progress(self, user_id: str = "local", skill_tag=None, limit: int = 100):
|
|
self.calls.append(("get_progress", {"user_id": user_id, "skill_tag": skill_tag, "limit": limit}))
|
|
return [
|
|
{
|
|
"user_id": user_id,
|
|
"skill_tag": skill_tag or "sweep",
|
|
"attempt_count": 1,
|
|
"avg_score": 8.25,
|
|
"meta": {
|
|
"public_result": "submitted",
|
|
"answer": M21_PRIVATE_MARKERS[0],
|
|
INTERNAL_FIELD: M21_PRIVATE_MARKERS[1],
|
|
},
|
|
}
|
|
][:limit]
|
|
|
|
|
|
def _documented_routes() -> tuple:
|
|
text = TRAINING_PRODUCT_DOC.read_text(encoding="utf-8")
|
|
routes = []
|
|
for method, path in re.findall(r"\|\s*(GET|POST)\s*\|\s*`([^`]+)`\s*\|", text):
|
|
if path.startswith("/training/"):
|
|
routes.append((method, path))
|
|
return tuple(routes)
|
|
|
|
|
|
def _training_dependencies(service: TrainingCaseService) -> dict:
|
|
return {
|
|
"training_cases": lambda **kwargs: training_api.get_training_cases(service=service, **kwargs),
|
|
"training_case": lambda **kwargs: training_api.get_training_case(service=service, **kwargs),
|
|
"training_export_cases": lambda **kwargs: training_api.export_training_case_package(service=service, **kwargs),
|
|
"training_import_cases": lambda body: training_api.import_training_case_package(body, service=service),
|
|
"training_create_attempt": lambda body: training_api.create_training_attempt(body, service=service),
|
|
"training_add_annotation": lambda attempt_id, body: training_api.add_training_annotation(attempt_id, body, service=service),
|
|
"training_submit_attempt": lambda attempt_id, body: training_api.submit_training_attempt(attempt_id, body, service=service),
|
|
"training_progress": lambda **kwargs: training_api.get_training_progress(service=service, **kwargs),
|
|
}
|
|
|
|
|
|
def _public_import_package() -> dict:
|
|
return {
|
|
"version": 1,
|
|
"items": [
|
|
{
|
|
"title": "Imported sweep practice",
|
|
"scenario": {
|
|
"instrument_id": 1,
|
|
"timeframe": "15m",
|
|
"case_type": "sweep",
|
|
"start_ts": "2026-04-01T09:00:00Z",
|
|
"decision_ts": "2026-04-01T10:00:00Z",
|
|
"end_ts": "2026-04-01T11:00:00Z",
|
|
"source_event_refs": [{"kind": "sweep_event", "id": 9}],
|
|
},
|
|
"skill_tags": ["sweep", "liquidity_pool"],
|
|
"difficulty": "medium",
|
|
"answer": {"side": PRIVATE_MARKER},
|
|
"rubric": [{"category": PRIVATE_RUBRIC_MARKER, "max_score": 5}],
|
|
"tasks": [
|
|
{
|
|
"task_type": "multiple_choice",
|
|
"skill_tag": "sweep",
|
|
"prompt": "Classify the setup.",
|
|
"required_annotation_types": ["multiple_choice"],
|
|
"max_score": 5,
|
|
}
|
|
],
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
def _nested_public_import_package() -> dict:
|
|
return {
|
|
"version": 1,
|
|
"items": [
|
|
{
|
|
"external_id": "m19-import-ref " + INTERNAL_FIELD,
|
|
"title": "Imported response privacy",
|
|
"scenario": {
|
|
"instrument_id": 1,
|
|
"timeframe": "15m",
|
|
"case_type": "sweep",
|
|
"start_ts": "2026-04-22T09:00:00Z",
|
|
"decision_ts": "2026-04-22T10:00:00Z",
|
|
"end_ts": "2026-04-22T11:00:00Z",
|
|
"source_event_refs": [
|
|
{
|
|
"kind": "sweep_event",
|
|
"id": 19,
|
|
"answer": NESTED_PRIVATE_MARKERS[0],
|
|
INTERNAL_FIELD: NESTED_PRIVATE_MARKERS[1],
|
|
},
|
|
{"kind": "context", "id": 20, "label": PRIVATE_FIELD_TEXT},
|
|
],
|
|
},
|
|
"skill_tags": ["sweep", "context_reading"],
|
|
"difficulty": "medium",
|
|
"status": "draft",
|
|
"answer": {
|
|
"side": NESTED_PRIVATE_MARKERS[0],
|
|
"nested": {
|
|
"close": NESTED_PRIVATE_MARKERS[1],
|
|
INTERNAL_FIELD: NESTED_PRIVATE_MARKERS[2],
|
|
},
|
|
},
|
|
"rubric": [
|
|
{
|
|
"category": NESTED_PRIVATE_MARKERS[2],
|
|
"max_score": 5,
|
|
"answer": NESTED_PRIVATE_MARKERS[0],
|
|
INTERNAL_FIELD: {"detail": NESTED_PRIVATE_MARKERS[1]},
|
|
}
|
|
],
|
|
"tasks": [
|
|
{
|
|
"task_type": "short_answer",
|
|
"skill_tag": "sweep",
|
|
"prompt": "Describe the public confirmation.",
|
|
"required_annotation_types": ["text"],
|
|
"max_score": 5,
|
|
"meta": {
|
|
"public_hint": "Focus on context.",
|
|
"answer": NESTED_PRIVATE_MARKERS[0],
|
|
INTERNAL_FIELD: NESTED_PRIVATE_MARKERS[1],
|
|
"nested": {"answer": NESTED_PRIVATE_MARKERS[2]},
|
|
},
|
|
}
|
|
],
|
|
"metadata": {
|
|
"chapter_ref": "chapter_19",
|
|
"public_label": "visible",
|
|
"answer": NESTED_PRIVATE_MARKERS[0],
|
|
INTERNAL_FIELD: NESTED_PRIVATE_MARKERS[1],
|
|
"nested": {"answer": NESTED_PRIVATE_MARKERS[2]},
|
|
},
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
def _dict_keys(value) -> list[str]:
|
|
if isinstance(value, dict):
|
|
keys = []
|
|
for key, item in value.items():
|
|
keys.append(str(key))
|
|
keys.extend(_dict_keys(item))
|
|
return keys
|
|
if isinstance(value, list):
|
|
keys = []
|
|
for item in value:
|
|
keys.extend(_dict_keys(item))
|
|
return keys
|
|
return []
|
|
|
|
|
|
def _test_methods_by_class(path: Path) -> dict[str, set[str]]:
|
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
return {
|
|
node.name: {
|
|
item.name
|
|
for item in node.body
|
|
if isinstance(item, ast.FunctionDef) and item.name.startswith("test_")
|
|
}
|
|
for node in tree.body
|
|
if isinstance(node, ast.ClassDef)
|
|
}
|
|
|
|
|
|
class TrainingPublicApiContractM8Tests(unittest.TestCase):
|
|
def test_public_privacy_contract_inventory_names_exist_by_surface(self) -> None:
|
|
surfaces = tuple(entry["surface"] for entry in PUBLIC_PRIVACY_CONTRACT_TEST_INVENTORY)
|
|
self.assertEqual(surfaces, PUBLIC_PRIVACY_CONTRACT_TEST_SURFACES)
|
|
|
|
parsed_methods_by_path = {}
|
|
seen_test_names = set()
|
|
for entry in PUBLIC_PRIVACY_CONTRACT_TEST_INVENTORY:
|
|
path = entry["file"]
|
|
class_name = entry["class_name"]
|
|
test_names = entry["test_names"]
|
|
self.assertGreater(len(test_names), 0, entry["surface"])
|
|
|
|
if path not in parsed_methods_by_path:
|
|
self.assertTrue(path.exists(), path)
|
|
parsed_methods_by_path[path] = _test_methods_by_class(path)
|
|
|
|
class_methods = parsed_methods_by_path[path]
|
|
self.assertIn(class_name, class_methods)
|
|
for test_name in test_names:
|
|
seen_key = (path, class_name, test_name)
|
|
with self.subTest(surface=entry["surface"], test_name=test_name):
|
|
self.assertNotIn(seen_key, seen_test_names)
|
|
seen_test_names.add(seen_key)
|
|
self.assertIn(test_name, class_methods[class_name])
|
|
|
|
def test_public_validation_error_contract_inventory_names_exist_by_surface(self) -> None:
|
|
surfaces = tuple(entry["surface"] for entry in PUBLIC_VALIDATION_ERROR_CONTRACT_TEST_INVENTORY)
|
|
self.assertEqual(surfaces, PUBLIC_VALIDATION_ERROR_CONTRACT_TEST_SURFACES)
|
|
|
|
parsed_methods_by_path = {}
|
|
seen_inventory_entries = set()
|
|
for entry in PUBLIC_VALIDATION_ERROR_CONTRACT_TEST_INVENTORY:
|
|
path = entry["file"]
|
|
class_name = entry["class_name"]
|
|
test_names = entry["test_names"]
|
|
self.assertGreater(len(test_names), 0, entry["surface"])
|
|
|
|
if path not in parsed_methods_by_path:
|
|
self.assertTrue(path.exists(), path)
|
|
parsed_methods_by_path[path] = _test_methods_by_class(path)
|
|
|
|
class_methods = parsed_methods_by_path[path]
|
|
self.assertIn(class_name, class_methods)
|
|
for test_name in test_names:
|
|
seen_key = (entry["surface"], path, class_name, test_name)
|
|
with self.subTest(surface=entry["surface"], test_name=test_name):
|
|
self.assertNotIn(seen_key, seen_inventory_entries)
|
|
seen_inventory_entries.add(seen_key)
|
|
self.assertIn(test_name, class_methods[class_name])
|
|
|
|
def test_documented_training_routes_resolve_to_expected_handlers(self) -> None:
|
|
self.assertEqual(_documented_routes(), DOCUMENTED_TRAINING_ROUTES)
|
|
|
|
route_cases = (
|
|
(
|
|
"GET",
|
|
"/training/cases",
|
|
{"status": ["published"], "skill_tag": ["sweep"], "instrument_id": ["1"], "limit": ["8"]},
|
|
None,
|
|
200,
|
|
("training_cases", {"status": "published", "skill_tag": "sweep", "instrument_id": 1, "limit": 8}),
|
|
),
|
|
("GET", "/training/cases/7", {}, None, 200, ("training_case", {"case_id": 7})),
|
|
(
|
|
"GET",
|
|
"/training/cases/export",
|
|
{"status": ["published"], "skill_tag": ["sweep"], "instrument_id": ["1"], "limit": ["8"]},
|
|
None,
|
|
200,
|
|
("training_export_cases", {"status": "published", "skill_tag": "sweep", "instrument_id": 1, "limit": 8}),
|
|
),
|
|
(
|
|
"POST",
|
|
"/training/cases/import",
|
|
{},
|
|
{"version": 1, "items": [{"title": "stub"}]},
|
|
201,
|
|
("training_import_cases", {"body": {"version": 1, "items": [{"title": "stub"}]}}),
|
|
),
|
|
(
|
|
"POST",
|
|
"/training/attempts",
|
|
{},
|
|
{"case_id": 7, "user_id": "analyst"},
|
|
201,
|
|
("training_create_attempt", {"body": {"case_id": 7, "user_id": "analyst"}}),
|
|
),
|
|
(
|
|
"POST",
|
|
"/training/attempts/101/annotations",
|
|
{},
|
|
{"annotation_type": "zone", "payload": {"note": "context"}},
|
|
201,
|
|
(
|
|
"training_add_annotation",
|
|
{"attempt_id": 101, "body": {"annotation_type": "zone", "payload": {"note": "context"}}},
|
|
),
|
|
),
|
|
(
|
|
"POST",
|
|
"/training/attempts/101/submit",
|
|
{},
|
|
{"total_score": 9},
|
|
200,
|
|
("training_submit_attempt", {"attempt_id": 101, "body": {"total_score": 9}}),
|
|
),
|
|
(
|
|
"GET",
|
|
"/training/progress",
|
|
{"user_id": ["analyst"], "skill_tag": ["sweep"], "limit": ["4"]},
|
|
None,
|
|
200,
|
|
("training_progress", {"user_id": "analyst", "skill_tag": "sweep", "limit": 4}),
|
|
),
|
|
)
|
|
|
|
self.assertEqual(len(route_cases), len(DOCUMENTED_TRAINING_ROUTES))
|
|
for method, path, query, body, expected_status, expected_call in route_cases:
|
|
probe = _TrainingRouteProbe()
|
|
with self.subTest(method=method, path=path):
|
|
status, payload = server.route_request(
|
|
path,
|
|
query,
|
|
dependencies=probe.dependencies(),
|
|
method=method,
|
|
body=body,
|
|
)
|
|
|
|
self.assertEqual(status, expected_status)
|
|
self.assertIsInstance(payload, dict)
|
|
self.assertEqual(probe.calls, [expected_call])
|
|
|
|
def test_training_http_handler_smoke_uses_documented_routes_and_injected_dependencies(self) -> None:
|
|
route_cases = (
|
|
(
|
|
"GET",
|
|
"/training/cases?status=published&skill_tag=sweep&instrument_id=1&limit=8",
|
|
b"",
|
|
200,
|
|
{"items": [{"id": 7}]},
|
|
("training_cases", {"status": "published", "skill_tag": "sweep", "instrument_id": 1, "limit": 8}),
|
|
),
|
|
("GET", "/training/cases/7", b"", 200, {"id": 7}, ("training_case", {"case_id": 7})),
|
|
(
|
|
"GET",
|
|
"/training/cases/export?status=published&skill_tag=sweep&instrument_id=1&limit=8",
|
|
b"",
|
|
200,
|
|
{"version": 1, "items": []},
|
|
("training_export_cases", {"status": "published", "skill_tag": "sweep", "instrument_id": 1, "limit": 8}),
|
|
),
|
|
(
|
|
"POST",
|
|
"/training/cases/import",
|
|
json.dumps({"version": 1, "items": [{"title": "stub"}]}).encode("utf-8"),
|
|
201,
|
|
{"version": 1, "imported": 1, "items": []},
|
|
("training_import_cases", {"body": {"version": 1, "items": [{"title": "stub"}]}}),
|
|
),
|
|
(
|
|
"POST",
|
|
"/training/attempts",
|
|
json.dumps({"case_id": 7, "user_id": "analyst"}).encode("utf-8"),
|
|
201,
|
|
{"id": 101},
|
|
("training_create_attempt", {"body": {"case_id": 7, "user_id": "analyst"}}),
|
|
),
|
|
(
|
|
"POST",
|
|
"/training/attempts/101/annotations",
|
|
json.dumps({"annotation_type": "zone", "payload": {"note": "context"}}).encode("utf-8"),
|
|
201,
|
|
{"id": 202},
|
|
(
|
|
"training_add_annotation",
|
|
{"attempt_id": 101, "body": {"annotation_type": "zone", "payload": {"note": "context"}}},
|
|
),
|
|
),
|
|
(
|
|
"POST",
|
|
"/training/attempts/101/submit",
|
|
json.dumps({"total_score": 9}).encode("utf-8"),
|
|
200,
|
|
{"id": 101, "status": "submitted"},
|
|
("training_submit_attempt", {"attempt_id": 101, "body": {"total_score": 9}}),
|
|
),
|
|
(
|
|
"GET",
|
|
"/training/progress?user_id=analyst&skill_tag=sweep&limit=4",
|
|
b"",
|
|
200,
|
|
{"items": [{"user_id": "analyst", "skill_tag": "sweep"}]},
|
|
("training_progress", {"user_id": "analyst", "skill_tag": "sweep", "limit": 4}),
|
|
),
|
|
)
|
|
|
|
self.assertEqual(len(route_cases), len(DOCUMENTED_TRAINING_ROUTES))
|
|
for method, path, body, expected_status, expected_payload, expected_call in route_cases:
|
|
probe = _TrainingRouteProbe()
|
|
with self.subTest(method=method, path=path):
|
|
handler = _HandlerHarness(path, probe.dependencies(), method=method, body=body)
|
|
|
|
handler.dispatch()
|
|
|
|
self.assertEqual(handler.status, expected_status)
|
|
self.assertEqual(handler.json_body(), expected_payload)
|
|
self.assertEqual(handler.response_headers.get("Content-Type"), "application/json; charset=utf-8")
|
|
self.assertEqual(handler.response_headers.get("Content-Length"), str(len(handler.wfile.getvalue())))
|
|
self.assertEqual(probe.calls, [expected_call])
|
|
|
|
def test_training_case_selector_query_params_route_to_expected_handlers(self) -> None:
|
|
probe = _TrainingRouteProbe()
|
|
|
|
status, payload = server.route_request(
|
|
"/training/cases",
|
|
{
|
|
"quality_rank": ["high"],
|
|
"min_quality_score": ["80"],
|
|
"sort_by": ["quality_score_desc"],
|
|
},
|
|
dependencies=probe.dependencies(),
|
|
)
|
|
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(payload, {"items": [{"id": 7}]})
|
|
self.assertEqual(
|
|
probe.calls,
|
|
[
|
|
(
|
|
"training_cases",
|
|
{
|
|
"status": None,
|
|
"skill_tag": None,
|
|
"instrument_id": None,
|
|
"limit": 100,
|
|
"quality_rank": "high",
|
|
"min_quality_score": 80,
|
|
"sort_by": "quality_score_desc",
|
|
},
|
|
)
|
|
],
|
|
)
|
|
|
|
def test_training_workflow_contract_case_attempt_submit_progress_is_deterministic(self) -> None:
|
|
repository = _WorkflowContractRepository()
|
|
service = TrainingCaseService(repository=repository)
|
|
dependencies = _training_dependencies(service)
|
|
|
|
detail_status, detail_payload = server.route_request("/training/cases/17", {}, dependencies=dependencies)
|
|
attempt_status, attempt_payload = server.route_request(
|
|
"/training/attempts",
|
|
{},
|
|
dependencies=dependencies,
|
|
method="POST",
|
|
body={
|
|
"case_id": 17,
|
|
"user_id": "analyst",
|
|
"current_frame_index": 3,
|
|
"meta": {"source": "workflow-contract"},
|
|
},
|
|
)
|
|
submit_status, submit_payload = server.route_request(
|
|
"/training/attempts/501/submit",
|
|
{},
|
|
dependencies=dependencies,
|
|
method="POST",
|
|
body={"total_score": 8.25},
|
|
)
|
|
progress_status, progress_payload = server.route_request(
|
|
"/training/progress",
|
|
{"user_id": ["analyst"], "skill_tag": ["sweep"], "limit": ["1"]},
|
|
dependencies=dependencies,
|
|
)
|
|
|
|
self.assertEqual([detail_status, attempt_status, submit_status, progress_status], [200, 201, 200, 200])
|
|
self.assertEqual(
|
|
detail_payload,
|
|
{
|
|
"id": 17,
|
|
"case_type": "sweep",
|
|
"skill_tag": "sweep",
|
|
"status": "published",
|
|
"quality": {"score": 10, "rank": "low", "signals": ["published_case"]},
|
|
"meta": {"title": "Workflow contract case", "public_note": "visible"},
|
|
},
|
|
)
|
|
self.assertEqual(attempt_payload, {"id": 501})
|
|
self.assertEqual(submit_payload, {"id": 501, "status": "submitted"})
|
|
self.assertEqual(
|
|
progress_payload,
|
|
{
|
|
"items": [
|
|
{
|
|
"user_id": "analyst",
|
|
"skill_tag": "sweep",
|
|
"attempt_count": 1,
|
|
"avg_score": 8.25,
|
|
"meta": {"public_result": "submitted"},
|
|
}
|
|
]
|
|
},
|
|
)
|
|
self.assertEqual(
|
|
repository.calls,
|
|
[
|
|
("get_case", {"case_id": 17, "include_" + INTERNAL_FIELD: False}),
|
|
(
|
|
"create_attempt",
|
|
{
|
|
"case_id": 17,
|
|
"user_id": "analyst",
|
|
"status": "started",
|
|
"current_frame_index": 3,
|
|
"meta": {"source": "workflow-contract"},
|
|
},
|
|
),
|
|
("submit_attempt", {"attempt_id": 501, "total_score": 8.25, "status": "submitted"}),
|
|
("get_progress", {"user_id": "analyst", "skill_tag": "sweep", "limit": 1}),
|
|
],
|
|
)
|
|
|
|
serialized = json.dumps(
|
|
{
|
|
"detail": detail_payload,
|
|
"attempt": attempt_payload,
|
|
"submit": submit_payload,
|
|
"progress": progress_payload,
|
|
},
|
|
sort_keys=True,
|
|
)
|
|
self.assertNotIn(INTERNAL_FIELD, serialized)
|
|
for private_text in M21_PRIVATE_MARKERS:
|
|
with self.subTest(private_text=private_text):
|
|
self.assertNotIn(private_text, serialized)
|
|
|
|
def test_dependency_builder_exposes_training_handler_functions(self) -> None:
|
|
expected = {
|
|
"training_cases": training_api.get_training_cases,
|
|
"training_case": training_api.get_training_case,
|
|
"training_export_cases": training_api.export_training_case_package,
|
|
"training_import_cases": training_api.import_training_case_package,
|
|
"training_create_attempt": training_api.create_training_attempt,
|
|
"training_add_annotation": training_api.add_training_annotation,
|
|
"training_submit_attempt": training_api.submit_training_attempt,
|
|
"training_progress": training_api.get_training_progress,
|
|
}
|
|
|
|
with mock.patch.object(server, "ExecutionService", _ExecutionServiceStub):
|
|
dependencies = server.build_api_dependencies()
|
|
|
|
for name, handler in expected.items():
|
|
with self.subTest(name=name):
|
|
self.assertIs(dependencies[name], handler)
|
|
|
|
def test_public_training_serialization_omits_internal_solution_material(self) -> None:
|
|
repository = _PublicTrainingRepository()
|
|
service = TrainingCaseService(repository=repository)
|
|
dependencies = _training_dependencies(service)
|
|
|
|
list_status, list_payload = server.route_request("/training/cases", {}, dependencies=dependencies)
|
|
detail_status, detail_payload = server.route_request("/training/cases/7", {}, dependencies=dependencies)
|
|
export_status, export_payload = server.route_request("/training/cases/export", {}, dependencies=dependencies)
|
|
import_status, import_payload = server.route_request(
|
|
"/training/cases/import",
|
|
{},
|
|
dependencies=dependencies,
|
|
method="POST",
|
|
body=_public_import_package(),
|
|
)
|
|
|
|
self.assertEqual([list_status, detail_status, export_status, import_status], [200, 200, 200, 201])
|
|
self.assertEqual(repository.get_case_args, {"case_id": 7, "include_" + INTERNAL_FIELD: False})
|
|
self.assertEqual(len(repository.created_cases), 1)
|
|
self.assertIn(INTERNAL_FIELD, repository.created_cases[0])
|
|
|
|
serialized = json.dumps(
|
|
{
|
|
"list": list_payload,
|
|
"detail": detail_payload,
|
|
"export": export_payload,
|
|
"import": import_payload,
|
|
},
|
|
sort_keys=True,
|
|
)
|
|
for private_text in (INTERNAL_FIELD, PRIVATE_MARKER, PRIVATE_RUBRIC_MARKER):
|
|
with self.subTest(private_text=private_text):
|
|
self.assertNotIn(private_text, serialized)
|
|
|
|
def test_public_training_import_route_scrubs_nested_solution_material_from_response(self) -> None:
|
|
repository = _PublicTrainingRepository()
|
|
service = TrainingCaseService(repository=repository)
|
|
dependencies = _training_dependencies(service)
|
|
|
|
status, payload = server.route_request(
|
|
"/training/cases/import",
|
|
{},
|
|
dependencies=dependencies,
|
|
method="POST",
|
|
body=_nested_public_import_package(),
|
|
)
|
|
|
|
self.assertEqual(status, 201)
|
|
self.assertEqual(payload["imported"], 1)
|
|
self.assertEqual(payload["case_ids"], [41])
|
|
item = payload["items"][0]
|
|
self.assertEqual(
|
|
set(item),
|
|
{"id", "title", "scenario", "skill_tags", "difficulty", "status", "external_id"},
|
|
)
|
|
self.assertEqual(item["id"], 41)
|
|
self.assertEqual(item["title"], "Imported response privacy")
|
|
self.assertEqual(item["external_id"], "m19-import-ref private solution field")
|
|
self.assertEqual(item["skill_tags"], ["sweep", "context_reading"])
|
|
self.assertEqual(item["difficulty"], "medium")
|
|
self.assertEqual(item["status"], "draft")
|
|
self.assertEqual(
|
|
item["scenario"],
|
|
{
|
|
"instrument_id": 1,
|
|
"timeframe": "15m",
|
|
"case_type": "sweep",
|
|
"start_ts": "2026-04-22T09:00:00Z",
|
|
"decision_ts": "2026-04-22T10:00:00Z",
|
|
"end_ts": "2026-04-22T11:00:00Z",
|
|
"source_event_refs": [
|
|
{"id": 19, "kind": "sweep_event"},
|
|
{"id": 20, "kind": "context", "label": "public text mentions private solution field"},
|
|
],
|
|
},
|
|
)
|
|
|
|
serialized = json.dumps(payload, sort_keys=True)
|
|
self.assertNotIn(INTERNAL_FIELD, serialized)
|
|
for private_text in NESTED_PRIVATE_MARKERS:
|
|
with self.subTest(private_text=private_text):
|
|
self.assertNotIn(private_text, serialized)
|
|
|
|
keys = _dict_keys(payload)
|
|
self.assertNotIn("answer", keys)
|
|
self.assertNotIn(INTERNAL_FIELD, keys)
|
|
|
|
def test_public_training_list_detail_routes_scrub_nested_solution_material(self) -> None:
|
|
repository = _NestedPublicTrainingRepository()
|
|
service = TrainingCaseService(repository=repository)
|
|
dependencies = _training_dependencies(service)
|
|
|
|
list_status, list_payload = server.route_request("/training/cases", {}, dependencies=dependencies)
|
|
detail_status, detail_payload = server.route_request("/training/cases/17", {}, dependencies=dependencies)
|
|
|
|
self.assertEqual([list_status, detail_status], [200, 200])
|
|
self.assertEqual(repository.get_case_args, {"case_id": 17, "include_" + INTERNAL_FIELD: False})
|
|
self.assertEqual(list_payload["items"][0]["meta"]["tasks"][0]["meta"]["public_hint"], "Focus on context.")
|
|
self.assertEqual(detail_payload["meta"]["rubric"], [{"category": "context", "max_score": 3}])
|
|
|
|
payload = {"list": list_payload, "detail": detail_payload}
|
|
serialized = json.dumps(payload, sort_keys=True)
|
|
|
|
self.assertNotIn(INTERNAL_FIELD, serialized)
|
|
for private_text in NESTED_PRIVATE_MARKERS:
|
|
with self.subTest(private_text=private_text):
|
|
self.assertNotIn(private_text, serialized)
|
|
|
|
keys = _dict_keys(payload)
|
|
self.assertNotIn("answer", keys)
|
|
self.assertNotIn(INTERNAL_FIELD, keys)
|
|
|
|
def test_public_training_export_route_scrubs_nested_solution_material(self) -> None:
|
|
repository = _NestedPublicTrainingRepository()
|
|
service = TrainingCaseService(repository=repository)
|
|
dependencies = _training_dependencies(service)
|
|
|
|
status, payload = server.route_request(
|
|
"/training/cases/export",
|
|
{"limit": ["100"]},
|
|
dependencies=dependencies,
|
|
)
|
|
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(payload["metadata"]["item_count"], 1)
|
|
item = payload["items"][0]
|
|
self.assertEqual(item["rubric"], [{"category": "context", "max_score": 3}])
|
|
self.assertEqual(item["tasks"][0]["meta"]["public_hint"], "Focus on context.")
|
|
self.assertEqual(item["metadata"]["metadata_note"]["public_label"], "visible")
|
|
self.assertIn("quality", item)
|
|
|
|
serialized = json.dumps(payload, sort_keys=True)
|
|
|
|
self.assertNotIn(INTERNAL_FIELD, serialized)
|
|
for private_text in NESTED_PRIVATE_MARKERS:
|
|
with self.subTest(private_text=private_text):
|
|
self.assertNotIn(private_text, serialized)
|
|
|
|
keys = _dict_keys(payload)
|
|
self.assertNotIn("answer", keys)
|
|
self.assertNotIn(INTERNAL_FIELD, keys)
|
|
|
|
def test_public_training_progress_route_scrubs_nested_solution_material(self) -> None:
|
|
repository = _M20ProgressRepository()
|
|
service = TrainingCaseService(repository=repository)
|
|
dependencies = _training_dependencies(service)
|
|
|
|
status, payload = server.route_request(
|
|
"/training/progress",
|
|
{"user_id": ["analyst"], "skill_tag": ["sweep"], "limit": ["1"]},
|
|
dependencies=dependencies,
|
|
)
|
|
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(repository.progress_args, {"user_id": "analyst", "skill_tag": "sweep", "limit": 1})
|
|
item = payload["items"][0]
|
|
self.assertEqual(item["user_id"], "analyst")
|
|
self.assertEqual(item["skill_tag"], "sweep")
|
|
self.assertEqual(item["attempt_count"], 3)
|
|
self.assertEqual(item["avg_score"], 8.5)
|
|
self.assertEqual(item["weakness_tags"], ["entry_timing"])
|
|
self.assertEqual(item["meta"]["public_summary"], "Needs more context reps")
|
|
self.assertEqual(item["meta"]["public_note"], "m20 public text mentions private solution field")
|
|
self.assertEqual(item["meta"]["nested"], {"visible": "kept"})
|
|
|
|
serialized = json.dumps(payload, sort_keys=True)
|
|
self.assertNotIn(INTERNAL_FIELD, serialized)
|
|
for private_text in M20_PRIVATE_MARKERS:
|
|
with self.subTest(private_text=private_text):
|
|
self.assertNotIn(private_text, serialized)
|
|
|
|
keys = _dict_keys(payload)
|
|
self.assertNotIn("answer", keys)
|
|
self.assertNotIn(INTERNAL_FIELD, keys)
|
|
|
|
def test_public_training_submit_route_scrubs_unexpected_service_solution_material(self) -> None:
|
|
service = _M20SubmitService()
|
|
dependencies = _training_dependencies(service)
|
|
|
|
status, payload = server.route_request(
|
|
"/training/attempts/707/submit",
|
|
{},
|
|
dependencies=dependencies,
|
|
method="POST",
|
|
body={"total_score": 8.5},
|
|
)
|
|
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(service.calls, [{"attempt_id": 707, "total_score": 8.5}])
|
|
self.assertEqual(payload["id"], 707)
|
|
self.assertEqual(payload["status"], "submitted")
|
|
self.assertEqual(payload["summary"]["public_result"], "recorded")
|
|
self.assertEqual(payload["summary"]["public_note"], "m20 public text mentions private solution field")
|
|
self.assertEqual(payload["summary"]["nested"], {"visible": "kept"})
|
|
|
|
serialized = json.dumps(payload, sort_keys=True)
|
|
self.assertNotIn(INTERNAL_FIELD, serialized)
|
|
for private_text in M20_PRIVATE_MARKERS:
|
|
with self.subTest(private_text=private_text):
|
|
self.assertNotIn(private_text, serialized)
|
|
|
|
keys = _dict_keys(payload)
|
|
self.assertNotIn("answer", keys)
|
|
self.assertNotIn(INTERNAL_FIELD, keys)
|
|
|
|
def test_public_training_create_attempt_route_scrubs_unexpected_service_solution_material(self) -> None:
|
|
service = _M21AttemptLifecycleService()
|
|
dependencies = _training_dependencies(service)
|
|
|
|
status, payload = server.route_request(
|
|
"/training/attempts",
|
|
{},
|
|
dependencies=dependencies,
|
|
method="POST",
|
|
body={"case_id": 17, "user_id": "analyst", "current_frame_index": 2, "meta": {"source": "unit"}},
|
|
)
|
|
|
|
self.assertEqual(status, 201)
|
|
self.assertEqual(
|
|
service.calls,
|
|
[
|
|
{
|
|
"method": "create_attempt",
|
|
"case_id": 17,
|
|
"user_id": "analyst",
|
|
"current_frame_index": 2,
|
|
"meta": {"source": "unit"},
|
|
}
|
|
],
|
|
)
|
|
self.assertEqual(payload["id"], 808)
|
|
self.assertEqual(payload["status"], "started")
|
|
self.assertEqual(payload["summary"]["public_result"], "attempt created")
|
|
self.assertEqual(payload["summary"]["public_note"], "m21 public text mentions private solution field")
|
|
self.assertEqual(payload["summary"]["nested"], {"visible": "kept"})
|
|
|
|
serialized = json.dumps(payload, sort_keys=True)
|
|
self.assertNotIn(INTERNAL_FIELD, serialized)
|
|
for private_text in M21_PRIVATE_MARKERS:
|
|
with self.subTest(private_text=private_text):
|
|
self.assertNotIn(private_text, serialized)
|
|
|
|
keys = _dict_keys(payload)
|
|
self.assertNotIn("answer", keys)
|
|
self.assertNotIn(INTERNAL_FIELD, keys)
|
|
|
|
def test_public_training_annotation_route_scrubs_unexpected_service_solution_material(self) -> None:
|
|
service = _M21AttemptLifecycleService()
|
|
dependencies = _training_dependencies(service)
|
|
|
|
status, payload = server.route_request(
|
|
"/training/attempts/808/annotations",
|
|
{},
|
|
dependencies=dependencies,
|
|
method="POST",
|
|
body={
|
|
"annotation_type": "zone",
|
|
"payload": {"note": "context"},
|
|
"user_id": "analyst",
|
|
"task_id": 33,
|
|
},
|
|
)
|
|
|
|
self.assertEqual(status, 201)
|
|
self.assertEqual(
|
|
service.calls,
|
|
[
|
|
{
|
|
"method": "add_annotation",
|
|
"attempt_id": 808,
|
|
"annotation_type": "zone",
|
|
"payload": {"note": "context"},
|
|
"user_id": "analyst",
|
|
"task_id": 33,
|
|
}
|
|
],
|
|
)
|
|
self.assertEqual(payload["id"], 909)
|
|
self.assertEqual(payload["status"], "recorded")
|
|
self.assertEqual(payload["summary"]["public_result"], "annotation added")
|
|
self.assertEqual(payload["summary"]["public_note"], "m21 public text mentions private solution field")
|
|
self.assertEqual(payload["summary"]["nested"], {"visible": "kept"})
|
|
|
|
serialized = json.dumps(payload, sort_keys=True)
|
|
self.assertNotIn(INTERNAL_FIELD, serialized)
|
|
for private_text in M21_PRIVATE_MARKERS:
|
|
with self.subTest(private_text=private_text):
|
|
self.assertNotIn(private_text, serialized)
|
|
|
|
keys = _dict_keys(payload)
|
|
self.assertNotIn("answer", keys)
|
|
self.assertNotIn(INTERNAL_FIELD, keys)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|