Implement local task 09 training workflows
This commit is contained in:
parent
e081fa410b
commit
cec84ffcd0
45 changed files with 2679 additions and 366 deletions
70
backend/app/scoring/card.py
Normal file
70
backend/app/scoring/card.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""Детерминированная оценка текстового упражнения оператора 112.
|
||||
|
||||
Нет входящего звонка и опроса: оцениваются только поля готовой КИО и
|
||||
маршрутизация. Эталон берётся из утверждённого сценария, но не показывается
|
||||
в `card.briefing` до завершения упражнения.
|
||||
"""
|
||||
|
||||
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.taxonomy import METRIC_MAP, METRIC_WEIGHTS
|
||||
|
||||
|
||||
def evaluate_card(scenario: Scenario, kio: KIO) -> GostResult:
|
||||
result = GostResult()
|
||||
truth = scenario.ground_truth
|
||||
|
||||
def add(key: str, title: str, fact: str, norm: str, passed: bool, ref: str) -> None:
|
||||
result.metrics.append(Metric(
|
||||
key=key, title=title, fact=fact, norm=norm, passed=passed,
|
||||
ref=ref, weight=METRIC_WEIGHTS.get(key, 1.0),
|
||||
))
|
||||
if not passed:
|
||||
code, competency = METRIC_MAP[key]
|
||||
result.findings.append(Finding(
|
||||
code=code,
|
||||
source=FindingSource.KIO if code.value == "E5" else FindingSource.GROUND_TRUTH,
|
||||
summary=f"{title}: {fact}; требуется {norm}",
|
||||
fact=fact, norm=norm, ref=ref, competency=competency,
|
||||
))
|
||||
|
||||
if truth.incident_code:
|
||||
actual = kio.incident_code or "не определён"
|
||||
add("incident_signs", "Признаки происшествия", actual,
|
||||
truth.incident_code, actual == truth.incident_code, "классификатор ЕКП")
|
||||
elif truth.incident_type:
|
||||
actual = kio.incident_type.value if kio.incident_type else "не указан"
|
||||
add("incident_type", "Тип происшествия", actual,
|
||||
truth.incident_type.value, kio.incident_type == truth.incident_type,
|
||||
"классификатор происшествий")
|
||||
|
||||
if truth.dds and truth.incident_code is None:
|
||||
actual = kio.dds.value if kio.dds else "не выбрана"
|
||||
add("dds_choice", "ДДС", actual, truth.dds.value,
|
||||
kio.dds == truth.dds, "список оповещения по сценарию")
|
||||
|
||||
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),
|
||||
"эталон сценария")
|
||||
|
||||
if truth.victims is not None:
|
||||
actual = str(kio.victims_count) if kio.victims_count is not None else "не указано"
|
||||
add("victims_count", "Число пострадавших", actual, str(truth.victims),
|
||||
kio.victims_count == truth.victims, "эталон сценария")
|
||||
|
||||
required_fields = ([field for field in scenario.required_fields if field != "dds"]
|
||||
if truth.incident_code else scenario.required_fields)
|
||||
if required_fields:
|
||||
missing = missing_fields(kio, required_fields)
|
||||
add("required_fields", "Обязательные поля карточки",
|
||||
"все заполнены" if not missing else "пусто: " + ", ".join(missing),
|
||||
"все обязательные поля заполнены", not missing,
|
||||
"поля КИО утверждённого сценария")
|
||||
|
||||
return result
|
||||
|
|
@ -18,6 +18,8 @@ from app.domain.statuses import (
|
|||
current,
|
||||
)
|
||||
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
||||
from app.domain.events import Metric
|
||||
from app.domain.timers import GOST_REF
|
||||
|
||||
#: Статусы, означающие, что реагирование действительно шло.
|
||||
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
|
||||
|
|
@ -123,3 +125,61 @@ def evaluate_dispatcher(
|
|||
)
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
|
||||
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
|
||||
|
||||
Веса предварительные — до утверждения методистом. Неподтверждённые
|
||||
телефонные действия нельзя засчитывать по одной кнопке статуса.
|
||||
"""
|
||||
metrics: list[Metric] = []
|
||||
for service in state.notified_services():
|
||||
marks = [mark for mark in state.status_log if mark.service == service]
|
||||
statuses = {mark.status for mark in marks}
|
||||
reports = {report.phase for report in state.phone_reports if report.service == service}
|
||||
selected_crew = state.crew_assignments.get(service)
|
||||
selected = selected_crew is not None
|
||||
|
||||
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0):
|
||||
metrics.append(Metric(
|
||||
key=key, title=f"{service}: {title}", fact=fact, norm=norm,
|
||||
ref=GOST_REF if key == "dds_ack" else "памятка «Работа на АРМ-112», статусы реагирования",
|
||||
passed=passed, weight=weight,
|
||||
))
|
||||
|
||||
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
|
||||
elapsed = (int((primary.at - state.dispatched_at).total_seconds() * 1000)
|
||||
if primary and state.dispatched_at else None)
|
||||
add("dds_primary", "решение по карточке", primary is not None,
|
||||
primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0)
|
||||
add("dds_ack", "первичная отметка за 30 с",
|
||||
elapsed is not None and elapsed <= deadline_ms,
|
||||
f"{elapsed / 1000:.1f} с" if elapsed is not None else "не отмечено",
|
||||
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:
|
||||
continue
|
||||
add("dds_crew", "ручной выбор бригады", selected,
|
||||
selected_crew or "бригада не выбрана", "выбрать конкретную бригаду", 1.5)
|
||||
add("dds_contact", "связь с бригадой", bool(reports),
|
||||
", ".join(sorted(reports)) or "докладов нет", "получить доклад о выезде", 1.5)
|
||||
expected = {
|
||||
"dispatched": ServiceStatus.RESPONDING,
|
||||
"arrived": ServiceStatus.ARRIVED,
|
||||
"working": ServiceStatus.WORKING,
|
||||
}
|
||||
aligned = all(status in statuses for phase, status in expected.items() if phase in reports)
|
||||
all_progress = all(phase in reports and status in statuses for phase, status in expected.items())
|
||||
add("dds_progress", "статусы по докладам", aligned and all_progress,
|
||||
", ".join(status.value for status in statuses) or "статусов нет",
|
||||
"выезд, прибытие и работы отмечены после докладов", 2.0)
|
||||
add("dds_completion", "закрытие работ по докладу",
|
||||
"completed" in reports and ServiceStatus.COMPLETED in statuses,
|
||||
"завершено" if ServiceStatus.COMPLETED in statuses else "не завершено",
|
||||
"доклад о завершении и статус «Работы завершены»")
|
||||
return metrics
|
||||
|
|
|
|||
228
backend/app/scoring/export.py
Normal file
228
backend/app/scoring/export.py
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
"""Экспорт разбора занятия без обращения к внешним сервисам.
|
||||
|
||||
CSV - плоский журнал с пометкой раздела. PDF - читаемый протокол с переносом
|
||||
длинных реплик и автоматическим разбиением на страницы.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
|
||||
from app.domain.events import SessionReport
|
||||
|
||||
|
||||
def _cell(value: object) -> str:
|
||||
"""Excel/LibreOffice не должны исполнять текст курсанта как формулу."""
|
||||
if value is None:
|
||||
return ""
|
||||
result = str(value)
|
||||
probe = result.lstrip(" \t\r\n\ufeff\u200b")
|
||||
if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")):
|
||||
return "'" + result
|
||||
return result
|
||||
|
||||
|
||||
def to_csv(report: SessionReport) -> bytes:
|
||||
"""UTF-8 с BOM: кириллица открывается в Excel без выбора кодировки."""
|
||||
output = io.StringIO(newline="")
|
||||
writer = csv.writer(output, lineterminator="\r\n")
|
||||
writer.writerow(["Раздел", "№", "Поле", "Значение", "Дополнительно"])
|
||||
|
||||
def row(section: str, index: object, field: str, value: object, detail: object = "") -> None:
|
||||
writer.writerow([_cell(part) for part in (section, index, field, value, detail)])
|
||||
|
||||
row("Занятие", "", "ID", report.session_id)
|
||||
row("Занятие", "", "Сценарий", report.scenario_id)
|
||||
row("Занятие", "", "Режим", report.mode.value)
|
||||
row("Занятие", "", "Попытка", report.attempt)
|
||||
row("Оценка", "", "Автоматическая", report.score_auto)
|
||||
row("Оценка", "", "Итоговая", report.score_final)
|
||||
row("Оценка", "", "Изменена преподавателем", report.overridden_by)
|
||||
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.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):
|
||||
row("Компетенции", number, item.competency, item.value)
|
||||
for number, item in enumerate(report.transcript, 1):
|
||||
row("Транскрипт", number, item.speaker.value, item.text, item.at.isoformat())
|
||||
for number, item in enumerate(report.reference_questions, 1):
|
||||
row("Эталонные вопросы", number, item.checklist_id, item.question)
|
||||
for number, item in enumerate(report.missed_checklist, 1):
|
||||
row("Пропущенные вопросы", number, "Пункт", item)
|
||||
for number, item in enumerate(report.hints_used, 1):
|
||||
row("Подсказки", number, item.checklist_id, item.question, item.at.isoformat())
|
||||
if report.self_assessment:
|
||||
row("Самооценка", "", "Пропущено по мнению курсанта", ", ".join(report.self_assessment.missed))
|
||||
row("Самооценка", "", "Комментарий", report.self_assessment.comment)
|
||||
if report.self_assessment_diff:
|
||||
for label, values in (
|
||||
("Заметил", report.self_assessment_diff.noticed),
|
||||
("Не заметил", report.self_assessment_diff.unnoticed),
|
||||
("Отметил ошибочно", report.self_assessment_diff.overcautious),
|
||||
):
|
||||
row("Сверка самооценки", "", label, ", ".join(values))
|
||||
for number, item in enumerate(report.notes, 1):
|
||||
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
|
||||
return ("\ufeff" + output.getvalue()).encode("utf-8")
|
||||
|
||||
|
||||
def _font_paths() -> tuple[Path, Path | None]:
|
||||
"""Находить TTF с кириллицей явно, без молчаливой подмены Helvetica."""
|
||||
custom = os.environ.get("LCT_PDF_FONT")
|
||||
if custom:
|
||||
path = Path(custom)
|
||||
if not path.is_file():
|
||||
raise RuntimeError(f"LCT_PDF_FONT не найден: {path}")
|
||||
return path, None
|
||||
candidates = (
|
||||
("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
|
||||
("/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf", "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf"),
|
||||
("/System/Library/Fonts/Supplemental/Arial.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf"),
|
||||
)
|
||||
for regular, bold in candidates:
|
||||
if Path(regular).is_file():
|
||||
return Path(regular), Path(bold) if Path(bold).is_file() else None
|
||||
raise RuntimeError("Не найден TTF-шрифт с кириллицей; установите DejaVu Sans или задайте LCT_PDF_FONT")
|
||||
|
||||
|
||||
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
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.platypus import HRFlowable, Paragraph, SimpleDocTemplate, Spacer
|
||||
|
||||
regular_path, bold_path = _font_paths()
|
||||
regular_name = "LCTReportRegular"
|
||||
bold_name = "LCTReportBold"
|
||||
if regular_name not in pdfmetrics.getRegisteredFontNames():
|
||||
regular = TTFont(regular_name, str(regular_path))
|
||||
if not all(ord(char) in regular.face.charToGlyph for char in "Отчёт курсанта Ёж"):
|
||||
raise RuntimeError(f"Шрифт не поддерживает кириллицу: {regular_path}")
|
||||
pdfmetrics.registerFont(regular)
|
||||
pdfmetrics.registerFont(TTFont(bold_name, str(bold_path or regular_path)))
|
||||
pdfmetrics.registerFontFamily(regular_name, normal=regular_name, bold=bold_name)
|
||||
|
||||
base = ParagraphStyle(
|
||||
"body", fontName=regular_name, fontSize=9, leading=13,
|
||||
textColor=colors.HexColor("#18202a"), spaceAfter=5,
|
||||
allowWidows=0, allowOrphans=0, splitLongWords=1,
|
||||
)
|
||||
title_style = ParagraphStyle(
|
||||
"title", parent=base, fontName=bold_name, fontSize=17, leading=22,
|
||||
textColor=colors.HexColor("#193b56"), spaceAfter=10,
|
||||
)
|
||||
heading = ParagraphStyle(
|
||||
"heading", parent=base, fontName=bold_name, fontSize=12, leading=17,
|
||||
textColor=colors.HexColor("#193b56"), spaceBefore=13, spaceAfter=5,
|
||||
keepWithNext=True,
|
||||
)
|
||||
muted = ParagraphStyle("muted", parent=base, fontSize=8, textColor=colors.HexColor("#5d6873"))
|
||||
|
||||
def p(value: object, style=base):
|
||||
# XML-экранирование необходимо: реплики и пометки вводит человек.
|
||||
safe = escape("" if value is None else str(value)).replace("\n", "<br/>")
|
||||
return Paragraph(safe or "-", style)
|
||||
|
||||
story = [p("Отчёт по учебному занятию", title_style)]
|
||||
for label, value in (
|
||||
("Идентификатор", report.session_id),
|
||||
("Сценарий", report.scenario_id),
|
||||
("Режим", report.mode.value),
|
||||
("Попытка", report.attempt),
|
||||
("Автоматическая оценка", report.score_auto),
|
||||
("Итоговая оценка", report.score_final),
|
||||
):
|
||||
story.append(Paragraph(f"<b>{escape(label)}:</b> {escape(str(value))}", base))
|
||||
if report.overridden_by:
|
||||
story.append(Paragraph(f"<b>Оценка изменена:</b> {escape(report.overridden_by)}", base))
|
||||
if report.override_comment:
|
||||
story.append(p(f"Причина изменения: {report.override_comment}"))
|
||||
story.extend([Spacer(1, 7), HRFlowable(width="100%", color=colors.HexColor("#aeb8c2"))])
|
||||
|
||||
def section(label: str):
|
||||
story.append(p(label, heading))
|
||||
|
||||
section("Метрики")
|
||||
if not report.metrics:
|
||||
story.append(p("Нет данных", muted))
|
||||
for item in report.metrics:
|
||||
story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'}"))
|
||||
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm}. {item.ref or ''}", muted))
|
||||
|
||||
section("Выявленные ошибки")
|
||||
if not report.findings:
|
||||
story.append(p("Ошибок не выявлено", muted))
|
||||
for item in report.findings:
|
||||
story.append(p(f"{item.code.value} - {item.summary}"))
|
||||
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. Источник: {item.ref or 'не указан'}", muted))
|
||||
|
||||
section("Компетенции")
|
||||
for item in report.competencies:
|
||||
story.append(p(f"{item.competency}: {item.value:g}"))
|
||||
if not report.competencies:
|
||||
story.append(p("Нет данных", muted))
|
||||
|
||||
section("Транскрипт")
|
||||
if not report.transcript:
|
||||
story.append(p("Реплик нет", muted))
|
||||
for item in report.transcript:
|
||||
story.append(p(f"{item.at.isoformat()} - {item.speaker.value}", muted))
|
||||
story.append(p(item.text))
|
||||
|
||||
section("Эталонные вопросы")
|
||||
for item in report.reference_questions:
|
||||
story.append(p(f"{item.checklist_id}: {item.question}"))
|
||||
if not report.reference_questions:
|
||||
story.append(p("Нет данных", muted))
|
||||
if report.missed_checklist:
|
||||
section("Пропущенные пункты")
|
||||
for item in report.missed_checklist:
|
||||
story.append(p(item))
|
||||
|
||||
if report.hints_used:
|
||||
section("Использованные подсказки")
|
||||
for item in report.hints_used:
|
||||
story.append(p(f"{item.at.isoformat()} - {item.checklist_id}: {item.question}"))
|
||||
|
||||
if report.self_assessment:
|
||||
section("Самооценка")
|
||||
story.append(p("Пропущено по мнению курсанта: " + ", ".join(report.self_assessment.missed)))
|
||||
story.append(p("Комментарий: " + report.self_assessment.comment))
|
||||
if report.self_assessment_diff:
|
||||
for label, items in (
|
||||
("Заметил", report.self_assessment_diff.noticed),
|
||||
("Не заметил", report.self_assessment_diff.unnoticed),
|
||||
("Отметил ошибочно", report.self_assessment_diff.overcautious),
|
||||
):
|
||||
story.append(p(f"{label}: {', '.join(items) or '-'}"))
|
||||
if report.notes:
|
||||
section("Пометки преподавателя")
|
||||
for item in report.notes:
|
||||
story.append(p(f"{item.transcript_ref} - {item.author}: {item.text}"))
|
||||
|
||||
output = io.BytesIO()
|
||||
document = SimpleDocTemplate(
|
||||
output, pagesize=A4, leftMargin=42, rightMargin=42,
|
||||
topMargin=45, bottomMargin=44,
|
||||
title="Отчёт по учебному занятию", author="Учебный симулятор 112",
|
||||
)
|
||||
|
||||
def footer(canvas, doc):
|
||||
canvas.saveState()
|
||||
canvas.setFont(regular_name, 8)
|
||||
canvas.setFillColor(colors.HexColor("#5d6873"))
|
||||
canvas.drawString(42, 25, "Учебный симулятор 112")
|
||||
canvas.drawRightString(A4[0] - 42, 25, f"Стр. {doc.page}")
|
||||
canvas.restoreState()
|
||||
|
||||
document.build(story, onFirstPage=footer, onLaterPages=footer)
|
||||
return output.getvalue()
|
||||
|
|
@ -16,6 +16,8 @@ from app.domain.events import (
|
|||
SelfAssessment,
|
||||
SelfAssessmentDiff,
|
||||
SessionReport,
|
||||
Speaker,
|
||||
Exercise,
|
||||
)
|
||||
from app.domain.taxonomy import Finding
|
||||
from app.scenarios.schema import Scenario
|
||||
|
|
@ -43,7 +45,12 @@ def assess_difference(
|
|||
|
||||
def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
|
||||
score = state.score or {}
|
||||
revealed = [fact.id for fact in state.slots.revealed_facts()] if state.slots else None
|
||||
# Без модели эмбеддингов распознать *какие* факты прозвучали нельзя.
|
||||
# Но если оператор не произнёс ни слова, все обязательные вопросы точно
|
||||
# пропущены — это доказуемый частный случай и он нужен офлайн-разбору.
|
||||
revealed = ([fact.id for fact in state.slots.revealed_facts()] if state.slots else
|
||||
[] if not any(entry.speaker is Speaker.OPERATOR for entry in state.transcript)
|
||||
else None)
|
||||
reference = build_reference(scenario)
|
||||
|
||||
self_assessment = None
|
||||
|
|
@ -61,7 +68,7 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
|
|||
step.checklist_id
|
||||
for step in reference.steps
|
||||
if step.required and revealed is not None and step.fact_id not in revealed
|
||||
]
|
||||
] if state.exercise is not Exercise.CARD else []
|
||||
|
||||
return SessionReport(
|
||||
session_id=session_id,
|
||||
|
|
@ -77,7 +84,7 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
|
|||
reference_questions=[
|
||||
HintShown(checklist_id=step.checklist_id, question=step.question)
|
||||
for step in reference.steps
|
||||
],
|
||||
] if state.exercise is not Exercise.CARD else [],
|
||||
missed_checklist=missed_checklist,
|
||||
hints_used=[
|
||||
HintUsage(checklist_id=checklist_id, question=questions.get(checklist_id, ""), at=at)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
|
|||
"address": (ErrorCode.E5, Competency.CARD),
|
||||
"victims_count": (ErrorCode.E5, Competency.CARD),
|
||||
"required_fields": (ErrorCode.E5, Competency.CARD),
|
||||
"dds_primary": (ErrorCode.D1, Competency.CARD),
|
||||
"dds_ack": (ErrorCode.D1, Competency.NORMS),
|
||||
"dds_decision": (ErrorCode.D3, Competency.ROUTING),
|
||||
"dds_crew": (ErrorCode.D2, Competency.ROUTING),
|
||||
"dds_contact": (ErrorCode.D6, Competency.COMMUNICATION),
|
||||
"dds_progress": (ErrorCode.D6, Competency.CARD),
|
||||
"dds_completion": (ErrorCode.D6, Competency.CARD),
|
||||
}
|
||||
|
||||
#: Вес метрики в детерминированной оценке.
|
||||
|
|
|
|||
Loading…
Reference in a new issue