Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -36,7 +36,7 @@ def reveals_number(value: str) -> bool:
|
|||
|
||||
def editable_fact_ids(source: Scenario) -> list[str]:
|
||||
return [fact.id for fact in source.facts
|
||||
if not fact.hidden and not fact.refined and not fact.refine_on
|
||||
if not fact.refined and not fact.refine_on
|
||||
and not any(part in fact.id.casefold() for part in PROTECTED_IDS)]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -42,14 +42,30 @@ def _merge_checklist(base: list[dict], local: list[dict]) -> list[ChecklistItem]
|
|||
return [ChecklistItem.model_validate(item) for item in merged.values()]
|
||||
|
||||
|
||||
def _assert_unique_checklist_ids(items: list[dict], source: str) -> None:
|
||||
ids = [
|
||||
item["id"] for item in items
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), str)
|
||||
]
|
||||
seen: set[str] = set()
|
||||
duplicates: set[str] = set()
|
||||
for item_id in ids:
|
||||
if item_id in seen:
|
||||
duplicates.add(item_id)
|
||||
seen.add(item_id)
|
||||
if duplicates:
|
||||
raise ScenarioError(
|
||||
f"{source}: повторяются id пунктов чек-листа: {', '.join(sorted(duplicates))}"
|
||||
)
|
||||
|
||||
|
||||
def _derive_ground_truth(scenario: Scenario) -> Scenario:
|
||||
"""Эталон собирается кодом. Из YAML берутся только нормализованные
|
||||
адрес и число пострадавших — остальное перезаписывается."""
|
||||
hidden = {fact.id for fact in scenario.facts if fact.hidden}
|
||||
required = [
|
||||
item.fact
|
||||
for item in scenario.checklist
|
||||
if item.fact and item.fact not in hidden
|
||||
if item.fact
|
||||
]
|
||||
scenario.ground_truth.incident_type = scenario.type
|
||||
scenario.ground_truth.dds = DDS_BY_INCIDENT[scenario.type]
|
||||
|
|
@ -114,6 +130,7 @@ def load_file(path: Path, root: Path) -> Scenario:
|
|||
)
|
||||
|
||||
own = raw.get("checklist", [])
|
||||
_assert_unique_checklist_ids(own, path.name)
|
||||
base = _common_checklist(root, {item.get("id") for item in own})
|
||||
extends = raw.get("extends")
|
||||
if extends:
|
||||
|
|
@ -121,6 +138,7 @@ def load_file(path: Path, root: Path) -> Scenario:
|
|||
if not base_path.exists():
|
||||
raise ScenarioError(f"{path.name}: чек-лист {extends} не найден")
|
||||
base = base + _read_yaml(base_path).get("checklist", [])
|
||||
_assert_unique_checklist_ids(base, f"{path.name}: подключённые чек-листы")
|
||||
if base:
|
||||
raw["checklist"] = [
|
||||
item.model_dump(exclude_none=True) for item in _merge_checklist(base, own)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|||
|
||||
from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome
|
||||
from app.domain.events import Mood
|
||||
from app.domain.kio import EDITABLE_KIO_FIELDS, KIO
|
||||
from app.scoring.taxonomy import METRIC_MAP
|
||||
|
||||
|
||||
|
|
@ -32,16 +33,9 @@ class Background(Strict):
|
|||
|
||||
|
||||
class RevealOn(Strict):
|
||||
"""Два вида условий: вопрос из чек-листа либо подход оператора."""
|
||||
"""Факт открывает только вопрос, сопоставленный жёстким слот-протоколом."""
|
||||
|
||||
question: str | None = None
|
||||
approach: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def exactly_one(self):
|
||||
if bool(self.question) == bool(self.approach):
|
||||
raise ValueError("reveal_on: ровно одно из `question` или `approach`")
|
||||
return self
|
||||
question: str = Field(min_length=1)
|
||||
|
||||
|
||||
class Fact(Strict):
|
||||
|
|
@ -59,22 +53,12 @@ class Fact(Strict):
|
|||
|
||||
id: str
|
||||
value: str
|
||||
hidden: bool = False
|
||||
reveal_on: RevealOn | None = None
|
||||
refined: str | None = None
|
||||
refine_on: str | None = Field(
|
||||
default=None, description="Пункт чек-листа, уточняющий этот факт"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def hidden_needs_condition(self):
|
||||
if self.hidden and (self.reveal_on is None or not self.reveal_on.approach):
|
||||
raise ValueError(
|
||||
f"факт {self.id}: hidden требует reveal_on.approach — "
|
||||
"скрытый факт не раскрывается прямым вопросом"
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def refinement_needs_both_halves(self):
|
||||
if bool(self.refined) != bool(self.refine_on):
|
||||
|
|
@ -129,6 +113,23 @@ class GroundTruth(Strict):
|
|||
victims: int | None = None
|
||||
|
||||
|
||||
class DdsDecision(Strict):
|
||||
"""Эталон первичного решения службы по данной карточке.
|
||||
|
||||
Профильность по ЕКП сама по себе не исключает дубль или территориальный
|
||||
отказ, поэтому такие исключения задаются явно в сценарии.
|
||||
"""
|
||||
|
||||
expected: str = Field(default="accept", pattern="^(accept|decline)$")
|
||||
reason: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def decline_needs_reason(self):
|
||||
if self.expected == "decline" and not (self.reason and self.reason.strip()):
|
||||
raise ValueError("dds_decision.reason обязателен для эталонного отказа")
|
||||
return self
|
||||
|
||||
|
||||
class Scenario(Strict):
|
||||
id: str
|
||||
title: str
|
||||
|
|
@ -149,11 +150,19 @@ class Scenario(Strict):
|
|||
# Билет — единица занятия у заказчика: три вызова подряд, разные службы
|
||||
# (docs/spec/TICKETS.md). Преподаватель выбирает билет, а не сценарий.
|
||||
ticket: int | None = None
|
||||
position: int | None = Field(default=None, ge=1, le=3, description="Номер вызова в билете")
|
||||
position: int | None = Field(
|
||||
default=None, ge=1, le=3, description="Номер вызова в билете"
|
||||
)
|
||||
|
||||
# Чем вызов заканчивается правильно. По умолчанию — карточка и выезд;
|
||||
# справка и передача в другой регион разбираются в lct-36.
|
||||
outcome: Outcome = Outcome.CARD
|
||||
dds_decision: DdsDecision = DdsDecision()
|
||||
|
||||
# Готовая КИО, сформированная курсантом и утверждённая преподавателем.
|
||||
# Для системных сценариев поле отсутствует; ДДС использует снимок как
|
||||
# исходную карточку вместо реконструкции её из кратких фактов.
|
||||
student_card: KIO | None = None
|
||||
|
||||
facts: list[Fact] = []
|
||||
checklist: list[ChecklistItem] = []
|
||||
|
|
@ -173,15 +182,51 @@ class Scenario(Strict):
|
|||
def valid_score_weights(self):
|
||||
unknown = self.score_weights.keys() - METRIC_MAP.keys()
|
||||
if unknown:
|
||||
raise ValueError(f"неизвестные метрики score_weights: {', '.join(sorted(unknown))}")
|
||||
raise ValueError(
|
||||
f"неизвестные метрики score_weights: {', '.join(sorted(unknown))}"
|
||||
)
|
||||
if any(not 0 <= weight <= 10 for weight in self.score_weights.values()):
|
||||
raise ValueError("score_weights: каждый вес должен быть от 0 до 10")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_required_fields(self):
|
||||
duplicates = sorted({path for path in self.required_fields
|
||||
if self.required_fields.count(path) > 1})
|
||||
if duplicates:
|
||||
raise ValueError(
|
||||
f"required_fields: повторяются поля: {', '.join(duplicates)}"
|
||||
)
|
||||
unavailable = sorted(set(self.required_fields) - EDITABLE_KIO_FIELDS)
|
||||
if unavailable:
|
||||
raise ValueError(
|
||||
"required_fields: поля отсутствуют в форме КИО или заполняются системой: "
|
||||
+ ", ".join(unavailable)
|
||||
)
|
||||
if self.outcome is not Outcome.CARD and self.required_fields:
|
||||
raise ValueError(
|
||||
"required_fields должны быть пустыми, если карточка КИО не создаётся"
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def unique_reference_ids(self):
|
||||
for label, values in (
|
||||
("id фактов", [fact.id for fact in self.facts]),
|
||||
("id пунктов чек-листа", [item.id for item in self.checklist]),
|
||||
("признаки ЕКП", self.signs),
|
||||
):
|
||||
duplicates = sorted({value for value in values if values.count(value) > 1})
|
||||
if duplicates:
|
||||
raise ValueError(f"{label} должны быть уникальны: {', '.join(duplicates)}")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def ticket_needs_position(self):
|
||||
if (self.ticket is None) != (self.position is None):
|
||||
raise ValueError("ticket и position задаются вместе: билет без номера вызова неполон")
|
||||
raise ValueError(
|
||||
"ticket и position задаются вместе: билет без номера вызова неполон"
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
преподаватель выбирает сценарий и что переживает перезапуск.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
|
|
@ -11,15 +12,16 @@ from sqlalchemy import select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Scenario as ScenarioRow
|
||||
from app.scenarios import editor
|
||||
from app.scenarios.loader import load_library
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.scenarios import editor
|
||||
|
||||
_library: dict[str, Scenario] = {}
|
||||
_demo_drafts: dict[str, ScenarioRow] = {}
|
||||
_demo_archived: dict[str, Scenario] = {}
|
||||
_demo_scenario_owners: dict[str, str] = {}
|
||||
_demo_archived_owners: dict[str, str] = {}
|
||||
BeforeCommit = Callable[[AsyncSession, ScenarioRow], None]
|
||||
|
||||
|
||||
def reset_demo_drafts() -> None:
|
||||
|
|
@ -48,6 +50,12 @@ def publish(scenario: Scenario) -> None:
|
|||
_library[scenario.id] = scenario
|
||||
|
||||
|
||||
def register_owned_scenario(scenario: Scenario, owner_login: str) -> None:
|
||||
"""Обновить runtime-библиотеку после публикации модерируемого сценария."""
|
||||
_library[scenario.id] = scenario
|
||||
_demo_scenario_owners[scenario.id] = owner_login
|
||||
|
||||
|
||||
def load_from_disk(root: Path) -> list[Scenario]:
|
||||
scenarios = load_library(root)
|
||||
set_library(scenarios)
|
||||
|
|
@ -93,13 +101,50 @@ async def restore_published(db: AsyncSession) -> int:
|
|||
continue
|
||||
if row.owner_login:
|
||||
_demo_scenario_owners[row.id] = row.owner_login
|
||||
if row.id in _library:
|
||||
# Shipped YAML remains the canonical source for base cards, but a
|
||||
# published instructor-owned row may have changed on another process.
|
||||
already_loaded = row.id in _library
|
||||
if already_loaded and row.owner_login is None:
|
||||
continue
|
||||
_library[row.id] = Scenario.model_validate(row.body)
|
||||
delta += 1
|
||||
if not already_loaded:
|
||||
delta += 1
|
||||
return delta
|
||||
|
||||
|
||||
async def published_catalog(
|
||||
db: AsyncSession, scenario_ids: list[str], owner_login: str | None,
|
||||
) -> tuple[dict[str, Scenario], set[str]]:
|
||||
"""Resolve startable scenarios from shared DB, including a peer's cache misses.
|
||||
|
||||
Process-local memory remains a fallback only for the shipped library. Any
|
||||
database row is authoritative: archived/pending rows never fall back to a
|
||||
stale in-memory copy, and instructor-owned rows stay private across nodes.
|
||||
"""
|
||||
ids = set(scenario_ids)
|
||||
if not ids:
|
||||
return {}, set()
|
||||
rows = await db.scalars(select(ScenarioRow).where(ScenarioRow.id.in_(ids)))
|
||||
by_id = {row.id: row for row in rows}
|
||||
scenarios: dict[str, Scenario] = {}
|
||||
hidden: set[str] = set()
|
||||
for scenario_id, row in by_id.items():
|
||||
if row.owner_login and row.owner_login != owner_login:
|
||||
hidden.add(scenario_id)
|
||||
continue
|
||||
if row.status == "published":
|
||||
scenario = Scenario.model_validate(row.body)
|
||||
scenarios[scenario_id] = scenario
|
||||
# Refresh a stale process-local version from the authoritative row.
|
||||
_library[scenario_id] = scenario
|
||||
for scenario_id in ids - by_id.keys():
|
||||
scenario = _library.get(scenario_id)
|
||||
# A process-local owner marker without a durable row is not publishable.
|
||||
if scenario is not None and scenario_id not in _demo_scenario_owners:
|
||||
scenarios[scenario_id] = scenario
|
||||
return scenarios, hidden
|
||||
|
||||
|
||||
async def owned_scenario_ids(db: AsyncSession | None, owner_login: str) -> set[str]:
|
||||
"""IDs the current instructor may edit/archive; base and legacy rows are read-only."""
|
||||
if db is None:
|
||||
|
|
@ -117,8 +162,24 @@ async def owned_scenario_ids(db: AsyncSession | None, owner_login: str) -> set[s
|
|||
return {value if isinstance(value, str) else value.id for value in values}
|
||||
|
||||
|
||||
async def scenario_ids_owned_by_other(db: AsyncSession | None, owner_login: str) -> set[str]:
|
||||
"""Hide another instructor's private scenarios from this instructor's bank."""
|
||||
if db is None:
|
||||
return {
|
||||
scenario_id for scenario_id, owner in _demo_scenario_owners.items()
|
||||
if owner != owner_login
|
||||
}
|
||||
rows = await db.scalars(select(ScenarioRow.id).where(
|
||||
ScenarioRow.owner_login.is_not(None),
|
||||
ScenarioRow.owner_login != owner_login,
|
||||
ScenarioRow.status == "published",
|
||||
))
|
||||
return {value if isinstance(value, str) else value.id for value in rows}
|
||||
|
||||
|
||||
async def archive(
|
||||
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
|
||||
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None,
|
||||
before_commit: BeforeCommit | None = None,
|
||||
) -> Scenario | None:
|
||||
"""Скрыть опубликованный сценарий без удаления истории и внешних ключей."""
|
||||
scenario = _library.get(scenario_id)
|
||||
|
|
@ -148,13 +209,16 @@ async def archive(
|
|||
)
|
||||
db.add(row)
|
||||
row.status = "archived"
|
||||
if before_commit is not None:
|
||||
before_commit(db, row)
|
||||
await db.commit()
|
||||
_library.pop(scenario_id, None)
|
||||
return scenario
|
||||
|
||||
|
||||
async def restore_archived(
|
||||
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
|
||||
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None,
|
||||
before_commit: BeforeCommit | None = None,
|
||||
) -> Scenario | None:
|
||||
"""Вернуть мягко удалённый сценарий в библиотеку назначения."""
|
||||
if db is None:
|
||||
|
|
@ -175,6 +239,8 @@ async def restore_archived(
|
|||
# черновика до вычисления и закономерно запрещает такие поля.
|
||||
scenario = Scenario.model_validate(row.body)
|
||||
row.status = "published"
|
||||
if before_commit is not None:
|
||||
before_commit(db, row)
|
||||
await db.commit()
|
||||
if scenario is None:
|
||||
return None
|
||||
|
|
@ -185,7 +251,7 @@ async def restore_archived(
|
|||
async def create_draft(
|
||||
db: AsyncSession | None, *, source: Scenario, title: str | None = None,
|
||||
proposal: dict | None = None, full_proposal: dict | None = None,
|
||||
owner_login: str | None = None,
|
||||
owner_login: str | None = None, before_commit: BeforeCommit | None = None,
|
||||
) -> ScenarioRow:
|
||||
if proposal is not None and full_proposal is not None:
|
||||
raise ValueError("нельзя одновременно передать вариацию и полный сюжет")
|
||||
|
|
@ -216,6 +282,8 @@ async def create_draft(
|
|||
_demo_drafts[row.id] = row
|
||||
else:
|
||||
db.add(row)
|
||||
if before_commit is not None:
|
||||
before_commit(db, row)
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
|
@ -231,15 +299,25 @@ async def draft(
|
|||
return row
|
||||
|
||||
|
||||
async def update_draft(db: AsyncSession | None, row: ScenarioRow, patch: dict) -> ScenarioRow:
|
||||
async def update_draft(
|
||||
db: AsyncSession | None, row: ScenarioRow, patch: dict,
|
||||
*, before_commit: BeforeCommit | None = None,
|
||||
) -> ScenarioRow:
|
||||
row.body = editor.merge_patch(row.body, patch)
|
||||
row.title = str(row.body.get("title") or "")[:200]
|
||||
row.manual_edit_pending = True
|
||||
row.grammar_check_hash = None
|
||||
if db is not None:
|
||||
if before_commit is not None:
|
||||
before_commit(db, row)
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
async def revise_draft(db: AsyncSession | None, row: ScenarioRow, proposal: dict) -> ScenarioRow:
|
||||
async def revise_draft(
|
||||
db: AsyncSession | None, row: ScenarioRow, proposal: dict,
|
||||
*, before_commit: BeforeCommit | None = None,
|
||||
) -> ScenarioRow:
|
||||
"""Заменить сюжетную версию того же черновика после комментария преподавателя."""
|
||||
from app.scenarios.generation import proposal_body
|
||||
|
||||
|
|
@ -250,12 +328,18 @@ async def revise_draft(db: AsyncSession | None, row: ScenarioRow, proposal: dict
|
|||
row.level = row.body["level"]
|
||||
row.topics = row.body["topics"]
|
||||
row.modes = row.body["modes"]
|
||||
row.manual_edit_pending = False
|
||||
row.grammar_check_hash = None
|
||||
if db is not None:
|
||||
if before_commit is not None:
|
||||
before_commit(db, row)
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
async def approve_draft(db: AsyncSession | None, row: ScenarioRow) -> Scenario:
|
||||
async def approve_draft(
|
||||
db: AsyncSession | None, row: ScenarioRow, *, before_commit: BeforeCommit | None = None,
|
||||
) -> Scenario:
|
||||
scenario = editor.validate(row.body)
|
||||
row.title = scenario.title
|
||||
row.incident_type = scenario.type.value
|
||||
|
|
@ -269,6 +353,8 @@ async def approve_draft(db: AsyncSession | None, row: ScenarioRow) -> Scenario:
|
|||
if row.owner_login is not None:
|
||||
_demo_scenario_owners[row.id] = row.owner_login
|
||||
else:
|
||||
if before_commit is not None:
|
||||
before_commit(db, row)
|
||||
await db.commit()
|
||||
publish(scenario)
|
||||
return scenario
|
||||
|
|
|
|||
Loading…
Reference in a new issue