Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue