Implement local task 09 training workflows
This commit is contained in:
parent
e081fa410b
commit
cec84ffcd0
45 changed files with 2679 additions and 366 deletions
100
backend/app/scenarios/editor.py
Normal file
100
backend/app/scenarios/editor.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Локальное, детерминированное редактирование учебных сценариев.
|
||||
|
||||
Это копирование утверждённого шаблона, а не генерация нового сюжета ИИ.
|
||||
Никаких сетевых вызовов в этом модуле нет.
|
||||
"""
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.scenarios.loader import ScenarioError, _derive_ground_truth
|
||||
from app.scenarios.schema import Scenario
|
||||
|
||||
DERIVED_TRUTH = {"incident_type", "dds", "incident_code", "notify", "required_facts"}
|
||||
|
||||
|
||||
def template_copy(source: Scenario, draft_id: str, title: str | None = None) -> dict:
|
||||
"""Сделать полностью самостоятельный шаблонный черновик из сценария."""
|
||||
body = source.model_dump(mode="json")
|
||||
body["id"] = draft_id
|
||||
body["title"] = title or f"{source.title} — копия"
|
||||
body["extends"] = None # чек-лист уже развёрнут загрузчиком
|
||||
body["ticket"] = None
|
||||
body["position"] = None
|
||||
for key in DERIVED_TRUTH:
|
||||
body["ground_truth"].pop(key, None)
|
||||
return body
|
||||
|
||||
|
||||
def merge_patch(body: dict, patch: dict) -> dict:
|
||||
"""JSON Merge Patch: словари сливаются, списки заменяются целиком.
|
||||
|
||||
`id` — идентичность записи, а вычисляемые поля эталона не принимаются от
|
||||
клиента. Некорректные обычные поля можно временно сохранить в черновике;
|
||||
утверждение всё равно потребует полной проверки.
|
||||
"""
|
||||
if "id" in patch and patch["id"] != body["id"]:
|
||||
raise ScenarioError("id сценария менять нельзя")
|
||||
truth = patch.get("ground_truth")
|
||||
if isinstance(truth, dict) and DERIVED_TRUTH & truth.keys():
|
||||
raise ScenarioError("вычисляемые поля ground_truth нельзя задавать вручную")
|
||||
|
||||
result = deepcopy(body)
|
||||
|
||||
def apply(target: dict, changes: dict) -> None:
|
||||
for key, value in changes.items():
|
||||
if value is None:
|
||||
target.pop(key, None)
|
||||
elif isinstance(value, dict) and isinstance(target.get(key), dict):
|
||||
apply(target[key], value)
|
||||
else:
|
||||
target[key] = deepcopy(value)
|
||||
|
||||
apply(result, patch)
|
||||
result["id"] = body["id"]
|
||||
return result
|
||||
|
||||
|
||||
def validate(body: dict) -> Scenario:
|
||||
"""Проверить схему и связи фактов, затем пересчитать эталон по ЕКП."""
|
||||
declared = body.get("ground_truth") or {}
|
||||
if not isinstance(declared, dict):
|
||||
raise ScenarioError("ground_truth: ожидается объект")
|
||||
forbidden = DERIVED_TRUTH & declared.keys()
|
||||
if forbidden:
|
||||
raise ScenarioError(
|
||||
f"{', '.join(sorted(forbidden))} в ground_truth выводится кодом и руками не пишется"
|
||||
)
|
||||
try:
|
||||
scenario = Scenario.model_validate(body)
|
||||
except ValidationError as exc:
|
||||
first = exc.errors()[0]
|
||||
where = ".".join(str(part) for part in first["loc"])
|
||||
raise ScenarioError(f"{where}: {first['msg']}") from exc
|
||||
|
||||
if not scenario.title.strip() or len(scenario.title) > 200:
|
||||
raise ScenarioError("title: требуется название длиной от 1 до 200 символов")
|
||||
if not scenario.first_line.strip():
|
||||
raise ScenarioError("first_line: требуется первая реплика")
|
||||
if not scenario.facts or not scenario.checklist:
|
||||
raise ScenarioError("сценарию нужны факты и чек-лист для оценки занятия")
|
||||
|
||||
known = scenario.fact_ids()
|
||||
question_ids = {item.id for item in scenario.checklist}
|
||||
if len(known) != len(scenario.facts):
|
||||
raise ScenarioError("id фактов должны быть уникальны")
|
||||
if len(question_ids) != len(scenario.checklist):
|
||||
raise ScenarioError("id пунктов чек-листа должны быть уникальны")
|
||||
for item in scenario.checklist:
|
||||
if not item.question:
|
||||
raise ScenarioError(f"у пункта {item.id} нет текста вопроса")
|
||||
if item.fact and item.fact not in known:
|
||||
raise ScenarioError(f"пункт {item.id} ссылается на факт {item.fact}, которого нет")
|
||||
for fact in scenario.facts:
|
||||
question_id = fact.reveal_on.question if fact.reveal_on else None
|
||||
if question_id and question_id not in question_ids:
|
||||
raise ScenarioError(f"факт {fact.id} раскрывается несуществующим вопросом {question_id}")
|
||||
if fact.refine_on and fact.refine_on not in question_ids:
|
||||
raise ScenarioError(f"факт {fact.id} уточняется несуществующим вопросом {fact.refine_on}")
|
||||
return _derive_ground_truth(scenario)
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -12,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from app.db.models import Scenario as ScenarioRow
|
||||
from app.scenarios.loader import load_library
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.scenarios import editor
|
||||
|
||||
_library: dict[str, Scenario] = {}
|
||||
|
||||
|
|
@ -29,6 +31,11 @@ def all_scenarios() -> list[Scenario]:
|
|||
return list(_library.values())
|
||||
|
||||
|
||||
def publish(scenario: Scenario) -> None:
|
||||
"""Сделать утверждённую версию доступной запуску занятия немедленно."""
|
||||
_library[scenario.id] = scenario
|
||||
|
||||
|
||||
def load_from_disk(root: Path) -> list[Scenario]:
|
||||
scenarios = load_library(root)
|
||||
set_library(scenarios)
|
||||
|
|
@ -51,3 +58,65 @@ async def seed(db: AsyncSession, scenarios: list[Scenario]) -> int:
|
|||
row.body = payload
|
||||
await db.commit()
|
||||
return len(scenarios)
|
||||
|
||||
|
||||
async def restore_published(db: AsyncSession) -> int:
|
||||
"""После старта вернуть утверждённые преподавателем сценарии из БД.
|
||||
|
||||
Вызвать после ``load_from_disk``. Базовую библиотеку не перезаписываем:
|
||||
её источником остаются проверенные YAML из репозитория.
|
||||
"""
|
||||
rows = await db.scalars(select(ScenarioRow).where(ScenarioRow.status == "published"))
|
||||
restored = 0
|
||||
for row in rows:
|
||||
if row.id in _library:
|
||||
continue
|
||||
_library[row.id] = Scenario.model_validate(row.body)
|
||||
restored += 1
|
||||
return restored
|
||||
|
||||
|
||||
async def create_draft(
|
||||
db: AsyncSession, *, source: Scenario, title: str | None = None
|
||||
) -> ScenarioRow:
|
||||
draft_id = f"draft-{uuid4().hex}"
|
||||
body = editor.template_copy(source, draft_id, title)
|
||||
row = ScenarioRow(
|
||||
id=draft_id,
|
||||
title=body["title"],
|
||||
incident_type=body["type"],
|
||||
level=body["level"],
|
||||
topics=body["topics"],
|
||||
modes=body["modes"],
|
||||
status="draft",
|
||||
body=body,
|
||||
)
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
async def draft(db: AsyncSession, scenario_id: str) -> ScenarioRow | None:
|
||||
row = await db.get(ScenarioRow, scenario_id)
|
||||
return row if row is not None and row.status == "draft" else None
|
||||
|
||||
|
||||
async def update_draft(db: AsyncSession, row: ScenarioRow, patch: dict) -> ScenarioRow:
|
||||
row.body = editor.merge_patch(row.body, patch)
|
||||
row.title = str(row.body.get("title") or "")[:200]
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
async def approve_draft(db: AsyncSession, row: ScenarioRow) -> Scenario:
|
||||
scenario = editor.validate(row.body)
|
||||
row.title = scenario.title
|
||||
row.incident_type = scenario.type.value
|
||||
row.level = scenario.level.value
|
||||
row.topics = scenario.topics
|
||||
row.modes = scenario.modes
|
||||
row.body = scenario.model_dump(mode="json")
|
||||
row.status = "published"
|
||||
await db.commit()
|
||||
publish(scenario)
|
||||
return scenario
|
||||
|
|
|
|||
Loading…
Reference in a new issue