Complete DDS training workflow and delivery package
This commit is contained in:
parent
68dd83c7c2
commit
4c4b91064f
229 changed files with 11969 additions and 1024 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
102
backend/app/scoring/grammar.py
Normal file
102
backend/app/scoring/grammar.py
Normal 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()
|
||||
77
backend/app/scoring/group_insight.py
Normal file
77
backend/app/scoring/group_insight.py
Normal 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]}
|
||||
|
|
@ -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", [])],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
#: Вес метрики в детерминированной оценке.
|
||||
|
|
|
|||
Loading…
Reference in a new issue