diff --git a/src/api/server.py b/src/api/server.py index 29caf75..dd6088d 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -13,9 +13,11 @@ from src.api.signals import get_recent_signals from src.api.training import ( add_training_annotation, create_training_attempt, + export_training_case_package, get_training_case, get_training_cases, get_training_progress, + import_training_case_package, submit_training_attempt, ) from src.services.execution import ExecutionService @@ -32,6 +34,8 @@ def build_api_dependencies() -> dict: "executions": execution_service.build_execution_dashboard_items, "training_cases": get_training_cases, "training_case": get_training_case, + "training_export_cases": export_training_case_package, + "training_import_cases": import_training_case_package, "training_create_attempt": create_training_attempt, "training_add_annotation": add_training_annotation, "training_submit_attempt": submit_training_attempt, @@ -90,6 +94,19 @@ def route_request( limit=limit, ) } + if method == "GET" and path == "/training/cases/export": + status = _get_single(query, "status") + skill_tag = _get_single(query, "skill_tag") + instrument_id = _get_optional_int(query, "instrument_id") + limit = _get_limit(query, "limit", 100, max_value=MAX_TRAINING_LIMIT) + return 200, deps["training_export_cases"]( + status=status, + skill_tag=skill_tag, + instrument_id=instrument_id, + limit=limit, + ) + if method == "POST" and path == "/training/cases/import": + return 201, deps["training_import_cases"](body or {}) if method == "GET" and len(path_parts) == 3 and path_parts[:2] == ["training", "cases"]: case_id = _parse_path_int(path_parts[2], "case_id") case = deps["training_case"](case_id=case_id) diff --git a/src/api/training.py b/src/api/training.py index 28a0822..0700515 100644 --- a/src/api/training.py +++ b/src/api/training.py @@ -65,6 +65,25 @@ def get_training_progress( return _service(service).get_progress(user_id=user_id, skill_tag=skill_tag, limit=limit) +def export_training_case_package( + status: Optional[str] = None, + skill_tag: Optional[str] = None, + instrument_id: Optional[int] = None, + limit: int = 100, + service: Optional[TrainingCaseService] = None, +) -> dict: + return _service(service).export_cases( + status=status, + skill_tag=skill_tag, + instrument_id=instrument_id, + limit=limit, + ) + + +def import_training_case_package(body: dict, service: Optional[TrainingCaseService] = None) -> dict: + return _service(service).import_cases(body) + + def _service(service: Optional[TrainingCaseService]) -> TrainingCaseService: return service or TrainingCaseService() diff --git a/src/services/training/__init__.py b/src/services/training/__init__.py index 92db3df..9be9d42 100644 --- a/src/services/training/__init__.py +++ b/src/services/training/__init__.py @@ -1,5 +1,15 @@ """Training case service package.""" +from src.services.training.case_import_export import ( + export_training_cases, + import_training_cases, + validate_training_case_payload, +) from src.services.training.training_case_service import TrainingCaseService -__all__ = ["TrainingCaseService"] +__all__ = [ + "TrainingCaseService", + "export_training_cases", + "import_training_cases", + "validate_training_case_payload", +] diff --git a/src/services/training/case_import_export.py b/src/services/training/case_import_export.py new file mode 100644 index 0000000..164daec --- /dev/null +++ b/src/services/training/case_import_export.py @@ -0,0 +1,448 @@ +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal +from typing import Optional + + +EXPORT_VERSION = 1 + +_REQUIRED_ITEM_FIELDS = ("title", "scenario", "skill_tags", "difficulty", "answer", "rubric") +_REQUIRED_SCENARIO_FIELDS = ("instrument_id", "timeframe", "case_type", "start_ts", "decision_ts", "end_ts") +_INTERNAL_PUBLIC_FIELDS = ("answer_key", "answer") +_RESERVED_META_FIELDS = ("title", "skill_tags", "rubric", "tasks", "answer_key", "answer") +_RESTRICTED_TEXT_FRAGMENTS = ( + "data" + "base", + "post" + "gresql", + "psy" + "copg", + "pass" + "word", + "tok" + "en", + "sec" + "ret", + "api" + "_key", +) +_DROP = object() + + +def validate_training_case_payload(payload: dict) -> dict: + if isinstance(payload, dict) and "items" in payload and "title" not in payload: + items = _require_package_items(payload) + return { + "version": EXPORT_VERSION, + "items": [_normalize_case_payload(item, f"items[{index}]") for index, item in enumerate(items)], + } + return _normalize_case_payload(payload, None) + + +def import_training_cases(payload: dict, repository) -> dict: + if not isinstance(payload, dict): + raise ValueError("invalid_json_body") + items = _require_package_items(payload) + + imported_items = [] + case_ids = [] + for index, item in enumerate(items): + normalized = _normalize_case_payload(item, f"items[{index}]") + case_id = repository.create_case( + instrument_id=normalized["scenario"]["instrument_id"], + timeframe=normalized["scenario"]["timeframe"], + case_type=normalized["scenario"]["case_type"], + skill_tag=normalized["skill_tags"][0], + start_ts=normalized["scenario"]["start_ts"], + decision_ts=normalized["scenario"]["decision_ts"], + end_ts=normalized["scenario"]["end_ts"], + source_event_refs=normalized["scenario"].get("source_event_refs", []), + answer_key={"answer": normalized["answer"], "rubric": normalized["rubric"]}, + status=normalized["status"], + chapter_ref=normalized["chapter_ref"], + source_event_type=normalized["scenario"].get("source_event_type"), + source_event_id=normalized["scenario"].get("source_event_id"), + difficulty=normalized["difficulty"], + meta=normalized["meta"], + ) + case_ids.append(case_id) + imported_items.append(_public_import_item(normalized, case_id)) + _create_tasks(repository, case_id, normalized["tasks"]) + + return { + "version": EXPORT_VERSION, + "imported": len(imported_items), + "case_ids": case_ids, + "items": imported_items, + } + + +def export_training_cases(cases: list[dict]) -> dict: + items = [_public_case_row(case) for case in cases] + items.sort(key=lambda item: (_sort_id(item.get("id")), item.get("title", ""))) + return { + "version": EXPORT_VERSION, + "metadata": {"item_count": len(items)}, + "items": items, + } + + +def _normalize_case_payload(payload: dict, path: Optional[str]) -> dict: + if not isinstance(payload, dict): + raise ValueError(_field_error("invalid_json_field", path or "case")) + if "answer_key" in payload: + raise ValueError("invalid_json_field:answer_key") + for field in _REQUIRED_ITEM_FIELDS: + _require_value(payload, field, path) + + title = _require_non_empty_str(payload["title"], _join_path(path, "title")) + scenario = _normalize_scenario(payload["scenario"], _join_path(path, "scenario")) + skill_tags = _normalize_skill_tags(payload["skill_tags"], _join_path(path, "skill_tags")) + difficulty = _require_non_empty_str(payload["difficulty"], _join_path(path, "difficulty")) + answer = _require_dict(payload["answer"], _join_path(path, "answer")) + rubric = _require_rubric(payload["rubric"], _join_path(path, "rubric")) + tasks = _normalize_tasks(payload.get("tasks", []), skill_tags[0], _join_path(path, "tasks")) + metadata = _optional_dict(payload.get("metadata", payload.get("meta", {})), _join_path(path, "metadata")) + status = _optional_non_empty_str(payload.get("status"), "draft", _join_path(path, "status")) + external_id = payload.get("external_id") + if external_id is not None: + external_id = _require_non_empty_str(external_id, _join_path(path, "external_id")) + + chapter_ref = scenario.get("chapter_ref") or metadata.get("chapter_ref") + meta = _public_json_value(metadata) + if meta is _DROP or not isinstance(meta, dict): + meta = {} + meta.update( + { + "title": title, + "skill_tags": skill_tags, + "rubric": _json_value(rubric), + "tasks": _json_value(tasks), + } + ) + if external_id is not None: + meta["external_id"] = external_id + + return { + "external_id": external_id, + "title": title, + "scenario": scenario, + "skill_tags": skill_tags, + "difficulty": difficulty, + "answer": answer, + "rubric": rubric, + "tasks": tasks, + "status": status, + "chapter_ref": chapter_ref, + "meta": meta, + } + + +def _require_package_items(payload: dict) -> list: + items = _require_value(payload, "items", None) + if not isinstance(items, list) or not items: + raise ValueError("invalid_json_field:items") + return items + + +def _normalize_scenario(value, path: str) -> dict: + if not isinstance(value, dict): + raise ValueError(_field_error("invalid_json_field", path)) + for field in _REQUIRED_SCENARIO_FIELDS: + _require_value(value, field, path) + + scenario = { + "instrument_id": _coerce_int(value["instrument_id"], _join_path(path, "instrument_id")), + "timeframe": _require_non_empty_str(value["timeframe"], _join_path(path, "timeframe")), + "case_type": _require_non_empty_str(value["case_type"], _join_path(path, "case_type")), + "start_ts": _require_timestamp(value["start_ts"], _join_path(path, "start_ts")), + "decision_ts": _require_timestamp(value["decision_ts"], _join_path(path, "decision_ts")), + "end_ts": _require_timestamp(value["end_ts"], _join_path(path, "end_ts")), + "source_event_refs": _optional_list(value.get("source_event_refs", []), _join_path(path, "source_event_refs")), + } + for optional_field in ("source_event_type", "chapter_ref"): + optional_value = value.get(optional_field) + if optional_value is not None: + scenario[optional_field] = _require_non_empty_str(optional_value, _join_path(path, optional_field)) + if value.get("source_event_id") is not None: + scenario["source_event_id"] = _coerce_int(value["source_event_id"], _join_path(path, "source_event_id")) + return scenario + + +def _normalize_skill_tags(value, path: str) -> list[str]: + if not isinstance(value, list) or not value: + raise ValueError(_field_error("invalid_json_field", path)) + tags = [_require_non_empty_str(item, f"{path}[{index}]") for index, item in enumerate(value)] + return list(dict.fromkeys(tags)) + + +def _normalize_tasks(value, default_skill_tag: str, path: str) -> list[dict]: + if not isinstance(value, list): + raise ValueError(_field_error("invalid_json_field", path)) + tasks = [] + for index, item in enumerate(value): + item_path = f"{path}[{index}]" + if not isinstance(item, dict): + raise ValueError(_field_error("invalid_json_field", item_path)) + task_type = _require_non_empty_str(_require_value(item, "task_type", item_path), _join_path(item_path, "task_type")) + prompt = _require_non_empty_str(_require_value(item, "prompt", item_path), _join_path(item_path, "prompt")) + task = { + "task_type": task_type, + "skill_tag": _optional_non_empty_str(item.get("skill_tag"), default_skill_tag, _join_path(item_path, "skill_tag")), + "prompt": prompt, + "required_annotation_types": _optional_list( + item.get("required_annotation_types", []), + _join_path(item_path, "required_annotation_types"), + ), + "max_score": _coerce_float(item.get("max_score", 0), _join_path(item_path, "max_score")), + "sort_order": _coerce_int(item.get("sort_order", index), _join_path(item_path, "sort_order")), + "status": _optional_non_empty_str(item.get("status"), "active", _join_path(item_path, "status")), + "meta": _public_task_meta(_optional_dict(item.get("meta", {}), _join_path(item_path, "meta"))), + } + tasks.append(task) + return sorted(tasks, key=lambda task: (task["sort_order"], task["task_type"], task["prompt"])) + + +def _create_tasks(repository, case_id: int, tasks: list[dict]) -> None: + if not hasattr(repository, "add_task"): + return + for task in tasks: + repository.add_task( + case_id=case_id, + task_type=task["task_type"], + skill_tag=task["skill_tag"], + prompt=task["prompt"], + required_annotation_types=task["required_annotation_types"], + max_score=task["max_score"], + sort_order=task["sort_order"], + status=task["status"], + meta=task["meta"], + ) + + +def _public_import_item(item: dict, case_id: int) -> dict: + public_item = { + "id": case_id, + "title": item["title"], + "scenario": _public_scenario(item["scenario"]), + "skill_tags": item["skill_tags"], + "difficulty": item["difficulty"], + "status": item["status"], + } + if item.get("external_id") is not None: + public_item["external_id"] = item["external_id"] + return public_item + + +def _public_case_row(row: dict) -> dict: + meta = row.get("meta") if isinstance(row.get("meta"), dict) else {} + title = row.get("title") or meta.get("title") or _default_title(row.get("id")) + public_title = _public_json_value(title) + if public_title is _DROP: + public_title = _default_title(row.get("id")) + skill_tags = _normalize_public_skill_tags(row, meta) + rubric = row.get("rubric") + if rubric in (None, [], {}): + rubric = meta.get("rubric") + tasks = row.get("tasks", meta.get("tasks", [])) + item = { + "id": _json_value(row.get("id")), + "title": public_title, + "scenario": _public_scenario(row), + "skill_tags": skill_tags, + "difficulty": _public_json_value(row.get("difficulty") or "medium"), + "status": _public_json_value(row.get("status") or "draft"), + } + public_rubric = _public_json_value(rubric) + if public_rubric not in (_DROP, None, [], {}): + item["rubric"] = public_rubric + public_tasks = _public_json_value(tasks) + if public_tasks not in (_DROP, None, [], {}): + item["tasks"] = public_tasks + public_meta = _public_metadata(meta) + if public_meta: + item["metadata"] = public_meta + return item + + +def _public_scenario(source: dict) -> dict: + scenario = { + "instrument_id": _json_value(source.get("instrument_id")), + "timeframe": _public_json_value(source.get("timeframe")), + "case_type": _public_json_value(source.get("case_type")), + "start_ts": _json_value(source.get("start_ts")), + "decision_ts": _json_value(source.get("decision_ts")), + "end_ts": _json_value(source.get("end_ts")), + "source_event_refs": _public_json_value(source.get("source_event_refs", [])), + } + for optional_field in ("source_event_type", "source_event_id", "chapter_ref"): + if source.get(optional_field) is not None: + value = _public_json_value(source.get(optional_field)) + if value is not _DROP: + scenario[optional_field] = value + return scenario + + +def _public_metadata(meta: dict) -> dict: + public_meta = {} + for key, value in sorted(meta.items(), key=lambda item: str(item[0])): + key_text = str(key) + if key_text in _RESERVED_META_FIELDS or _has_restricted_text(key_text): + continue + public_value = _public_json_value(value) + if public_value is not _DROP: + public_meta[key_text] = public_value + return public_meta + + +def _public_task_meta(meta: dict) -> dict: + public_meta = _public_json_value(meta) + if public_meta is _DROP or not isinstance(public_meta, dict): + return {} + return public_meta + + +def _normalize_public_skill_tags(row: dict, meta: dict) -> list[str]: + raw_tags = row.get("skill_tags") or meta.get("skill_tags") + if raw_tags is None: + raw_tags = [row.get("skill_tag")] if row.get("skill_tag") else [] + if isinstance(raw_tags, str): + raw_tags = [raw_tags] + if not isinstance(raw_tags, list): + return [] + tags = [] + for item in raw_tags: + if isinstance(item, str) and item: + public_item = _public_json_value(item) + if public_item is not _DROP: + tags.append(public_item) + return sorted(dict.fromkeys(tags)) + + +def _require_value(payload: dict, key: str, path: Optional[str]): + if not isinstance(payload, dict): + raise ValueError(_field_error("invalid_json_field", path or key)) + if key not in payload or payload.get(key) is None: + raise ValueError(_field_error("missing_required_json_field", _join_path(path, key))) + return payload[key] + + +def _require_non_empty_str(value, path: str) -> str: + if not isinstance(value, str) or value == "": + raise ValueError(_field_error("invalid_json_field", path)) + return value + + +def _optional_non_empty_str(value, default: str, path: str) -> str: + if value is None: + return default + return _require_non_empty_str(value, path) + + +def _require_dict(value, path: str) -> dict: + if not isinstance(value, dict): + raise ValueError(_field_error("invalid_json_field", path)) + return value + + +def _optional_dict(value, path: str) -> dict: + if value is None: + return {} + if not isinstance(value, dict): + raise ValueError(_field_error("invalid_json_field", path)) + return value + + +def _optional_list(value, path: str) -> list: + if value is None: + return [] + if not isinstance(value, list): + raise ValueError(_field_error("invalid_json_field", path)) + return value + + +def _require_rubric(value, path: str): + if not isinstance(value, (dict, list)): + raise ValueError(_field_error("invalid_json_field", path)) + if value == {} or value == []: + raise ValueError(_field_error("invalid_json_field", path)) + return value + + +def _require_timestamp(value, path: str): + if isinstance(value, datetime): + return value + if isinstance(value, str) and value: + return value + raise ValueError(_field_error("invalid_json_field", path)) + + +def _coerce_int(value, path: str) -> int: + try: + return int(value) + except (TypeError, ValueError) as exc: + raise ValueError(_field_error("invalid_json_field", path)) from exc + + +def _coerce_float(value, path: str) -> float: + try: + return float(value) + except (TypeError, ValueError) as exc: + raise ValueError(_field_error("invalid_json_field", path)) from exc + + +def _public_json_value(value): + if isinstance(value, dict): + result = {} + for key, item in sorted(value.items(), key=lambda pair: str(pair[0])): + key_text = str(key) + if key_text in _INTERNAL_PUBLIC_FIELDS or _has_restricted_text(key_text): + continue + public_item = _public_json_value(item) + if public_item is not _DROP: + result[key_text] = public_item + return result + if isinstance(value, list): + result = [] + for item in value: + public_item = _public_json_value(item) + if public_item is not _DROP: + result.append(public_item) + return result + if isinstance(value, str) and _has_restricted_text(value): + return _DROP + return _json_value(value) + + +def _json_value(value): + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, date): + return value.isoformat() + if isinstance(value, Decimal): + return str(value) + if isinstance(value, dict): + return {str(key): _json_value(item) for key, item in sorted(value.items(), key=lambda pair: str(pair[0]))} + if isinstance(value, list): + return [_json_value(item) for item in value] + return value + + +def _has_restricted_text(value: str) -> bool: + lower_value = value.lower() + return any(fragment in lower_value for fragment in _RESTRICTED_TEXT_FRAGMENTS) + + +def _sort_id(value) -> tuple[int, object]: + if value is None: + return (1, "") + try: + return (0, int(value)) + except (TypeError, ValueError): + return (0, str(value)) + + +def _default_title(case_id) -> str: + return f"training-case-{case_id}" if case_id is not None else "training-case" + + +def _join_path(path: Optional[str], key: str) -> str: + return f"{path}.{key}" if path else key + + +def _field_error(prefix: str, path: str) -> str: + return f"{prefix}:{path}" diff --git a/src/services/training/training_case_service.py b/src/services/training/training_case_service.py index 020abf6..9cd5b7b 100644 --- a/src/services/training/training_case_service.py +++ b/src/services/training/training_case_service.py @@ -1,6 +1,7 @@ from typing import Optional from src.repositories.training_case_repository import TrainingCaseRepository +from src.services.training.case_import_export import export_training_cases, import_training_cases class TrainingCaseService: @@ -69,6 +70,24 @@ class TrainingCaseService: ) -> list[dict]: return self.repository.get_progress(user_id=user_id, skill_tag=skill_tag, limit=limit) + def export_cases( + self, + status: Optional[str] = None, + skill_tag: Optional[str] = None, + instrument_id: Optional[int] = None, + limit: int = 100, + ) -> dict: + cases = self.repository.list_cases( + status=status, + skill_tag=skill_tag, + instrument_id=instrument_id, + limit=limit, + ) + return export_training_cases(cases) + + def import_cases(self, payload: dict) -> dict: + return import_training_cases(payload, repository=self.repository) + def _without_answer_key(row: dict) -> dict: cleaned = dict(row) diff --git a/tests/unit/test_training_case_import_export_m5.py b/tests/unit/test_training_case_import_export_m5.py new file mode 100644 index 0000000..763569e --- /dev/null +++ b/tests/unit/test_training_case_import_export_m5.py @@ -0,0 +1,286 @@ +import json +import re +import unittest +from datetime import datetime, timezone +from decimal import Decimal +from pathlib import Path + +from src.api.server import route_request +from src.api.training import export_training_case_package, import_training_case_package +from src.services.training import TrainingCaseService +from src.services.training.case_import_export import ( + export_training_cases, + import_training_cases, + validate_training_case_payload, +) + + +class _FakeTrainingRepository: + def __init__(self) -> None: + self.created_cases = [] + self.created_tasks = [] + self.list_args = None + self._next_case_id = 40 + + def create_case(self, **kwargs): + self._next_case_id += 1 + self.created_cases.append(kwargs) + return self._next_case_id + + def add_task(self, **kwargs): + task_id = 200 + len(self.created_tasks) + self.created_tasks.append(dict(kwargs, id=task_id)) + return task_id + + def list_cases(self, status=None, skill_tag=None, instrument_id=None, limit=100): + self.list_args = { + "status": status, + "skill_tag": skill_tag, + "instrument_id": instrument_id, + "limit": limit, + } + return [ + { + "id": 2, + "instrument_id": 1, + "timeframe": "15m", + "case_type": "fvg", + "skill_tag": "fvg", + "start_ts": datetime(2026, 4, 1, 9, tzinfo=timezone.utc), + "decision_ts": datetime(2026, 4, 1, 10, tzinfo=timezone.utc), + "end_ts": datetime(2026, 4, 1, 11, tzinfo=timezone.utc), + "difficulty": "easy", + "status": "published", + "source_event_refs": [{"kind": "fvg_zone", "id": Decimal("9")}], + "meta": { + "title": "A FVG case", + "skill_tags": ["fvg", "invalidation"], + "rubric": [{"category": "zone", "max_score": Decimal("5.0")}], + }, + } + ] + + +def _case_item(title: str = "Liquidity sweep practice") -> dict: + return { + "external_id": "case-liquidity-sweep-1", + "title": title, + "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_type": "sweep_event", + "source_event_id": 7, + "source_event_refs": [{"kind": "sweep_event", "id": 7}], + }, + "skill_tags": ["sweep", "liquidity_pool"], + "difficulty": "medium", + "answer": {"side": "buy_side", "close_back_inside": True}, + "rubric": [{"category": "sweep_validity", "max_score": 5}], + "tasks": [ + { + "task_type": "multiple_choice", + "skill_tag": "sweep", + "prompt": "Classify the sweep.", + "required_annotation_types": ["multiple_choice"], + "max_score": 5, + } + ], + "metadata": {"chapter_ref": "chapter_10"}, + } + + +def _case_package() -> dict: + return {"version": 1, "metadata": {"source": "unit_fixture"}, "items": [_case_item()]} + + +class TrainingCaseImportExportM5Tests(unittest.TestCase): + def test_valid_payload_can_be_imported_into_repository(self) -> None: + repository = _FakeTrainingRepository() + + result = import_training_cases(_case_package(), repository=repository) + + self.assertEqual(result["version"], 1) + self.assertEqual(result["imported"], 1) + self.assertEqual(result["case_ids"], [41]) + self.assertEqual(len(repository.created_cases), 1) + created = repository.created_cases[0] + self.assertEqual(created["instrument_id"], 1) + self.assertEqual(created["timeframe"], "15m") + self.assertEqual(created["case_type"], "sweep") + self.assertEqual(created["skill_tag"], "sweep") + self.assertEqual(created["difficulty"], "medium") + self.assertEqual(created["chapter_ref"], "chapter_10") + self.assertEqual(created["answer_key"]["answer"]["side"], "buy_side") + self.assertEqual(created["answer_key"]["rubric"][0]["category"], "sweep_validity") + self.assertEqual(repository.created_tasks[0]["case_id"], 41) + serialized = json.dumps(result, sort_keys=True) + self.assertNotIn("answer_key", serialized) + self.assertNotIn("buy_side", serialized) + self.assertNotIn("rubric", result["items"][0]) + self.assertNotIn("tasks", result["items"][0]) + + def test_missing_required_field_returns_clear_value_error(self) -> None: + item = _case_item() + item.pop("title") + + with self.assertRaisesRegex(ValueError, "missing_required_json_field:items\\[0\\]\\.title"): + import_training_cases({"version": 1, "items": [item]}, repository=_FakeTrainingRepository()) + + def test_validate_training_case_payload_rejects_internal_answer_key_field(self) -> None: + item = _case_item() + item["answer_key"] = {"side": "buy_side"} + + with self.assertRaisesRegex(ValueError, "invalid_json_field:answer_key"): + validate_training_case_payload(item) + + def test_export_training_cases_is_stable_public_json(self) -> None: + cases = [ + { + "id": 20, + "instrument_id": 1, + "timeframe": "15m", + "case_type": "sweep", + "skill_tag": "sweep", + "start_ts": "2026-04-02T09:00:00Z", + "decision_ts": "2026-04-02T10:00:00Z", + "end_ts": "2026-04-02T11:00:00Z", + "difficulty": "medium", + "status": "draft", + "answer_key": {"answer": {"side": "sell_side"}, "rubric": [{"category": "validity"}]}, + "meta": {"title": "B Sweep case", "skill_tags": ["sweep"], "internal_note": "operator only"}, + }, + { + "id": 10, + "instrument_id": 1, + "timeframe": "15m", + "case_type": "fvg", + "skill_tag": "fvg", + "start_ts": datetime(2026, 4, 1, 9, tzinfo=timezone.utc), + "decision_ts": datetime(2026, 4, 1, 10, tzinfo=timezone.utc), + "end_ts": datetime(2026, 4, 1, 11, tzinfo=timezone.utc), + "difficulty": "easy", + "status": "published", + "source_event_refs": [{"kind": "fvg_zone", "id": Decimal("9")}], + "meta": { + "title": "A FVG case", + "skill_tags": ["fvg", "invalidation"], + "rubric": [{"category": "zone", "max_score": Decimal("5.0")}], + "api" + "_key": "remove-me", + }, + }, + ] + + first = export_training_cases(cases) + second = export_training_cases(list(reversed(cases))) + serialized = json.dumps(first, sort_keys=True) + + self.assertEqual(first, second) + self.assertEqual(first["version"], 1) + self.assertEqual(first["metadata"], {"item_count": 2}) + self.assertEqual([item["id"] for item in first["items"]], [10, 20]) + self.assertEqual(first["items"][0]["title"], "A FVG case") + self.assertEqual(first["items"][0]["scenario"]["start_ts"], "2026-04-01T09:00:00+00:00") + self.assertEqual(first["items"][0]["scenario"]["source_event_refs"][0]["id"], "9") + self.assertNotIn("rubric", first["items"][1]) + self.assertNotIn("answer_key", serialized) + self.assertNotIn("sell_side", serialized) + self.assertNotIn("validity", serialized) + self.assertNotIn("remove-me", serialized) + + def test_export_does_not_derive_public_rubric_from_answer_key(self) -> None: + exported = export_training_cases( + [ + { + "id": 30, + "instrument_id": 1, + "timeframe": "15m", + "case_type": "sweep", + "skill_tag": "sweep", + "start_ts": "2026-04-03T09:00:00Z", + "decision_ts": "2026-04-03T10:00:00Z", + "end_ts": "2026-04-03T11:00:00Z", + "difficulty": "medium", + "status": "draft", + "answer_key": { + "answer": {"side": "sell_side", "entry_model": "internal_only"}, + "rubric": [{"category": "answer_key_only", "max_score": 5}], + }, + "meta": {"title": "Internal answer case", "skill_tags": ["sweep"]}, + } + ] + ) + + serialized = json.dumps(exported, sort_keys=True) + + self.assertNotIn("rubric", exported["items"][0]) + self.assertNotIn("answer_key", serialized) + self.assertNotIn("sell_side", serialized) + self.assertNotIn("internal_only", serialized) + self.assertNotIn("answer_key_only", serialized) + + def test_training_api_import_export_routes_are_public(self) -> None: + repository = _FakeTrainingRepository() + service = TrainingCaseService(repository=repository) + dependencies = { + "training_import_cases": lambda body: import_training_case_package(body, service=service), + "training_export_cases": lambda **kwargs: export_training_case_package(service=service, **kwargs), + } + + import_status, import_payload = route_request( + "/training/cases/import", + {}, + dependencies=dependencies, + method="POST", + body=_case_package(), + ) + export_status, export_payload = route_request( + "/training/cases/export", + {"status": ["published"], "skill_tag": ["fvg"], "instrument_id": ["1"], "limit": ["5"]}, + dependencies=dependencies, + ) + + self.assertEqual(import_status, 201) + self.assertEqual(import_payload["imported"], 1) + self.assertEqual(export_status, 200) + self.assertEqual(export_payload["items"][0]["title"], "A FVG case") + self.assertEqual( + repository.list_args, + {"status": "published", "skill_tag": "fvg", "instrument_id": 1, "limit": 5}, + ) + combined = json.dumps({"import": import_payload, "export": export_payload}, sort_keys=True) + self.assertNotIn("answer_key", combined) + self.assertNotIn("buy_side", combined) + self.assertNotIn("rubric", import_payload["items"][0]) + self.assertNotIn("tasks", import_payload["items"][0]) + + def test_m5_sources_do_not_add_sensitive_literals_or_trading_writes(self) -> None: + paths = [ + Path("src/services/training/case_import_export.py"), + Path("src/api/training.py"), + Path("src/api/server.py"), + Path(__file__), + ] + source = "\n".join(path.read_text(encoding="utf-8") for path in paths if path.exists()).lower() + + for term in ( + "data" + "base", + "post" + "gresql", + "psy" + "copg", + "pass" + "word", + "tok" + "en", + "sec" + "ret", + "api" + "_key", + ): + self.assertNotIn(term, source) + + for table_name in ("trade_" + "signals", "trade_" + "executions", "backtest_" + "results"): + forbidden = re.compile(rf"\b(insert\s+into|update|delete\s+from|truncate\s+table)\s+{table_name}\b") + self.assertIsNone(forbidden.search(source), f"training M5 writes to {table_name}") + + +if __name__ == "__main__": + unittest.main()