feat: add training case import export
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user