Complete training workflow and acceptance hardening

This commit is contained in:
andreysk0304 2026-09-26 18:12:27 +03:00 • committed by gglamer
commit 7237265833
243 changed files with 17014 additions and 1500 deletions

View file

@ -0,0 +1,80 @@
"""Conservative matching for operational addresses.
Names are compared as whole normalized words (never four-letter prefixes), and
numbered address components remain attached to their labels. Extra detail in a
trainee's address is allowed, but a street typo or swapped house/apartment is
not treated as a match. Explicit road types (for example, street vs. lane) are
also operationally significant.
"""
import re
_ALIASES = {
"ул": "улица", "улица": "улица",
"д": "дом", "дом": "дом",
"корп": "корпус", "корпус": "корпус",
"стр": "строение", "строение": "строение",
"кв": "квартира", "квартира": "квартира",
"под": "подъезд", "подъезд": "подъезд",
"эт": "этаж", "этаж": "этаж",
"код": "код", "домофон": "код",
"г": "город", "город": "город",
"обл": "область", "область": "область",
"пр": "проспект", "просп": "проспект", "проспект": "проспект",
"пер": "переулок", "переулок": "переулок",
"наб": "набережная", "набережная": "набережная",
"ш": "шоссе", "шоссе": "шоссе",
}
_COMPONENTS = {"дом", "корпус", "строение", "квартира", "подъезд", "этаж", "код"}
_ROAD_TYPES = {"улица", "проспект", "переулок", "набережная", "шоссе"}
_NON_CONTENT = _COMPONENTS | {
"город", "область",
} | _ROAD_TYPES
def _tokens(value: str | None) -> list[str]:
if not value:
return []
raw = re.findall(r"[a-zа-яё0-9]+", value.casefold().replace("ё", "е"))
return [_ALIASES.get(token, token) for token in raw if token != "номер"]
def _components(tokens: list[str]) -> dict[str, set[str]]:
result: dict[str, set[str]] = {}
for index, token in enumerate(tokens[:-1]):
if token in _COMPONENTS:
result.setdefault(token, set()).add(tokens[index + 1])
return result
def address_matches(expected: str | None, supplied: str | None) -> bool:
"""Return true only if all expected address words/components are preserved."""
expected_tokens = _tokens(expected)
supplied_tokens = _tokens(supplied)
if not expected_tokens:
return bool(supplied_tokens)
if not supplied_tokens:
return False
expected_content = {token for token in expected_tokens if token not in _NON_CONTENT}
supplied_content = {token for token in supplied_tokens if token not in _NON_CONTENT}
if not expected_content <= supplied_content:
return False
expected_components = _components(expected_tokens)
supplied_components = _components(supplied_tokens)
expected_road_types = set(expected_tokens) & _ROAD_TYPES
supplied_road_types = set(supplied_tokens) & _ROAD_TYPES
# Тип объекта не является декоративным словом: «улица Ленина» и
# «переулок Ленина» — разные адреса, даже при одинаковых остальных токенах.
# Если тип явно указан в ответе, он должен совпасть с источником; краткая
# форма без типа остаётся допустимой, как и прочие проверенные сокращения.
if (
expected_road_types
and supplied_road_types
and supplied_road_types != expected_road_types
):
return False
return all(supplied_components.get(kind) == values
for kind, values in expected_components.items())

View file

@ -0,0 +1,83 @@
"""Optional local-model coaching based only on deterministic score findings.
The model may explain how to improve, but never changes metric values, points,
or pass/fail. Only failed criterion keys supplied by the scorer are accepted.
"""
import json
from app.config import get_settings
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, is_loopback_url
from app.domain.events import AICoaching, AIRecommendation, Metric
async def coach(metrics: list[Metric]) -> AICoaching:
failed = [item for item in metrics if not item.passed and item.weight > 0]
if not failed:
return AICoaching(status="not_needed")
settings = get_settings()
if not settings.assessment_feedback_enabled:
return AICoaching(status="disabled")
if (not settings.llm_model_control or not is_loopback_url(
settings.llm_control_base_url,
allow_docker_host=settings.allow_docker_host_models,
)):
return AICoaching(status="unavailable")
allowed = {item.key for item in failed}
schema = {"type": "json_object", "schema": {
"type": "object",
"properties": {"recommendations": {
"type": "array", "maxItems": 3,
"items": {"type": "object",
"properties": {
"metric_key": {"type": "string", "enum": sorted(allowed)},
"text": {"type": "string", "minLength": 12, "maxLength": 240},
},
"required": ["metric_key", "text"], "additionalProperties": False,
},
}},
"required": ["recommendations"], "additionalProperties": False,
}}
evidence = [{"metric_key": item.key, "criterion": item.title,
"observed": item.fact, "expected": item.norm}
for item in failed[:12]]
request = LlmRequest(
model=settings.llm_model_control,
messages=[{
"role": "system",
"content": (
"Ты методист учебного центра 112. По результатам детерминированной оценки "
"сформулируй до трёх коротких, конкретных рекомендаций курсанту: что "
"потренировать и как. Не пересчитывай баллы и не оспаривай зачёт. "
"Опирайся только на переданные наблюдения и нормативы; не придумывай "
"новые факты, требования и числа. Каждая рекомендация должна ссылаться "
"на один из переданных metric_key. Верни только JSON. /no_think"
),
}, {"role": "user", "content": json.dumps(evidence, ensure_ascii=False)}],
temperature=0.0, max_tokens=360, response_format=schema, strip_reasoning=True,
)
client = LlmClient(base_url=settings.llm_control_base_url, timeout=6)
try:
raw = await client.complete(request, use_cache=True)
payload = json.loads(raw)
if set(payload) != {"recommendations"} or not isinstance(payload["recommendations"], list):
raise ValueError("invalid coaching schema")
recommendations: list[AIRecommendation] = []
seen: set[str] = set()
for item in payload["recommendations"]:
if (not isinstance(item, dict) or set(item) != {"metric_key", "text"}
or item["metric_key"] not in allowed or item["metric_key"] in seen):
raise ValueError("recommendation references an unscored criterion")
recommendation = AIRecommendation.model_validate(item)
seen.add(recommendation.metric_key)
recommendations.append(recommendation)
if not recommendations:
raise ValueError("model returned no recommendations")
return AICoaching(status="ready", model=settings.llm_model_control,
recommendations=recommendations)
except (LlmUnavailable, ValueError, TypeError, KeyError, json.JSONDecodeError):
return AICoaching(status="unavailable")
finally:
await client.aclose()

View file

@ -9,7 +9,8 @@ from app.domain.events import Metric
from app.domain.kio import KIO, missing_fields
from app.domain.taxonomy import Finding, FindingSource
from app.scenarios.schema import Scenario
from app.scoring.gost import GostResult, _normalize_address
from app.scoring.gost import GostResult
from app.scoring.address import address_matches
from app.scoring.taxonomy import METRIC_MAP, METRIC_WEIGHTS
@ -49,8 +50,7 @@ def evaluate_card(scenario: Scenario, kio: KIO) -> GostResult:
if truth.address:
written = kio.address or " ".join(filter(None, (kio.street, kio.building)))
add("address", "Адрес происшествия", written or "не заполнен", truth.address,
bool(_normalize_address(truth.address))
and _normalize_address(truth.address) <= _normalize_address(written),
address_matches(truth.address, written),
"эталон сценария")
if truth.victims is not None:

View file

@ -24,8 +24,8 @@ def radar(metrics: list[Metric]) -> list[CompetencyScore]:
continue
competency = mapping[1]
total[competency] = total.get(competency, 0.0) + metric.weight
if metric.passed:
passed[competency] = passed.get(competency, 0.0) + metric.weight
credit = metric.credit if metric.credit is not None else float(metric.passed)
passed[competency] = passed.get(competency, 0.0) + metric.weight * credit
return [
CompetencyScore(competency=competency.value, value=round(passed.get(competency, 0.0) / weight, 3))

View file

@ -4,14 +4,17 @@
перечислены поимённо и с реальными примерами (docs/spec/DATASET.md). Формулировки
не переписаны: преподаватель, который эту памятку читал, должен узнать их в разборе.
Детерминированно считаются D1, D2, D3, D4 и D6. D5 — полнота комментария — мягкий
критерий, его место у судьи (lct-13): «не принята: не обслуживаем» без указания,
куда передана информация, формально неотличимо от полного комментария.
Детерминированно считаются D1–D6. D5 проверяет структуру отдельного доклада и
получателя сведений; пропуск по каждому комментарию виден отдельно. К каждой ручной
отметке обязательны непустые поля «Основание» и «Сведения»; их наличие входит в
числовую метрику `dds_reply`, но не подтверждает истинность текста. Это не
привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу.
"""
from app.domain.statuses import (
COMMENT_REQUIRED,
PRIMARY,
REFUSAL_COMMENT_REQUIRED,
SERVICE_STATUS_LABELS,
ServiceStatus,
StatusEntry,
@ -33,16 +36,61 @@ def _finding(code: ErrorCode, summary: str, fact: str, norm: str | None = None)
fact=fact,
norm=norm,
ref="памятка «Работа на АРМ-112», раздел «Статусы реагирования»",
competency=Competency.CARD,
competency=(
Competency.COMMUNICATION if code is ErrorCode.D5 else Competency.CARD
),
)
_RECIPIENT_TERMS = (
"бригад",
"старш",
"дежурн",
"диспетчер",
"оператор",
"заявител",
"пострадавш",
"мвд",
"полици",
"мчс",
"скорая",
"медицинск",
"аварийн",
"служба 101",
"служба 102",
"служба 103",
"служба 104",
)
def _has_recipient(text: str, service: str, crew: str | None) -> bool:
value = text.casefold()
if any(term in value for term in _RECIPIENT_TERMS):
return True
candidates = [service, crew or ""]
return any(len(item.strip()) >= 3 and item.strip().casefold() in value for item in candidates)
def _has_basis_and_information(text: str) -> bool:
"""Require two explicit fields; this checks structure, not factual truth."""
fields: dict[str, str] = {}
for line in text.splitlines():
label, separator, value = line.partition(":")
if separator and label.strip().casefold() in {"основание", "сведения"}:
fields[label.strip().casefold()] = value.strip()
return bool(fields.get("основание") and fields.get("сведения"))
def evaluate_dispatcher(
*,
entries: list[StatusEntry],
services: list[str],
crew_assignments: dict[str, str] | None = None,
deadline_ms: int,
elapsed_ms: int | None,
reply_text: str = "",
expected_decision: str = "accept",
expected_decision_reason: str | None = None,
) -> list[Finding]:
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
@ -51,6 +99,7 @@ def evaluate_dispatcher(
компетенция» неправомерен.
"""
findings: list[Finding] = []
crew_assignments = crew_assignments or {}
if not services:
return findings
@ -58,8 +107,63 @@ def evaluate_dispatcher(
marks = [entry for entry in entries if entry.service == service]
latest = current(entries, service)
# D1 — первичного статуса нет вовсе.
if not any(mark.status in PRIMARY for mark in marks):
# D5 — мягкая проверка памятки: если диспетчер оставил комментарий,
# в нём должен быть назван получатель сведений. Статусы и внешние
# звонки не используются как выдуманное доказательство.
missing_comment_statuses = [
SERVICE_STATUS_LABELS[mark.status]
for mark in marks
if mark.status in COMMENT_REQUIRED and not mark.comment.strip()
]
if missing_comment_statuses:
findings.append(_finding(
ErrorCode.D5,
f"{service}: к статусу не добавлены основание и сведения",
fact="не заполнены комментарии: " + ", ".join(missing_comment_statuses),
norm="к каждой ручной отметке добавить основание и содержание полученных сведений",
))
incomplete_comment_statuses = [
SERVICE_STATUS_LABELS[mark.status]
for mark in marks
if mark.status in COMMENT_REQUIRED
and mark.comment.strip()
and not _has_basis_and_information(mark.comment)
]
if incomplete_comment_statuses:
findings.append(_finding(
ErrorCode.D5,
f"{service}: комментарии к статусам не разделяют основание и сведения",
fact="неполные комментарии: " + ", ".join(incomplete_comment_statuses),
norm="в каждом комментарии заполнить отдельные поля «Основание» и «Сведения»",
))
comments_to_check = [
(SERVICE_STATUS_LABELS[mark.status], mark.comment.strip())
for mark in marks
if mark.comment.strip()
]
if reply_text.strip():
comments_to_check.append(("ответ ДДС", reply_text.strip()))
missing_recipients = [
f"{label}: {comment[:180]}"
for label, comment in comments_to_check
if not _has_recipient(comment, service, crew_assignments.get(service))
]
if missing_recipients:
findings.append(_finding(
ErrorCode.D5,
f"{service}: отдельный комментарий не указывает получателя сведений",
fact="; ".join(missing_recipients)[:500],
norm=(
"назвать, кому переданы сведения: бригаде, службе, "
"заявителю или иному адресату"
),
))
# D1 — первичного статуса нет либо он проставлен позже норматива.
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
if primary is None:
findings.append(
_finding(
ErrorCode.D1,
@ -74,9 +178,23 @@ def evaluate_dispatcher(
)
continue
if elapsed_ms is None or elapsed_ms > deadline_ms:
findings.append(
_finding(
ErrorCode.D1,
f"{service}: первичный статус проставлен с нарушением срока",
fact=(
f"прошло {elapsed_ms // 1000} с"
if elapsed_ms is not None
else "время первичной отметки не зафиксировано"
),
norm=f"первичный статус в течение {deadline_ms // 1000} с",
)
)
for mark in marks:
# D4 — отказ без комментария.
if mark.status in COMMENT_REQUIRED and not mark.comment.strip():
if mark.status in REFUSAL_COMMENT_REQUIRED and not mark.comment.strip():
findings.append(
_finding(
ErrorCode.D4,
@ -86,48 +204,89 @@ def evaluate_dispatcher(
)
)
# D3 — отказ от профильного происшествия. Служба в списке оповещения
# по классификатору, значит происшествие входит в её компетенцию.
if latest is ServiceStatus.DECLINED:
# D3 — отказ от профильного происшествия, когда эталон сценария
# требует принятия. Дубль/территориальное исключение задаются явно.
if latest is ServiceStatus.DECLINED and expected_decision == "accept":
findings.append(
_finding(
ErrorCode.D3,
f"{service}: отказ от происшествия, которое в её компетенции",
fact="служба есть в списке оповещения по ЕКП",
norm="отказываться от профильного происшествия нельзя",
norm="принять согласно эталону сценария",
)
)
# D2 — «Принята», но работ не было и отказа тоже: статус не отражает факт.
if latest is ServiceStatus.ACCEPTED:
elif latest is ServiceStatus.ACCEPTED and expected_decision == "decline":
findings.append(
_finding(
ErrorCode.D2,
f"{service}: «Принята», но о реагировании ничего не отмечено",
fact="после приёма статусов не было",
norm="статус должен соответствовать фактическому состоянию заявки",
f"{service}: принято вопреки эталону сценария",
fact="карточка принята службой",
norm=expected_decision_reason or "отказать с указанной причиной",
)
)
# D6 — работы завершены, а ход работ не отмечен. В памятке это отдельный
# разбор: по такому происшествию идут повторные звонки, и другие службы
# не видят, что реагирование вообще началось.
if latest is ServiceStatus.COMPLETED and not any(
# D2 — «Принята» без назначения бригады/дальнейшего хода или ход без
# зафиксированной бригады. Заказчик уточнил: необходимые бригады ДДС
# выбирает вручную; канал получения докладов при этом не предписан.
if latest is ServiceStatus.ACCEPTED and expected_decision == "accept":
if service not in crew_assignments:
findings.append(
_finding(
ErrorCode.D2,
f"{service}: «Принята» без назначения бригады и хода реагирования",
fact="бригада не выбрана, после приёма статусов не было",
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
)
)
else:
findings.append(
_finding(
ErrorCode.D2,
f"{service}: «Принята», но о реагировании ничего не отмечено",
fact="после приёма статусов не было",
norm="статус должен соответствовать фактическому состоянию заявки",
)
)
elif latest is not ServiceStatus.DECLINED and any(
mark.status in PROGRESS for mark in marks
):
) and service not in crew_assignments:
findings.append(
_finding(
ErrorCode.D2,
f"{service}: ход реагирования без назначения бригады",
fact="статусы хода работ проставлены, бригада не выбрана",
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
)
)
# D6 — ход работ неполон к моменту закрытия карточки/занятия. В памятке
# это приводит к повторным звонкам и скрывает от других служб факт реакции.
# REFUSED — отдельный допустимый терминальный статус с обязательной причиной.
missing_progress = [status for status in PROGRESS if status not in {m.status for m in marks}]
if (latest not in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
and (missing_progress or latest is not ServiceStatus.COMPLETED)):
missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress)
findings.append(
_finding(
ErrorCode.D6,
f"{service}: работы завершены без отметок хода",
fact="начало реагирования и прибытие не отмечены",
norm="статусы хода работ проставляются по факту",
f"{service}: ход реагирования не доведён до конца",
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта"),
norm=(
"отметить по факту начало реагирования, прибытие, проведение работ "
"и завершение; если работы не проводились — оформить отказ с причиной"
),
)
)
return findings
def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
def dispatcher_metrics(
state,
deadline_ms: int,
expected_decision: str = "accept",
expected_decision_reason: str | None = None,
) -> list[Metric]:
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
Веса предварительные — до утверждения методистом. Телефон — возможный
@ -156,19 +315,40 @@ def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
f"≤ {deadline_ms // 1000} с")
if primary is None:
continue
add("dds_decision", "профильное реагирование",
primary.status is ServiceStatus.ACCEPTED,
primary.status.value, "профильную заявку принять", 2.0)
if primary.status is ServiceStatus.DECLINED:
expected_status = (ServiceStatus.ACCEPTED if expected_decision == "accept"
else ServiceStatus.DECLINED)
expected_label = "Принята" if expected_decision == "accept" else "Не принята"
add("dds_decision", "решение по эталону сценария",
primary.status is expected_status,
primary.status.value,
expected_decision_reason or f"по эталону ожидается «{expected_label}»", 2.0)
if primary.status is ServiceStatus.DECLINED or expected_decision == "decline":
continue
crew = state.crew_assignments.get(service)
add("dds_crew", "назначение бригады", bool(crew),
crew or "бригада не выбрана",
"необходимую бригаду выбирает ДДС вручную", 2.0)
expected = {
ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING,
}
add("dds_progress", "ведение хода реагирования", expected <= statuses,
", ".join(status.value for status in statuses) or "статусов нет",
"начало реагирования, прибытие и работы отмечены по полученной информации", 2.0)
refused = ServiceStatus.REFUSED in statuses
add("dds_progress", "ведение хода реагирования",
expected <= statuses or refused,
("отказ от выполнения работ" if refused else
", ".join(mark.status.value for mark in marks if mark.status in expected)
or "статусов хода нет"),
"отметить по факту ход работ либо оформить обоснованный отказ от их выполнения", 2.0)
add("dds_completion", "закрытие работ",
ServiceStatus.COMPLETED in statuses,
"завершено" if ServiceStatus.COMPLETED in statuses else "не завершено",
"по факту поставить статус «Работы завершены»")
ServiceStatus.COMPLETED in statuses or refused,
("завершено" if ServiceStatus.COMPLETED in statuses else
"оформлен отказ от выполнения работ" if refused else "не завершено"),
"зафиксировать фактическое завершение работ или отказ от их выполнения")
notes = [mark.comment.strip() for mark in marks if mark.status in COMMENT_REQUIRED]
notes_complete = bool(notes) and all(
_has_basis_and_information(note) for note in notes
)
add("dds_reply", "основание статусов и сведения о ходе работ",
notes_complete,
"; ".join(notes) if notes else "комментарии к статусам не внесены",
"к каждой ручной отметке добавить основание и содержание полученных сведений")
return metrics

View file

@ -49,7 +49,10 @@ def to_csv(report: SessionReport) -> bytes:
row("Оценка", "", "Причина изменения", report.override_comment)
for number, item in enumerate(report.metrics, 1):
row("Метрики", number, item.title, item.fact, f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; вес: {item.weight:g}; источник: {item.ref or ''}")
credit = f"; оценочный вклад: {item.credit:.0%}" if item.credit is not None else ""
row("Метрики", number, item.title, item.fact,
f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; "
f"вес: {item.weight:g}{credit}; источник: {item.ref or ''}")
for number, item in enumerate(report.findings, 1):
row("Ошибки", number, item.code.value, item.summary, f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}")
for number, item in enumerate(report.competencies, 1):
@ -99,7 +102,6 @@ def _font_paths() -> tuple[Path, Path | None]:
def to_pdf(report: SessionReport) -> bytes:
"""Собрать многостраничный PDF с кириллицей и переносом длинных текстов."""
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle
from reportlab.pdfbase import pdfmetrics
@ -167,7 +169,9 @@ def to_pdf(report: SessionReport) -> bytes:
if not report.metrics:
story.append(p("Нет данных", muted))
for item in report.metrics:
story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'} (вес {item.weight:g})"))
credit = f", оценочный вклад {item.credit:.0%}" if item.credit is not None else ""
story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'} "
f"(вес {item.weight:g}{credit})"))
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm}. {item.ref or ''}", muted))
section("Выявленные ошибки")

View file

@ -1,12 +1,11 @@
"""Детерминированный слой оценки — 60% веса, считается кодом.
"""Метрики опроса и КИО — объяснимые правила, считаются кодом.
Воспроизводится стопроцентно: один и тот же ход занятия даёт один и тот же
результат. Каждая метрика — «факт против норматива со ссылкой», а не балл:
«опрос 94 с при нормативе 75 с (ГОСТ Р 22.7.03-2021)» можно предъявить
и проверить руками (docs/product/DEBRIEF.md).
результат. Каждая метрика — «факт против норматива со ссылкой». Для временных
метрик к двоичному признаку нарушения добавлен прозрачный непрерывный вклад,
описанный в docs/product/METHODOLOGY.md.
"""
import re
from dataclasses import dataclass, field
from app.domain import ekp
@ -16,6 +15,7 @@ from app.domain.kio import KIO, missing_fields
from app.domain.taxonomy import ERRORS, Competency, Finding, FindingSource
from app.domain.timers import GOST_REF, NORMATIVES, TimerCode
from app.scenarios.schema import Scenario
from app.scoring.address import address_matches
from app.scoring.taxonomy import METRIC_MAP, METRIC_WEIGHTS
from app.session.timers import SessionTimers
@ -42,7 +42,8 @@ class GostResult:
total = sum(metric.weight for metric in self.metrics)
if not total:
return 0.0
passed = sum(metric.weight for metric in self.metrics if metric.passed)
passed = sum(metric.weight * (metric.credit if metric.credit is not None
else float(metric.passed)) for metric in self.metrics)
return round(100 * passed / total, 1)
@ -50,16 +51,6 @@ def _seconds(ms: int) -> str:
return f"{round(ms / 1000)} с"
def _normalize_address(text: str | None) -> set[str]:
"""Слова адреса без служебных: «ул. Ленина д. 14» и «улица Ленина, 14»
должны совпасть, иначе курсанта штрафуют за сокращение."""
if not text:
return set()
noise = {"улица", "ул", "дом", "д", "проспект", "пр", "переулок", "пер", "г", "город", "москва"}
words = re.findall(r"[\w-]+", text.lower().replace("ё", "е"))
return {word for word in words if word not in noise}
class _Builder:
def __init__(self) -> None:
self.result = GostResult()
@ -271,11 +262,10 @@ def evaluate(
return build.result
if truth.address:
written = kio.address or " ".join(filter(None, [kio.street, kio.building]))
expected = _normalize_address(truth.address)
build.add(
"address", "Адрес",
written or "не заполнен", truth.address,
passed=bool(expected) and expected <= _normalize_address(written),
passed=address_matches(truth.address, written),
ref="ground_truth сценария",
finding=f"Адрес в карточке «{written or 'пусто'}», верный — «{truth.address}»",
)

View file

@ -53,7 +53,6 @@ def build(scenario: Scenario) -> ReferenceDialog:
# звонке: эталон должен звучать так же, а не литературно.
answer=REVEAL[mood].format(fact=fact.value) if fact else None,
required=bool(fact and fact.id in required),
hidden=bool(fact and fact.hidden),
)
)
return ReferenceDialog(scenario_id=scenario.id, first_line=scenario.first_line, steps=steps)

View file

@ -79,6 +79,7 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
if state.exercise is Exercise.DDS and state.dds_scenarios
else scenario.id),
mode=state.mode,
exercise=state.exercise,
attempt=state.attempt,
criteria=state.criteria,
failed_metrics=failed_metrics,

View file

@ -23,6 +23,8 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
"required_fields": (ErrorCode.E5, Competency.CARD),
"dds_primary": (ErrorCode.D1, Competency.CARD),
"dds_ack": (ErrorCode.D1, Competency.NORMS),
"card_fill_time": (ErrorCode.E3, Competency.NORMS),
"dds_work_time": (ErrorCode.E3, Competency.NORMS),
"dds_decision": (ErrorCode.D3, Competency.ROUTING),
"dds_crew": (ErrorCode.D2, Competency.ROUTING),
"dds_contact": (ErrorCode.D6, Competency.COMMUNICATION),
@ -30,6 +32,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
"dds_completion": (ErrorCode.D6, Competency.CARD),
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
"dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
"description_grammar": (ErrorCode.E4, Competency.COMMUNICATION),
}
#: Вес метрики в детерминированной оценке.
@ -53,9 +56,7 @@ METRIC_WEIGHTS: dict[str, float] = {
"answer_time": 1.0,
"callback": 1.0,
"dds_chain": 2.0,
"description_grammar": 1.0,
"card_fill_time": 1.5,
"dds_work_time": 1.5,
}
#: Вес детерминированного слоя в итоговой оценке. Остальное — LLM-судья
#: на мягкие критерии (E4), и не больше (docs/arch/BACKEND.md).
DETERMINISTIC_WEIGHT = 0.6
JUDGE_WEIGHT = 0.4

View file

@ -0,0 +1,13 @@
"""Временная составляющая оценки по занятой методике.
Скорость даёт непрерывный вклад, а превышение норматива дополнительно
отмечается как E3. Линейный множитель 1 − t/(2T) ограничен диапазоном 0–1:
нулевое время даёт полный вклад, два норматива и более — нулевой.
"""
def time_credit(elapsed_ms: int | None, limit_ms: int) -> float:
"""Доля веса временной метрики, 0–1; отсутствие доказанного времени — 0."""
if elapsed_ms is None or elapsed_ms < 0 or limit_ms <= 0:
return 0.0
return round(max(0.0, min(1.0, 1.0 - elapsed_ms / (2 * limit_ms))), 4)