Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
80
backend/app/scoring/address.py
Normal file
80
backend/app/scoring/address.py
Normal 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())
|
||||
83
backend/app/scoring/ai_coach.py
Normal file
83
backend/app/scoring/ai_coach.py
Normal 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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("Выявленные ошибки")
|
||||
|
|
|
|||
|
|
@ -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}»",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
13
backend/app/scoring/timing.py
Normal file
13
backend/app/scoring/timing.py
Normal 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)
|
||||
Loading…
Reference in a new issue