Complete DDS training workflow and delivery package

This commit is contained in:
andreysk0304 2026-09-24 01:10:49 +03:00
commit 4c4b91064f
229 changed files with 11969 additions and 1024 deletions

View file

@ -130,16 +130,13 @@ def evaluate_dispatcher(
def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
Веса предварительные — до утверждения методистом. Неподтверждённые
телефонные действия нельзя засчитывать по одной кнопке статуса.
Веса предварительные — до утверждения методистом. Телефон — возможный
источник информации, но не обязательный шлюз статуса.
"""
metrics: list[Metric] = []
for service in state.notified_services():
for service in state.managed_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(
@ -153,7 +150,7 @@ def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
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 с",
add("dds_ack", f"первичная отметка за {deadline_ms // 1000} с",
elapsed is not None and elapsed <= deadline_ms,
f"{elapsed / 1000:.1f} с" if elapsed is not None else "не отмечено",
f"≤ {deadline_ms // 1000} с")
@ -164,22 +161,14 @@ def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
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,
ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, 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,
add("dds_progress", "ведение хода реагирования", expected <= statuses,
", ".join(status.value for status in statuses) or "статусов нет",
"выезд, прибытие и работы отмечены после докладов", 2.0)
add("dds_completion", "закрытие работ по докладу",
"completed" in reports and ServiceStatus.COMPLETED in statuses,
"начало реагирования, прибытие и работы отмечены по полученной информации", 2.0)
add("dds_completion", "закрытие работ",
ServiceStatus.COMPLETED in statuses,
"завершено" if ServiceStatus.COMPLETED in statuses else "не завершено",
"доклад о завершении и статус «Работы завершены»")
"по факту поставить статус «Работы завершены»")
return metrics

View file

@ -37,13 +37,19 @@ def to_csv(report: SessionReport) -> bytes:
row("Занятие", "", "Сценарий", report.scenario_id)
row("Занятие", "", "Режим", report.mode.value)
row("Занятие", "", "Попытка", report.attempt)
row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
row("Критерии", "", "Нарушенных метрик", report.failed_metrics)
row("Критерии", "", "Зачёт", "да" if report.passed else "нет")
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 ''}")
row("Метрики", number, item.title, item.fact, f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; вес: {item.weight:g}; источник: {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):
@ -138,6 +144,12 @@ def to_pdf(report: SessionReport) -> bytes:
("Сценарий", report.scenario_id),
("Режим", report.mode.value),
("Попытка", report.attempt),
("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
("Допустимо ошибок", report.criteria.allowed_errors),
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
("Переопределения весов", report.criteria.score_weights or "стандартные"),
("Нарушенных метрик", report.failed_metrics),
("Результат", "зачёт" if report.passed else "не зачтено"),
("Автоматическая оценка", report.score_auto),
("Итоговая оценка", report.score_final),
):
@ -155,7 +167,7 @@ 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 'нарушено'}"))
story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'} (вес {item.weight:g})"))
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm}. {item.ref or ''}", muted))
section("Выявленные ошибки")
@ -226,3 +238,74 @@ def to_pdf(report: SessionReport) -> bytes:
document.build(story, onFirstPage=footer, onLaterPages=footer)
return output.getvalue()
def certificate_pdf(
*, trainee_name: str, trainee_id: object, group_name: str | None,
attempts: int, average_score: float, issued_at: object,
) -> bytes:
"""Одностраничный сертификат по фактически сохранённым результатам."""
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.styles import ParagraphStyle
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
regular_path, bold_path = _font_paths()
regular_name = "LCTCertificateRegular"
bold_name = "LCTCertificateBold"
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)))
title = ParagraphStyle(
"certificate-title", fontName=bold_name, fontSize=28, leading=34,
alignment=TA_CENTER, textColor=colors.HexColor("#193b56"), spaceAfter=20,
)
body = ParagraphStyle(
"certificate-body", fontName=regular_name, fontSize=14, leading=21,
alignment=TA_CENTER, textColor=colors.HexColor("#18202a"), spaceAfter=10,
)
name_style = ParagraphStyle(
"certificate-name", parent=body, fontName=bold_name, fontSize=22, leading=28,
textColor=colors.HexColor("#a24816"), spaceAfter=18,
)
small = ParagraphStyle(
"certificate-small", parent=body, fontSize=9, leading=13,
textColor=colors.HexColor("#5d6873"),
)
safe_name = escape(trainee_name)
safe_group = escape(group_name or "без группы")
safe_date = escape(str(issued_at))
story = [
Spacer(1, 34),
Paragraph("СЕРТИФИКАТ", title),
Paragraph("о прохождении учебных занятий в локальном тренажёре системы-112 и ДДС", body),
Spacer(1, 12),
Paragraph(safe_name, name_style),
Paragraph(
f"Выполнено занятий: <b>{attempts}</b> · средняя итоговая оценка: "
f"<b>{average_score:.1f} из 100</b>", body,
),
Paragraph(f"Учебная группа: {safe_group}", body),
Spacer(1, 28),
Paragraph(f"Дата формирования: {safe_date}", small),
Paragraph(f"Идентификатор курсанта: {escape(str(trainee_id))}", small),
Paragraph(
"Документ сформирован локально по сохранённым результатам и не заменяет "
"квалификационный документ установленного образца.", small,
),
]
output = io.BytesIO()
document = SimpleDocTemplate(
output, pagesize=landscape(A4), leftMargin=60, rightMargin=60,
topMargin=45, bottomMargin=45, title="Сертификат о прохождении обучения",
author="Учебный симулятор 112",
)
document.build(story)
return output.getvalue()

View file

@ -0,0 +1,102 @@
"""Проверка письменного ответа ДДС: локальные правила и необязательный Vikhr."""
import json
import re
from dataclasses import dataclass
from app.config import get_settings
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, is_loopback_url
@dataclass(frozen=True)
class GrammarAssessment:
passed: bool
errors: tuple[str, ...]
source: str
def basic_check(text: str) -> GrammarAssessment:
"""Воспроизводимый минимум работает даже без модели."""
value = text.strip()
errors: list[str] = []
if not value:
errors.append("ответ отсутствует")
else:
first_letter = next((char for char in value if char.isalpha()), "")
if first_letter and first_letter != first_letter.upper():
errors.append("предложение начинается со строчной буквы")
if value[-1:] not in ".!?…":
errors.append("нет завершающего знака препинания")
if re.search(r"\s{2,}", value):
errors.append("повторяющиеся пробелы")
if re.search(r"[A-Za-z]", value):
errors.append("латинские буквы в русском ответе")
if re.search(r"([!?.,])\1{2,}", value):
errors.append("избыточно повторяющиеся знаки препинания")
agreement_errors = (
(r"\bсообщение\s+принят(?:ы|а)\b", "нарушено согласование: «сообщение принято»"),
(r"\bбригада\s+направлен(?:о|ы)\b", "нарушено согласование: «бригада направлена»"),
(r"\bинформация\s+передан(?:о|ы)\b", "нарушено согласование: «информация передана»"),
(r"\bработы\s+завершен(?:а|о)\b", "нарушено согласование: «работы завершены»"),
)
for pattern, message in agreement_errors:
if re.search(pattern, value, re.IGNORECASE):
errors.append(message)
return GrammarAssessment(not errors, tuple(errors), "rules")
async def assess(text: str) -> GrammarAssessment:
"""Vikhr проверяет мягкую грамматику, правила остаются страховочной сеткой."""
baseline = basic_check(text)
settings = get_settings()
if (not settings.grammar_llm_enabled or not text.strip()
or not is_loopback_url(
settings.llm_control_base_url,
allow_docker_host=settings.allow_docker_host_models,
)
or not settings.llm_model_control):
return baseline
schema = {"type": "json_object", "schema": {
"type": "object",
"properties": {
"passed": {"type": "boolean"},
"comment": {"type": "string"},
},
"required": ["passed", "comment"],
"additionalProperties": False,
}}
request = LlmRequest(
messages=[{
"role": "system",
"content": (
"Ты строгий корректор русского языка. Проверяй только грамматику, "
"орфографию и пунктуацию короткого служебного сообщения; не оценивай "
"факты и действия. Пример ошибки: «Сообщение приняты» => passed=false, "
"comment=«Нарушено согласование: сообщение принято». Пример нормы: "
"«Сообщение принято, бригада направлена.» => passed=true, comment=«». "
"Найди даже одну языковую ошибку и верни JSON по заданной схеме."
),
}, {"role": "user", "content": text}],
model=settings.llm_model_control,
temperature=0.0,
max_tokens=160,
response_format=schema,
)
client = LlmClient(base_url=settings.llm_control_base_url, timeout=5)
try:
raw = await client.complete(request, use_cache=True)
payload = json.loads(raw)
if set(payload) != {"passed", "comment"} or not isinstance(payload["passed"], bool):
raise ValueError("неверная схема")
comment = payload["comment"]
if not isinstance(comment, str) or len(comment) > 500:
raise ValueError("неверный комментарий")
errors = list(baseline.errors)
if not payload["passed"]:
errors.append(comment.strip() or "локальная модель обнаружила языковые ошибки")
return GrammarAssessment(not errors, tuple(dict.fromkeys(errors)), "vikhr")
except (LlmUnavailable, ValueError, TypeError, KeyError, json.JSONDecodeError):
return baseline
finally:
await client.aclose()

View file

@ -0,0 +1,77 @@
"""Локальный Qwen-инсайт по обезличенной агрегированной аналитике группы."""
import json
from app.config import get_settings
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, is_loopback_url
class InsightInvalid(ValueError):
pass
async def generate_group_insight(data: dict) -> dict:
settings = get_settings()
if (not settings.llm_model_caller or not is_loopback_url(
settings.llm_base_url, allow_docker_host=settings.allow_docker_host_models
)):
raise LlmUnavailable("локальная модель аналитики не настроена")
schema = {"type": "json_object", "schema": {
"type": "object",
"properties": {
"summary": {"type": "string", "minLength": 20, "maxLength": 700},
"priorities": {
"type": "array", "minItems": 1, "maxItems": 3,
"items": {"type": "string", "minLength": 8, "maxLength": 240},
},
},
"required": ["summary", "priorities"],
"additionalProperties": False,
}}
prompt = {
"active_trainees": data["active_trainees"],
"scored_attempts": data["scored_attempts"],
"average_score": data["average_score"],
"typical_errors": [{
"code": item["code"],
"title": item["title"],
"rate_percent": item["rate_percent"],
"rule_recommendation": item["recommendation"],
} for item in data["errors"][:8]],
}
request = LlmRequest(
model=settings.llm_model_caller,
messages=[{
"role": "system",
"content": (
"Ты методист учебного центра системы-112. По обезличенной агрегированной "
"статистике сформулируй краткий русский вывод и от одной до трёх конкретных "
"тем следующего занятия. Не придумывай персональные данные, новые числа, "
"диагнозы или нормативы. Верни только JSON по заданной схеме. /no_think"
),
}, {"role": "user", "content": json.dumps(prompt, ensure_ascii=False)}],
temperature=0.0,
max_tokens=420,
response_format=schema,
strip_reasoning=True,
)
client = LlmClient(base_url=settings.llm_base_url, timeout=25)
try:
raw = await client.complete(request, use_cache=True)
finally:
await client.aclose()
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise InsightInvalid("модель вернула не JSON") from exc
if set(payload) != {"summary", "priorities"}:
raise InsightInvalid("неверный набор полей инсайта")
summary = payload["summary"]
priorities = payload["priorities"]
if not isinstance(summary, str) or not 20 <= len(summary.strip()) <= 700:
raise InsightInvalid("неверная длина вывода")
if (not isinstance(priorities, list) or not 1 <= len(priorities) <= 3
or any(not isinstance(item, str) or not 8 <= len(item.strip()) <= 240
for item in priorities)):
raise InsightInvalid("неверные приоритеты")
return {"summary": summary.strip(), "priorities": [item.strip() for item in priorities]}

View file

@ -46,6 +46,8 @@ def assess_difference(
def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
score = state.score or {}
metrics = [Metric.model_validate(item) for item in score.get("metrics", [])]
failed_metrics = sum(not item.passed and item.weight > 0 for item in metrics)
# Без модели эмбеддингов распознать *какие* факты прозвучали нельзя.
# Но если оператор не произнёс ни слова, все обязательные вопросы точно
# пропущены — это доказуемый частный случай и он нужен офлайн-разбору.
@ -78,9 +80,12 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
else scenario.id),
mode=state.mode,
attempt=state.attempt,
criteria=state.criteria,
failed_metrics=failed_metrics,
passed=failed_metrics <= state.criteria.allowed_errors,
transcript=list(state.transcript),
findings=[Finding.model_validate(item) for item in score.get("findings", [])],
metrics=[Metric.model_validate(item) for item in score.get("metrics", [])],
metrics=metrics,
card_results=[DdsCardReport.model_validate(item)
for item in score.get("card_results", [])],
competencies=[CompetencyScore.model_validate(item) for item in score.get("competencies", [])],

View file

@ -29,6 +29,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
"dds_progress": (ErrorCode.D6, Competency.CARD),
"dds_completion": (ErrorCode.D6, Competency.CARD),
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
"dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
}
#: Вес метрики в детерминированной оценке.