Implement local task 09 training workflows
This commit is contained in:
parent
e081fa410b
commit
cec84ffcd0
45 changed files with 2679 additions and 366 deletions
|
|
@ -10,15 +10,116 @@
|
|||
`hint.shown` из живой сессии, эталонные вопросы — только в разборе.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import audit, require
|
||||
from app.db.base import get_session
|
||||
from app.domain.roles import Role
|
||||
from app.scenarios import store
|
||||
from app.scenarios.editor import validate
|
||||
from app.scenarios.loader import ScenarioError
|
||||
|
||||
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
|
||||
|
||||
HIDDEN_FROM_TRAINEE = {"facts", "ground_truth", "tree", "checklist"}
|
||||
|
||||
|
||||
class TemplateDraftIn(BaseModel):
|
||||
source_id: str = Field(min_length=1)
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
|
||||
|
||||
def _draft_out(row) -> dict:
|
||||
return {
|
||||
"id": row.id,
|
||||
"status": row.status,
|
||||
"generation": "template_copy",
|
||||
"body": row.body,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/drafts/from-template", status_code=201)
|
||||
async def create_template_draft(
|
||||
body: TemplateDraftIn, request: Request, db: AsyncSession = Depends(get_session)
|
||||
) -> dict:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
source = store.get(body.source_id)
|
||||
if source is None:
|
||||
raise HTTPException(status_code=404, detail="published_source_not_found")
|
||||
row = await store.create_draft(db, source=source, title=body.title)
|
||||
await audit(who.login, who.role.value, "scenario.draft.create", row.id, f"template:{source.id}")
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
@router.get("/drafts/{scenario_id}")
|
||||
async def read_draft(
|
||||
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
|
||||
) -> dict:
|
||||
require(request, Role.INSTRUCTOR)
|
||||
row = await store.draft(db, scenario_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
@router.patch("/drafts/{scenario_id}")
|
||||
async def patch_draft(
|
||||
scenario_id: str,
|
||||
body: dict[str, Any],
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
row = await store.draft(db, scenario_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||
try:
|
||||
row = await store.update_draft(db, row, body)
|
||||
except ScenarioError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "scenario.draft.update", row.id)
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
@router.post("/drafts/{scenario_id}/validate")
|
||||
async def validate_draft(
|
||||
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
|
||||
) -> dict:
|
||||
require(request, Role.INSTRUCTOR)
|
||||
row = await store.draft(db, scenario_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||
try:
|
||||
scenario = validate(row.body)
|
||||
except ScenarioError as exc:
|
||||
return {"valid": False, "errors": [str(exc)]}
|
||||
return {
|
||||
"valid": True,
|
||||
"errors": [],
|
||||
"ground_truth": scenario.ground_truth.model_dump(mode="json"),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/drafts/{scenario_id}/approve")
|
||||
async def approve_draft(
|
||||
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
|
||||
) -> dict:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
row = await store.draft(db, scenario_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||
try:
|
||||
scenario = await store.approve_draft(db, row)
|
||||
except ScenarioError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "scenario.approve", scenario.id)
|
||||
return {"id": scenario.id, "status": "published", "title": scenario.title}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def listing() -> list[dict]:
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -7,16 +7,17 @@
|
|||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import audit, require
|
||||
from app.db import repo
|
||||
from app.db.base import get_session
|
||||
from app.domain.events import SessionMode, SessionReport
|
||||
from app.domain.events import Exercise, SessionMode, SessionReport
|
||||
from app.scenarios import store
|
||||
from app.scoring.report import build as build_report
|
||||
from app.scoring.export import to_csv, to_pdf
|
||||
from app.domain.roles import Role
|
||||
from app.session.hub import hub
|
||||
|
||||
|
|
@ -57,7 +58,8 @@ def _out(session) -> SessionOut:
|
|||
|
||||
|
||||
@router.post("", response_model=SessionOut, status_code=201)
|
||||
async def create(body: SessionCreate, db: AsyncSession = Depends(get_session)) -> SessionOut:
|
||||
async def create(body: SessionCreate, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut:
|
||||
require(request, Role.INSTRUCTOR)
|
||||
group = await repo.ensure_group(db, body.group) if body.group else None
|
||||
trainee = await repo.ensure_trainee(db, body.trainee, group) if body.trainee else None
|
||||
session = await repo.create_session(
|
||||
|
|
@ -71,10 +73,13 @@ async def create(body: SessionCreate, db: AsyncSession = Depends(get_session)) -
|
|||
|
||||
|
||||
@router.get("/{session_id}", response_model=SessionOut)
|
||||
async def read(session_id: UUID, db: AsyncSession = Depends(get_session)) -> SessionOut:
|
||||
async def read(session_id: UUID, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut:
|
||||
who = require(request)
|
||||
session = await repo.get_session(db, session_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
if who.role is Role.TRAINEE and session.trainee_id != who.trainee_id:
|
||||
raise HTTPException(status_code=403, detail="not_your_session")
|
||||
return _out(session)
|
||||
|
||||
|
||||
|
|
@ -84,16 +89,19 @@ class ChecklistItemOut(BaseModel):
|
|||
|
||||
|
||||
@router.get("/{session_id}/checklist", response_model=list[ChecklistItemOut])
|
||||
async def checklist(session_id: UUID) -> list[ChecklistItemOut]:
|
||||
async def checklist(session_id: UUID, request: Request) -> list[ChecklistItemOut]:
|
||||
"""Чек-лист для самооценки — **только после конца звонка**.
|
||||
|
||||
Во время звонка это содержимое подсказок: отдать его значит выдать
|
||||
в контрольном режиме то, чего там быть не должно. После звонка курсант
|
||||
по нему отмечает, что, по его мнению, пропустил.
|
||||
"""
|
||||
who = require(request)
|
||||
state = hub.get(session_id)
|
||||
if state is None:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
|
||||
raise HTTPException(status_code=403, detail="not_your_session")
|
||||
if not state.ended:
|
||||
raise HTTPException(status_code=409, detail="call_not_ended")
|
||||
scenario = state.scenario or store.get(state.scenario_id)
|
||||
|
|
@ -136,11 +144,37 @@ async def report(session_id: UUID, request: Request) -> SessionReport:
|
|||
state, scenario = _live(session_id)
|
||||
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
|
||||
raise HTTPException(status_code=403, detail="not_your_session")
|
||||
if who.role is Role.TRAINEE and state.exercise is Exercise.CALL and not state.self_assessed:
|
||||
raise HTTPException(status_code=409, detail="self_assessment_required")
|
||||
if state.score is None:
|
||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||
return build_report(session_id, state, scenario)
|
||||
|
||||
|
||||
@router.get("/{session_id}/report.csv")
|
||||
async def report_csv(session_id: UUID, request: Request) -> Response:
|
||||
"""Те же права и готовность оценки, что у JSON-разбора."""
|
||||
data = await report(session_id, request)
|
||||
return Response(
|
||||
content=to_csv(data), media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.csv"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{session_id}/report.pdf")
|
||||
async def report_pdf(session_id: UUID, request: Request) -> Response:
|
||||
"""Печатный разбор; генерация полностью локальна."""
|
||||
data = await report(session_id, request)
|
||||
try:
|
||||
content = to_pdf(data)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
return Response(
|
||||
content=content, media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.pdf"'},
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{session_id}/report", response_model=SessionReport)
|
||||
async def override(session_id: UUID, body: ScoreOverride, request: Request) -> SessionReport:
|
||||
"""Тренажёр готовит материал, преподаватель имеет последнее слово.
|
||||
|
|
@ -177,6 +211,8 @@ async def listing(
|
|||
who = require(request)
|
||||
# Обучающийся видит только свою историю, что бы он ни передал в фильтре.
|
||||
if who.role is Role.TRAINEE:
|
||||
if who.trainee_id is None:
|
||||
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
||||
trainee = who.trainee_id
|
||||
rows = await repo.history(
|
||||
db,
|
||||
|
|
|
|||
|
|
@ -14,13 +14,18 @@ from pydantic import TypeAdapter, ValidationError
|
|||
|
||||
from app.domain.events import (
|
||||
StationState,
|
||||
CallIncoming,
|
||||
CallEnded,
|
||||
CallEndReason,
|
||||
CallStarted,
|
||||
ErrorEvent,
|
||||
ErrorKind,
|
||||
Exercise,
|
||||
HintShown,
|
||||
KioState,
|
||||
KioPatchOut,
|
||||
PatchSource,
|
||||
ScoreReady,
|
||||
SessionEnded,
|
||||
SessionMode,
|
||||
TimerTick,
|
||||
|
|
@ -33,6 +38,7 @@ from app.api.auth import principal_of
|
|||
from app.domain.roles import Role
|
||||
from app.session.hub import hub
|
||||
from app.session.state import now_utc
|
||||
from app.domain.kio import ResponseStatus
|
||||
from app.voice.models import get_voice_models
|
||||
from app.voice.pipeline import VoiceSession
|
||||
|
||||
|
|
@ -82,7 +88,37 @@ def _next_hint(state) -> tuple[str, str] | None:
|
|||
|
||||
|
||||
async def _handle(session_id: UUID, state, event) -> None:
|
||||
if state.ended and event.type != "self_assessment.submit":
|
||||
hub.to_trainee(session_id, ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT, message="Занятие уже завершено",
|
||||
))
|
||||
return
|
||||
if state.exercise is Exercise.DDS or (
|
||||
state.exercise is Exercise.CARD and event.type not in {"kio.patch", "card.submit"}
|
||||
) or (state.exercise is Exercise.CALL and event.type == "card.submit"):
|
||||
hub.to_trainee(session_id, ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT, message="Действие недоступно в этом упражнении",
|
||||
))
|
||||
return
|
||||
match event.type:
|
||||
case "card.submit":
|
||||
state.kio.registered_at = state.started_at or now_utc()
|
||||
state.kio.response_status = ResponseStatus.TRANSFERRED
|
||||
state.dispatched_card = state.kio.model_copy(deep=True)
|
||||
state.dispatched_at = now_utc()
|
||||
state.ended_at = state.dispatched_at
|
||||
state.end_reason = CallEndReason.COMPLETE
|
||||
hub.stop_ticker(session_id)
|
||||
hub.to_station(session_id, state.card_received_event())
|
||||
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
|
||||
hub.to_observers(session_id, KioState(kio=state.kio))
|
||||
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.COMPLETE))
|
||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||
if hub.journal:
|
||||
await hub.journal.session_ended(
|
||||
session_id, state.ended_at, CallEndReason.COMPLETE.value
|
||||
)
|
||||
await finish(session_id, state)
|
||||
case "call.answer":
|
||||
state.on_event("call.answer")
|
||||
state.started_at = now_utc()
|
||||
|
|
@ -93,7 +129,17 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
_start_voice(session_id, state)
|
||||
|
||||
case "kio.patch":
|
||||
old_code, old_notify = state.kio.incident_code, list(state.kio.notify)
|
||||
state.patch_kio(event.fields)
|
||||
hub.to_trainee(session_id, KioPatchOut(
|
||||
fields=event.fields, source=PatchSource.OPERATOR,
|
||||
))
|
||||
if (state.kio.incident_code, state.kio.notify) != (old_code, old_notify):
|
||||
hub.to_trainee(session_id, KioPatchOut(
|
||||
fields={"incident_code": state.kio.incident_code,
|
||||
"notify": list(state.kio.notify)},
|
||||
source=PatchSource.AUTO,
|
||||
))
|
||||
# Наблюдателю уходит карточка целиком: рассинхрон на внешнем мониторе
|
||||
# посреди занятия дороже лишних килобайт.
|
||||
hub.to_observers(session_id, KioState(kio=state.kio))
|
||||
|
|
@ -121,8 +167,19 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
await hub.journal.hint(session_id, checklist_id, question, now_utc())
|
||||
|
||||
case "dds.dispatch":
|
||||
if event.service is None and not (state.kio.incident_code or state.kio.notify):
|
||||
hub.to_trainee(session_id, ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT,
|
||||
message="Укажите ДДС или проставьте признаки для маршрутизации по ЕКП",
|
||||
))
|
||||
return
|
||||
state.on_event("dds.dispatch")
|
||||
state.dispatch(event.service.value)
|
||||
state.dispatch(event.service.value if event.service else None)
|
||||
hub.to_trainee(session_id, KioPatchOut(
|
||||
fields={"response_status": state.kio.response_status.value,
|
||||
"dds": state.kio.dds.value if state.kio.dds else None},
|
||||
source=PatchSource.AUTO,
|
||||
))
|
||||
# Карточка замораживается снимком и уходит диспетчеру: оператор
|
||||
# не должен иметь возможности дописать поле задним числом.
|
||||
hub.to_station(session_id, state.card_received_event())
|
||||
|
|
@ -227,8 +284,40 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
|
|||
)
|
||||
await ws.close()
|
||||
return
|
||||
if who.role is Role.TRAINEE and (
|
||||
state.trainee_id is None or state.trainee_id != who.trainee_id
|
||||
):
|
||||
await _reject(ws, "Занятие не назначено этому обучающемуся")
|
||||
return
|
||||
|
||||
with hub.trainee(session_id) as queue:
|
||||
if state.exercise is Exercise.CARD:
|
||||
from app.api.ws.control import card_briefing
|
||||
|
||||
hub.to_trainee(session_id, card_briefing(state))
|
||||
if state.ended:
|
||||
hub.to_trainee(session_id, CallEnded(reason=state.end_reason or CallEndReason.COMPLETE))
|
||||
if state.score is not None:
|
||||
hub.to_trainee(session_id, ScoreReady(session_id=session_id))
|
||||
elif state.exercise is Exercise.CALL:
|
||||
scenario = state.scenario or store.get(state.scenario_id)
|
||||
required = ([field for field in state.required_fields if field != "dds"]
|
||||
if scenario and scenario.ground_truth.incident_code
|
||||
else list(state.required_fields))
|
||||
hub.to_trainee(session_id, CallIncoming(
|
||||
scenario_id=state.scenario_id, caller_number="+7 (495) 000-00-00",
|
||||
level=state.level, mode=state.mode, required_fields=required,
|
||||
))
|
||||
hub.to_trainee(session_id, KioPatchOut(
|
||||
fields=state.kio.model_dump(mode="json"), source=PatchSource.OPERATOR,
|
||||
))
|
||||
if state.started_at is not None:
|
||||
hub.to_trainee(session_id, CallStarted(started_at=state.started_at))
|
||||
if state.ended:
|
||||
hub.to_trainee(session_id, CallEnded(reason=state.end_reason or CallEndReason.HANGUP))
|
||||
if state.score is not None and state.self_assessed:
|
||||
hub.to_trainee(session_id, ScoreReady(session_id=session_id))
|
||||
hub.to_trainee(session_id, TimerTick(timers=state.timers.snapshot()))
|
||||
writer = asyncio.create_task(_pump(ws, queue))
|
||||
try:
|
||||
while True:
|
||||
|
|
@ -238,7 +327,13 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
|
|||
# Бинарные кадры — аудио, текстовые — события. Направление определяется
|
||||
# каналом, обёртки JSON вокруг звука нет (docs/arch/CONTRACT.md).
|
||||
if message.get("bytes") is not None:
|
||||
_on_audio(session_id, state, message["bytes"])
|
||||
if state.exercise is Exercise.CALL:
|
||||
_on_audio(session_id, state, message["bytes"])
|
||||
else:
|
||||
hub.to_trainee(session_id, ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT,
|
||||
message="Аудио не используется в этом упражнении",
|
||||
))
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(message.get("text") or "")
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
|
@ -21,8 +22,9 @@ from app.domain.events import (
|
|||
ErrorKind,
|
||||
ScoreReady,
|
||||
CallIncoming,
|
||||
ErrorEvent,
|
||||
ErrorKind,
|
||||
CardBriefing,
|
||||
Exercise,
|
||||
StationState,
|
||||
InstructorToServer,
|
||||
InstructorNoteShown,
|
||||
ModeSet,
|
||||
|
|
@ -42,6 +44,7 @@ from app.api.auth import audit, principal_of
|
|||
from app.domain.roles import Role
|
||||
from app.session.hub import hub
|
||||
from app.session.state import SessionState, now_utc
|
||||
from app.domain.kio import KIO, ResponseStatus
|
||||
from app.voice.models import get_voice_models
|
||||
from app.voice.pipeline import FILLERS, prefetch
|
||||
|
||||
|
|
@ -51,6 +54,27 @@ router = APIRouter()
|
|||
_adapter = TypeAdapter(InstructorToServer)
|
||||
|
||||
|
||||
def card_briefing(state: SessionState) -> CardBriefing:
|
||||
"""Учебная текстовая вводная — исходные реплики, а не эталон карточки.
|
||||
|
||||
В отсутствие диалога факты раскрываются сразу. Если факт уточняется,
|
||||
показываем и уточнение: иначе правильно заполнить карточку невозможно.
|
||||
"""
|
||||
scenario = state.scenario
|
||||
lines = ["Учебная текстовая вводная: сведения заявителя приведены ниже.",
|
||||
scenario.first_line]
|
||||
for fact in scenario.facts:
|
||||
lines.append(f"• {fact.value}")
|
||||
if fact.refined:
|
||||
lines.append(f" Уточнено: {fact.refined}")
|
||||
return CardBriefing(
|
||||
scenario_id=scenario.id, mode=state.mode, text="\n".join(lines),
|
||||
required_fields=([field for field in state.required_fields if field != "dds"]
|
||||
if scenario.ground_truth.incident_code else list(state.required_fields)),
|
||||
card=state.kio,
|
||||
)
|
||||
|
||||
|
||||
async def _start(session_id: UUID, event, who=None) -> None:
|
||||
scenario = store.get(event.scenario_id)
|
||||
if scenario is None:
|
||||
|
|
@ -61,9 +85,10 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
return
|
||||
|
||||
attempt = 1
|
||||
recorded_trainee_id = event.trainee_id
|
||||
if hub.journal:
|
||||
attempt = await hub.journal.start_lesson(
|
||||
session_id, scenario.id, event.mode.value, event.trainee
|
||||
attempt, recorded_trainee_id = await hub.journal.start_lesson(
|
||||
session_id, scenario.id, event.mode.value, event.trainee, event.trainee_id
|
||||
)
|
||||
|
||||
# Занятие собирается целиком и только потом регистрируется: иначе
|
||||
|
|
@ -75,25 +100,59 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
scenario_title=scenario.title,
|
||||
level=scenario.level.value,
|
||||
mode=event.mode,
|
||||
exercise=event.exercise,
|
||||
scenario=scenario.model_copy(deep=True),
|
||||
required_fields=scenario.required_fields,
|
||||
trainee_name=event.trainee,
|
||||
trainee_id=getattr(event, "trainee_id", None),
|
||||
trainee_id=recorded_trainee_id,
|
||||
attempt=attempt,
|
||||
)
|
||||
embedder = get_embedder()
|
||||
if embedder is not None:
|
||||
state.slots = SlotMachine(state.scenario, embedder)
|
||||
state.persona = PersonaState(state.scenario.persona)
|
||||
state.caller = build_caller(scenario.id)
|
||||
if event.exercise is Exercise.CALL:
|
||||
embedder = get_embedder()
|
||||
if embedder is not None:
|
||||
state.slots = SlotMachine(state.scenario, embedder)
|
||||
state.persona = PersonaState(state.scenario.persona)
|
||||
state.caller = build_caller(scenario.id)
|
||||
elif event.exercise is Exercise.DDS:
|
||||
# В ДДС поступает уже оформленная учебная карточка. Содержимое берётся
|
||||
# из утверждённого сценария, а не из действий несуществующего оператора.
|
||||
truth = scenario.ground_truth
|
||||
address_fact = next((fact.value for fact in scenario.facts if "address" in fact.id), "")
|
||||
floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE)
|
||||
service = truth.dds.value if truth.dds else None
|
||||
fallback = {"01": "Служба 101", "02": "МВД", "03": "Скорая помощь", "04": "Аварийная служба"}
|
||||
state.kio = KIO(
|
||||
registered_at=now_utc(), caller_number="+7 (495) 000-00-00",
|
||||
address=truth.address or address_fact or None,
|
||||
floor=floor.group(1) if floor else None,
|
||||
incident_type=truth.incident_type, incident_code=truth.incident_code,
|
||||
dds=truth.dds, signs=list(scenario.signs),
|
||||
notify=list(truth.notify) or ([fallback[service]] if service in fallback else []),
|
||||
victims_count=truth.victims,
|
||||
description="; ".join(fact.value for fact in scenario.facts[:3]) or scenario.first_line,
|
||||
)
|
||||
if service:
|
||||
state.dispatch(service)
|
||||
else:
|
||||
state.kio.response_status = ResponseStatus.TRANSFERRED
|
||||
state.dispatched_card = state.kio.model_copy(deep=True)
|
||||
state.dispatched_at = now_utc()
|
||||
state.started_at = state.dispatched_at
|
||||
else:
|
||||
state.started_at = now_utc()
|
||||
hub.register(state)
|
||||
if event.exercise is not Exercise.CALL and hub.journal and state.started_at is not None:
|
||||
await hub.journal.session_started(session_id, state.started_at)
|
||||
|
||||
# Первая реплика и филлеры синтезируются, пока курсант не снял трубку:
|
||||
# «Алло! Помогите!» должно прозвучать мгновенно (docs/arch/BACKEND.md).
|
||||
models = get_voice_models()
|
||||
if models is not None:
|
||||
asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()]))
|
||||
state.on_event("call.incoming")
|
||||
if event.exercise is Exercise.CALL:
|
||||
models = get_voice_models()
|
||||
if models is not None:
|
||||
asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()]))
|
||||
state.on_event("call.incoming")
|
||||
elif event.exercise is Exercise.DDS:
|
||||
state.on_event("dds.dispatch")
|
||||
hub.start_ticker(session_id)
|
||||
if who is not None:
|
||||
# Запуск занятия меняет чужой результат — значит попадает в журнал
|
||||
|
|
@ -103,29 +162,45 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
f"{scenario.id}, режим {event.mode.value}, курсант {event.trainee}")
|
||||
)
|
||||
|
||||
hub.to_trainee(
|
||||
session_id,
|
||||
CallIncoming(
|
||||
scenario_id=scenario.id,
|
||||
caller_number="+7 (495) 000-00-00",
|
||||
level=scenario.level,
|
||||
mode=event.mode,
|
||||
scenario=scenario.model_copy(deep=True),
|
||||
required_fields=scenario.required_fields,
|
||||
),
|
||||
)
|
||||
if event.exercise is Exercise.CALL:
|
||||
hub.to_trainee(
|
||||
session_id,
|
||||
CallIncoming(
|
||||
scenario_id=scenario.id,
|
||||
caller_number="+7 (495) 000-00-00",
|
||||
level=scenario.level,
|
||||
mode=event.mode,
|
||||
required_fields=[field for field in scenario.required_fields
|
||||
if field != "dds" or not scenario.ground_truth.incident_code],
|
||||
),
|
||||
)
|
||||
elif event.exercise is Exercise.DDS:
|
||||
hub.to_station(session_id, state.card_received_event())
|
||||
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
|
||||
else:
|
||||
hub.to_trainee(session_id, card_briefing(state))
|
||||
hub.to_observers(session_id, ModeSet(mode=event.mode))
|
||||
hub.to_observers(session_id, state.snapshot())
|
||||
|
||||
|
||||
async def _stop(session_id: UUID) -> None:
|
||||
state = hub.get(session_id)
|
||||
if state is None:
|
||||
if state is None or state.ended:
|
||||
return
|
||||
state.ended_at = now_utc()
|
||||
state.end_reason = CallEndReason.INSTRUCTOR
|
||||
hub.stop_ticker(session_id)
|
||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR))
|
||||
if state.exercise in {Exercise.DDS, Exercise.CARD}:
|
||||
from app.session.finish import finish
|
||||
|
||||
if state.exercise is Exercise.DDS:
|
||||
hub.to_station(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR))
|
||||
else:
|
||||
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.INSTRUCTOR))
|
||||
await finish(session_id, state)
|
||||
if state.exercise is Exercise.DDS:
|
||||
hub.to_station(session_id, ScoreReady(session_id=session_id))
|
||||
if hub.journal:
|
||||
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.INSTRUCTOR.value)
|
||||
|
||||
|
|
@ -204,6 +279,12 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
|||
state = hub.get(session_id)
|
||||
if state is None:
|
||||
continue
|
||||
if state.exercise is not Exercise.CALL:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT,
|
||||
message="Директивы звонящему доступны только в голосовом упражнении",
|
||||
))
|
||||
continue
|
||||
result = apply_directive(state, event.directive)
|
||||
if result.needs_network:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Сокет диспетчера ДДС.
|
||||
|
||||
**Только JSON: аудио здесь нет вообще** — значит, нет ни VAD, ни распознавания,
|
||||
ни синтеза. Голосовой контур станция не трогает (docs/arch/CONTRACT.md).
|
||||
Учебный исходящий звонок бригаде имитируется событиями JSON. Для целевого
|
||||
локального прототипа не нужна внешняя телефония или аудиомодель.
|
||||
|
||||
Самая ценная механика цепочки — `card.bounce`: диспетчер видит, что не указан
|
||||
этаж, и отбивает карточку обратно. Неполнота КИО перестаёт быть процентом
|
||||
|
|
@ -15,20 +15,45 @@ from uuid import UUID
|
|||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from app.domain.events import ErrorEvent, ErrorKind, StationState, StationToServer
|
||||
from app.domain.statuses import PRIMARY, ServiceStatus, StatusError
|
||||
from app.domain.events import (
|
||||
CallEndReason, ErrorEvent, ErrorKind, Exercise, PhoneReport, ScoreReady,
|
||||
SessionEnded, StationState, StationToServer,
|
||||
)
|
||||
from app.domain.statuses import PRIMARY, PhoneReportRecord, ServiceStatus, StatusError, current
|
||||
from app.api.auth import principal_of
|
||||
from app.domain.roles import Role
|
||||
from app.session.hub import hub
|
||||
from app.session.state import now_utc
|
||||
from app.session.finish import finish
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_adapter = TypeAdapter(StationToServer)
|
||||
|
||||
REPORT_PHASES = ("dispatched", "arrived", "working", "completed")
|
||||
REPORT_TEXT = {
|
||||
"dispatched": "Бригада выехала к месту происшествия.",
|
||||
"arrived": "Бригада прибыла на место происшествия.",
|
||||
"working": "Бригада приступила к проведению работ.",
|
||||
"completed": "Работы завершены, бригада освобождена.",
|
||||
}
|
||||
REPORT_FOR_STATUS = {
|
||||
ServiceStatus.RESPONDING: "dispatched",
|
||||
ServiceStatus.ARRIVED: "arrived",
|
||||
ServiceStatus.WORKING: "working",
|
||||
ServiceStatus.COMPLETED: "completed",
|
||||
}
|
||||
|
||||
|
||||
def _error(session_id: UUID, message: str) -> None:
|
||||
hub.to_station(session_id, ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=message))
|
||||
|
||||
|
||||
async def _handle(session_id: UUID, state, event) -> None:
|
||||
if state.ended:
|
||||
_error(session_id, "Занятие уже завершено")
|
||||
return
|
||||
match event.type:
|
||||
case "card.ack":
|
||||
# Подтверждение приёма — это статус «Принята» у главной службы.
|
||||
|
|
@ -43,6 +68,16 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
except StatusError:
|
||||
pass # статус уже стоит: повторное нажатие ничего не меняет
|
||||
case "card.status":
|
||||
if event.service not in state.notified_services():
|
||||
_error(session_id, "Служба отсутствует в списке оповещения карточки")
|
||||
return
|
||||
phase = REPORT_FOR_STATUS.get(event.status)
|
||||
if state.exercise is Exercise.DDS and phase is not None and not any(
|
||||
report.service == event.service and report.phase == phase
|
||||
for report in state.phone_reports
|
||||
):
|
||||
_error(session_id, f"Статус «{event.status.value}» требует доклада бригады")
|
||||
return
|
||||
try:
|
||||
state.set_service_status(
|
||||
event.service, event.status, event.comment, author="диспетчер"
|
||||
|
|
@ -56,6 +91,55 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
# Первичный статус останавливает норматив 30 секунд.
|
||||
if event.status in PRIMARY:
|
||||
state.on_event("card.ack")
|
||||
case "crew.select":
|
||||
if event.crew not in state.crew_options():
|
||||
_error(session_id, "Выберите бригаду из списка доступных")
|
||||
return
|
||||
service = state.crew_service(event.crew)
|
||||
assigned = state.crew_assignments.get(service)
|
||||
if assigned and assigned != event.crew and any(
|
||||
report.service == service for report in state.phone_reports
|
||||
):
|
||||
_error(session_id, "После первого доклада бригаду этой службы менять нельзя")
|
||||
return
|
||||
state.crew_selected = event.crew
|
||||
state.crew_assignments[service] = event.crew
|
||||
case "phone.dial":
|
||||
crew = state.crew_selected
|
||||
service = state.crew_service(crew) if crew else None
|
||||
if service is None:
|
||||
_error(session_id, "Сначала выберите бригаду")
|
||||
return
|
||||
if current(state.status_log, service) not in {
|
||||
ServiceStatus.ACCEPTED, ServiceStatus.RESPONDING,
|
||||
ServiceStatus.ARRIVED, ServiceStatus.WORKING,
|
||||
}:
|
||||
_error(session_id, "Сначала примите карточку этой службы")
|
||||
return
|
||||
previous = [report for report in state.phone_reports if report.service == service]
|
||||
if len(previous) >= len(REPORT_PHASES):
|
||||
_error(session_id, "Все доклады этой бригады уже получены")
|
||||
return
|
||||
phase = REPORT_PHASES[len(previous)]
|
||||
report = PhoneReportRecord(
|
||||
service=service, crew=crew, phase=phase,
|
||||
text=REPORT_TEXT[phase], at=now_utc(),
|
||||
)
|
||||
state.phone_reports.append(report)
|
||||
hub.to_station(session_id, PhoneReport(**report.model_dump()))
|
||||
case "station.finish":
|
||||
if state.exercise is not Exercise.DDS:
|
||||
_error(session_id, "Операторское занятие завершается после звонка 112")
|
||||
return
|
||||
state.ended_at = now_utc()
|
||||
state.end_reason = CallEndReason.COMPLETE
|
||||
hub.stop_ticker(session_id)
|
||||
hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
|
||||
if hub.journal:
|
||||
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.COMPLETE.value)
|
||||
await finish(session_id, state)
|
||||
hub.to_station(session_id, ScoreReady(session_id=session_id))
|
||||
case "card.bounce":
|
||||
# Карточка вернулась: в разборе это E6 с конкретной причиной.
|
||||
state.bounced_fields = list(event.missing_fields)
|
||||
|
|
@ -105,6 +189,11 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
|
|||
)
|
||||
await ws.close()
|
||||
return
|
||||
if who.role is Role.TRAINEE and (
|
||||
state.trainee_id is None or state.trainee_id != who.trainee_id
|
||||
):
|
||||
await _reject(ws, "Занятие не назначено этому обучающемуся")
|
||||
return
|
||||
|
||||
with hub.station(session_id) as queue:
|
||||
sender = asyncio.create_task(_pump(ws, queue))
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ async def ensure_session(
|
|||
scenario_id: str,
|
||||
mode: str,
|
||||
trainee_name: str | None = None,
|
||||
trainee_id: UUID | None = None,
|
||||
group_name: str | None = None,
|
||||
) -> Session:
|
||||
"""Занятие, запущенное с пульта, должно иметь строку в журнале.
|
||||
|
|
@ -64,7 +65,11 @@ async def ensure_session(
|
|||
return existing
|
||||
|
||||
group = await ensure_group(db, group_name) if group_name else None
|
||||
trainee = await ensure_trainee(db, trainee_name, group) if trainee_name else None
|
||||
trainee = await db.get(Trainee, trainee_id) if trainee_id else None
|
||||
if trainee_id and trainee is None:
|
||||
raise ValueError(f"курсант {trainee_id} не найден")
|
||||
if trainee is None and trainee_name:
|
||||
trainee = await ensure_trainee(db, trainee_name, group)
|
||||
return await create_session(
|
||||
db,
|
||||
scenario_id=scenario_id,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,12 @@ class SessionMode(StrEnum):
|
|||
SELF = "self"
|
||||
|
||||
|
||||
class Exercise(StrEnum):
|
||||
CALL = "call"
|
||||
DDS = "dds"
|
||||
CARD = "card"
|
||||
|
||||
|
||||
class Speaker(StrEnum):
|
||||
CALLER = "caller"
|
||||
OPERATOR = "operator"
|
||||
|
|
@ -53,6 +59,7 @@ class CallEndReason(StrEnum):
|
|||
HANGUP = "hangup"
|
||||
DROPPED = "dropped"
|
||||
INSTRUCTOR = "instructor"
|
||||
COMPLETE = "complete"
|
||||
|
||||
|
||||
class PatchSource(StrEnum):
|
||||
|
|
@ -112,6 +119,17 @@ class CallIncoming(BaseModel):
|
|||
required_fields: list[str] = []
|
||||
|
||||
|
||||
class CardBriefing(BaseModel):
|
||||
"""Текстовая вводная упражнения 112; эталонные значения сюда не входят."""
|
||||
|
||||
type: Literal["card.briefing"] = "card.briefing"
|
||||
scenario_id: str
|
||||
mode: SessionMode
|
||||
text: str
|
||||
required_fields: list[str]
|
||||
card: KIO | None = None
|
||||
|
||||
|
||||
class CallStarted(BaseModel):
|
||||
type: Literal["call.started"] = "call.started"
|
||||
started_at: datetime
|
||||
|
|
@ -215,6 +233,7 @@ class ErrorEvent(BaseModel):
|
|||
|
||||
ServerToTrainee = Annotated[
|
||||
CallIncoming
|
||||
| CardBriefing
|
||||
| CallStarted
|
||||
| SttPartial
|
||||
| SttFinal
|
||||
|
|
@ -244,6 +263,10 @@ class CallAnswer(BaseModel):
|
|||
type: Literal["call.answer"] = "call.answer"
|
||||
|
||||
|
||||
class CardSubmit(BaseModel):
|
||||
type: Literal["card.submit"] = "card.submit"
|
||||
|
||||
|
||||
class KioPatchIn(BaseModel):
|
||||
"""Правка карточки. Дебаунс 300 мс, шлётся только дельта."""
|
||||
|
||||
|
|
@ -266,10 +289,10 @@ class SelfAssessmentSubmit(BaseModel):
|
|||
|
||||
|
||||
class DdsDispatch(BaseModel):
|
||||
"""Передача в ДДС. Замораживает карточку снимком и останавливает опрос (`interview`)."""
|
||||
"""Передача в ДДС. При маршрутизации по ЕКП служба уже есть в notify."""
|
||||
|
||||
type: Literal["dds.dispatch"] = "dds.dispatch"
|
||||
service: DDSCode
|
||||
service: DDSCode | None = None
|
||||
|
||||
|
||||
class CallHangup(BaseModel):
|
||||
|
|
@ -297,6 +320,7 @@ class CallResolve(BaseModel):
|
|||
|
||||
TraineeToServer = Annotated[
|
||||
CallAnswer
|
||||
| CardSubmit
|
||||
| KioPatchIn
|
||||
| HintRequest
|
||||
| SelfAssessmentSubmit
|
||||
|
|
@ -320,6 +344,7 @@ class SessionSnapshot(BaseModel):
|
|||
scenario_title: str
|
||||
level: Level
|
||||
mode: SessionMode
|
||||
exercise: Exercise = Exercise.CALL
|
||||
trainee_name: str | None = None
|
||||
started_at: datetime | None = None
|
||||
kio: KIO
|
||||
|
|
@ -394,8 +419,10 @@ class ScenarioStart(BaseModel):
|
|||
type: Literal["scenario.start"] = "scenario.start"
|
||||
scenario_id: str
|
||||
trainee: str
|
||||
trainee_id: UUID | None = None
|
||||
group_id: str | None = None
|
||||
mode: SessionMode
|
||||
exercise: Exercise = Exercise.CALL
|
||||
|
||||
|
||||
class DirectorInject(BaseModel):
|
||||
|
|
@ -486,6 +513,28 @@ class ServiceStatusSet(BaseModel):
|
|||
comment: str = ""
|
||||
|
||||
|
||||
class CrewSelect(BaseModel):
|
||||
type: Literal["crew.select"] = "crew.select"
|
||||
crew: str
|
||||
|
||||
|
||||
class PhoneDial(BaseModel):
|
||||
type: Literal["phone.dial"] = "phone.dial"
|
||||
|
||||
|
||||
class PhoneReport(BaseModel):
|
||||
type: Literal["phone.report"] = "phone.report"
|
||||
service: str
|
||||
crew: str
|
||||
phase: Literal["dispatched", "arrived", "working", "completed"]
|
||||
text: str
|
||||
at: datetime
|
||||
|
||||
|
||||
class StationFinish(BaseModel):
|
||||
type: Literal["station.finish"] = "station.finish"
|
||||
|
||||
|
||||
class StationState(BaseModel):
|
||||
"""Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше."""
|
||||
|
||||
|
|
@ -509,12 +558,13 @@ class CrewArrived(BaseModel):
|
|||
|
||||
|
||||
ServerToStation = Annotated[
|
||||
CardReceived | StationState | TimerTick | SessionEnded | ErrorEvent,
|
||||
CardReceived | StationState | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
StationToServer = Annotated[
|
||||
CardAck | CardBounce | ServiceStatusSet | ZoneDecision | CrewDispatched | CrewArrived,
|
||||
CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish
|
||||
| ZoneDecision | CrewDispatched | CrewArrived,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -193,10 +193,10 @@ def derive_incident(card: KIO) -> KIO:
|
|||
keys = modifiers(card)
|
||||
data = card.model_dump()
|
||||
data["incident_code"] = found.code if found else None
|
||||
computed = ekp.notify_list(found.code, keys) if found else []
|
||||
# Добавленную вручную службу не теряем: удалить её оператор не может.
|
||||
manual = [name for name in card.notify if name not in ekp.notify_list(data["incident_code"] or "", keys)]
|
||||
data["notify"] = computed + manual
|
||||
# `notify` недоступен оператору для записи (READ_ONLY_FIELDS), поэтому
|
||||
# хранить прошлый авторасчёт как «добавленные вручную службы» нельзя:
|
||||
# при снятии признака газа МОСГАЗ должен исчезнуть из нового расчёта.
|
||||
data["notify"] = ekp.notify_list(found.code, keys) if found else []
|
||||
return KIO.model_validate(data)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -126,6 +126,14 @@ class StatusEntry(BaseModel):
|
|||
author: str = ""
|
||||
|
||||
|
||||
class PhoneReportRecord(BaseModel):
|
||||
service: str
|
||||
crew: str
|
||||
phase: str
|
||||
text: str
|
||||
at: datetime
|
||||
|
||||
|
||||
class StatusError(ValueError):
|
||||
"""Переход запрещён автоматом или нет обязательного комментария."""
|
||||
|
||||
|
|
@ -168,7 +176,8 @@ def card_status(
|
|||
|
||||
latest = {service: current(entries, service) for service in services}
|
||||
silent = [
|
||||
service for service, status in latest.items() if status not in PRIMARY | TERMINAL
|
||||
service for service, status in latest.items()
|
||||
if status in {ServiceStatus.ADDED, ServiceStatus.RECEIVED}
|
||||
]
|
||||
if silent and notify_deadline_passed:
|
||||
return CardStatus.NOT_NOTIFIED
|
||||
|
|
@ -196,3 +205,6 @@ class StationSnapshot(BaseModel):
|
|||
available: dict[str, list[ServiceStatus]]
|
||||
card: CardStatus
|
||||
log: list[StatusEntry]
|
||||
crew_options: list[str] = []
|
||||
crew_selected: str | None = None
|
||||
phone_reports: list[PhoneReportRecord] = []
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"""Сборка приложения. Роутеры подключаются по мере готовности — см. tasks/."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from pathlib import Path
|
||||
|
|
@ -47,6 +49,19 @@ async def lifespan(app: FastAPI):
|
|||
raise RuntimeError(f"библиотека сценариев не прошла проверку: {exc}") from exc
|
||||
app.state.scenarios_loaded = len(loaded)
|
||||
|
||||
# Утверждённые преподавателем сценарии хранятся в БД и должны переживать
|
||||
# перезапуск процесса. При недоступной БД остаётся базовая YAML-библиотека.
|
||||
try:
|
||||
# БД может ещё подниматься или отсутствовать в unit-тесте. Не задерживаем
|
||||
# старт АРМ на минуту ради необязательной пользовательской библиотеки.
|
||||
async with asyncio.timeout(1):
|
||||
async with get_sessionmaker()() as db:
|
||||
app.state.scenarios_loaded += await store.restore_published(db)
|
||||
except (SQLAlchemyError, OSError, TimeoutError) as exc:
|
||||
logging.getLogger(__name__).warning(
|
||||
"не удалось восстановить утверждённые сценарии из БД: %s", exc
|
||||
)
|
||||
|
||||
# Журнал: всё, что не записано, для оценки не существует.
|
||||
hub.journal = DbJournal(get_sessionmaker())
|
||||
|
||||
|
|
|
|||
100
backend/app/scenarios/editor.py
Normal file
100
backend/app/scenarios/editor.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Локальное, детерминированное редактирование учебных сценариев.
|
||||
|
||||
Это копирование утверждённого шаблона, а не генерация нового сюжета ИИ.
|
||||
Никаких сетевых вызовов в этом модуле нет.
|
||||
"""
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.scenarios.loader import ScenarioError, _derive_ground_truth
|
||||
from app.scenarios.schema import Scenario
|
||||
|
||||
DERIVED_TRUTH = {"incident_type", "dds", "incident_code", "notify", "required_facts"}
|
||||
|
||||
|
||||
def template_copy(source: Scenario, draft_id: str, title: str | None = None) -> dict:
|
||||
"""Сделать полностью самостоятельный шаблонный черновик из сценария."""
|
||||
body = source.model_dump(mode="json")
|
||||
body["id"] = draft_id
|
||||
body["title"] = title or f"{source.title} — копия"
|
||||
body["extends"] = None # чек-лист уже развёрнут загрузчиком
|
||||
body["ticket"] = None
|
||||
body["position"] = None
|
||||
for key in DERIVED_TRUTH:
|
||||
body["ground_truth"].pop(key, None)
|
||||
return body
|
||||
|
||||
|
||||
def merge_patch(body: dict, patch: dict) -> dict:
|
||||
"""JSON Merge Patch: словари сливаются, списки заменяются целиком.
|
||||
|
||||
`id` — идентичность записи, а вычисляемые поля эталона не принимаются от
|
||||
клиента. Некорректные обычные поля можно временно сохранить в черновике;
|
||||
утверждение всё равно потребует полной проверки.
|
||||
"""
|
||||
if "id" in patch and patch["id"] != body["id"]:
|
||||
raise ScenarioError("id сценария менять нельзя")
|
||||
truth = patch.get("ground_truth")
|
||||
if isinstance(truth, dict) and DERIVED_TRUTH & truth.keys():
|
||||
raise ScenarioError("вычисляемые поля ground_truth нельзя задавать вручную")
|
||||
|
||||
result = deepcopy(body)
|
||||
|
||||
def apply(target: dict, changes: dict) -> None:
|
||||
for key, value in changes.items():
|
||||
if value is None:
|
||||
target.pop(key, None)
|
||||
elif isinstance(value, dict) and isinstance(target.get(key), dict):
|
||||
apply(target[key], value)
|
||||
else:
|
||||
target[key] = deepcopy(value)
|
||||
|
||||
apply(result, patch)
|
||||
result["id"] = body["id"]
|
||||
return result
|
||||
|
||||
|
||||
def validate(body: dict) -> Scenario:
|
||||
"""Проверить схему и связи фактов, затем пересчитать эталон по ЕКП."""
|
||||
declared = body.get("ground_truth") or {}
|
||||
if not isinstance(declared, dict):
|
||||
raise ScenarioError("ground_truth: ожидается объект")
|
||||
forbidden = DERIVED_TRUTH & declared.keys()
|
||||
if forbidden:
|
||||
raise ScenarioError(
|
||||
f"{', '.join(sorted(forbidden))} в ground_truth выводится кодом и руками не пишется"
|
||||
)
|
||||
try:
|
||||
scenario = Scenario.model_validate(body)
|
||||
except ValidationError as exc:
|
||||
first = exc.errors()[0]
|
||||
where = ".".join(str(part) for part in first["loc"])
|
||||
raise ScenarioError(f"{where}: {first['msg']}") from exc
|
||||
|
||||
if not scenario.title.strip() or len(scenario.title) > 200:
|
||||
raise ScenarioError("title: требуется название длиной от 1 до 200 символов")
|
||||
if not scenario.first_line.strip():
|
||||
raise ScenarioError("first_line: требуется первая реплика")
|
||||
if not scenario.facts or not scenario.checklist:
|
||||
raise ScenarioError("сценарию нужны факты и чек-лист для оценки занятия")
|
||||
|
||||
known = scenario.fact_ids()
|
||||
question_ids = {item.id for item in scenario.checklist}
|
||||
if len(known) != len(scenario.facts):
|
||||
raise ScenarioError("id фактов должны быть уникальны")
|
||||
if len(question_ids) != len(scenario.checklist):
|
||||
raise ScenarioError("id пунктов чек-листа должны быть уникальны")
|
||||
for item in scenario.checklist:
|
||||
if not item.question:
|
||||
raise ScenarioError(f"у пункта {item.id} нет текста вопроса")
|
||||
if item.fact and item.fact not in known:
|
||||
raise ScenarioError(f"пункт {item.id} ссылается на факт {item.fact}, которого нет")
|
||||
for fact in scenario.facts:
|
||||
question_id = fact.reveal_on.question if fact.reveal_on else None
|
||||
if question_id and question_id not in question_ids:
|
||||
raise ScenarioError(f"факт {fact.id} раскрывается несуществующим вопросом {question_id}")
|
||||
if fact.refine_on and fact.refine_on not in question_ids:
|
||||
raise ScenarioError(f"факт {fact.id} уточняется несуществующим вопросом {fact.refine_on}")
|
||||
return _derive_ground_truth(scenario)
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -12,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from app.db.models import Scenario as ScenarioRow
|
||||
from app.scenarios.loader import load_library
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.scenarios import editor
|
||||
|
||||
_library: dict[str, Scenario] = {}
|
||||
|
||||
|
|
@ -29,6 +31,11 @@ def all_scenarios() -> list[Scenario]:
|
|||
return list(_library.values())
|
||||
|
||||
|
||||
def publish(scenario: Scenario) -> None:
|
||||
"""Сделать утверждённую версию доступной запуску занятия немедленно."""
|
||||
_library[scenario.id] = scenario
|
||||
|
||||
|
||||
def load_from_disk(root: Path) -> list[Scenario]:
|
||||
scenarios = load_library(root)
|
||||
set_library(scenarios)
|
||||
|
|
@ -51,3 +58,65 @@ async def seed(db: AsyncSession, scenarios: list[Scenario]) -> int:
|
|||
row.body = payload
|
||||
await db.commit()
|
||||
return len(scenarios)
|
||||
|
||||
|
||||
async def restore_published(db: AsyncSession) -> int:
|
||||
"""После старта вернуть утверждённые преподавателем сценарии из БД.
|
||||
|
||||
Вызвать после ``load_from_disk``. Базовую библиотеку не перезаписываем:
|
||||
её источником остаются проверенные YAML из репозитория.
|
||||
"""
|
||||
rows = await db.scalars(select(ScenarioRow).where(ScenarioRow.status == "published"))
|
||||
restored = 0
|
||||
for row in rows:
|
||||
if row.id in _library:
|
||||
continue
|
||||
_library[row.id] = Scenario.model_validate(row.body)
|
||||
restored += 1
|
||||
return restored
|
||||
|
||||
|
||||
async def create_draft(
|
||||
db: AsyncSession, *, source: Scenario, title: str | None = None
|
||||
) -> ScenarioRow:
|
||||
draft_id = f"draft-{uuid4().hex}"
|
||||
body = editor.template_copy(source, draft_id, title)
|
||||
row = ScenarioRow(
|
||||
id=draft_id,
|
||||
title=body["title"],
|
||||
incident_type=body["type"],
|
||||
level=body["level"],
|
||||
topics=body["topics"],
|
||||
modes=body["modes"],
|
||||
status="draft",
|
||||
body=body,
|
||||
)
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
async def draft(db: AsyncSession, scenario_id: str) -> ScenarioRow | None:
|
||||
row = await db.get(ScenarioRow, scenario_id)
|
||||
return row if row is not None and row.status == "draft" else None
|
||||
|
||||
|
||||
async def update_draft(db: AsyncSession, row: ScenarioRow, patch: dict) -> ScenarioRow:
|
||||
row.body = editor.merge_patch(row.body, patch)
|
||||
row.title = str(row.body.get("title") or "")[:200]
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
async def approve_draft(db: AsyncSession, row: ScenarioRow) -> Scenario:
|
||||
scenario = editor.validate(row.body)
|
||||
row.title = scenario.title
|
||||
row.incident_type = scenario.type.value
|
||||
row.level = scenario.level.value
|
||||
row.topics = scenario.topics
|
||||
row.modes = scenario.modes
|
||||
row.body = scenario.model_dump(mode="json")
|
||||
row.status = "published"
|
||||
await db.commit()
|
||||
publish(scenario)
|
||||
return scenario
|
||||
|
|
|
|||
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),
|
||||
}
|
||||
|
||||
#: Вес метрики в детерминированной оценке.
|
||||
|
|
|
|||
|
|
@ -10,12 +10,13 @@
|
|||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.events import ScoreReady
|
||||
from app.domain.events import Exercise, ScoreReady
|
||||
from app.domain.timers import NORMATIVES, TimerCode
|
||||
from app.scenarios import store
|
||||
from app.scoring.competency import radar
|
||||
from app.scoring.dispatcher import evaluate_dispatcher
|
||||
from app.scoring.gost import evaluate
|
||||
from app.scoring.card import evaluate_card
|
||||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||||
from app.scoring.gost import GostResult, evaluate
|
||||
from app.session.hub import hub
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -27,27 +28,36 @@ async def finish(session_id: UUID, state) -> None:
|
|||
if scenario is None:
|
||||
return
|
||||
|
||||
result = evaluate(
|
||||
scenario=scenario,
|
||||
kio=state.kio,
|
||||
timers=state.timers,
|
||||
revealed_facts=[fact.id for fact in state.slots.revealed_facts()] if state.slots else None,
|
||||
refined_facts=list(state.slots.refined) if state.slots else None,
|
||||
end_reason=state.end_reason,
|
||||
bounced_fields=state.bounced_fields,
|
||||
resolved_outcome=state.resolved_outcome,
|
||||
dispatched=state.dispatched_card is not None,
|
||||
)
|
||||
if state.exercise is Exercise.CARD:
|
||||
result = evaluate_card(scenario, state.dispatched_card or state.kio)
|
||||
elif state.exercise is Exercise.DDS:
|
||||
# В готовой карточке не было входящего звонка, опроса и действий
|
||||
# оператора 112. Ни одна E-метрика здесь не применима.
|
||||
result = GostResult()
|
||||
else:
|
||||
result = evaluate(
|
||||
scenario=scenario,
|
||||
kio=state.kio,
|
||||
timers=state.timers,
|
||||
revealed_facts=[fact.id for fact in state.slots.revealed_facts()] if state.slots else None,
|
||||
refined_facts=list(state.slots.refined) if state.slots else None,
|
||||
end_reason=state.end_reason,
|
||||
bounced_fields=state.bounced_fields,
|
||||
resolved_outcome=state.resolved_outcome,
|
||||
dispatched=state.dispatched_card is not None,
|
||||
)
|
||||
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
|
||||
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
|
||||
# участвуют оба (docs/spec/DATASET.md#статусы-реагирования).
|
||||
dispatcher_findings = evaluate_dispatcher(
|
||||
entries=state.status_log,
|
||||
services=state.notified_services(),
|
||||
deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms,
|
||||
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
||||
)
|
||||
result.findings.extend(dispatcher_findings)
|
||||
if state.dispatched_card is not None and state.exercise is not Exercise.CARD:
|
||||
dispatcher_findings = evaluate_dispatcher(
|
||||
entries=state.status_log,
|
||||
services=state.notified_services(),
|
||||
deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms,
|
||||
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
||||
)
|
||||
result.findings.extend(dispatcher_findings)
|
||||
result.metrics.extend(dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms))
|
||||
|
||||
# Сводка числами: по ней считается дельта между попытками в профиле.
|
||||
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
|
||||
|
|
@ -84,5 +94,5 @@ async def finish(session_id: UUID, state) -> None:
|
|||
|
||||
async def release_score(session_id: UUID, state) -> None:
|
||||
"""Отдать оценку курсанту, когда самооценка сдана."""
|
||||
if state.score is not None and state.self_assessed:
|
||||
if state.score is not None and (state.self_assessed or state.exercise is Exercise.CARD):
|
||||
hub.to_trainee(session_id, ScoreReady(session_id=session_id))
|
||||
|
|
|
|||
|
|
@ -28,8 +28,9 @@ class Journal(Protocol):
|
|||
но молчать об ошибке записи нельзя."""
|
||||
|
||||
async def start_lesson(
|
||||
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None
|
||||
) -> int: ...
|
||||
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
|
||||
trainee_id: UUID | None = None,
|
||||
) -> tuple[int, UUID | None]: ...
|
||||
async def utterance(self, session_id: UUID, entry) -> None: ...
|
||||
async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ...
|
||||
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ...
|
||||
|
|
|
|||
|
|
@ -31,9 +31,10 @@ class DbJournal:
|
|||
log.exception("журнал: запись не удалась")
|
||||
|
||||
async def start_lesson(
|
||||
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None
|
||||
) -> int:
|
||||
"""Завести сессию в журнале и вернуть номер попытки.
|
||||
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
|
||||
trainee_id: UUID | None = None,
|
||||
) -> tuple[int, UUID | None]:
|
||||
"""Завести сессию в журнале и вернуть номер попытки и ID курсанта.
|
||||
|
||||
Если база недоступна, занятие всё равно идёт: номер попытки
|
||||
деградирует до первого, и это видно в логе.
|
||||
|
|
@ -46,11 +47,12 @@ class DbJournal:
|
|||
scenario_id=scenario_id,
|
||||
mode=mode,
|
||||
trainee_name=trainee_name,
|
||||
trainee_id=trainee_id,
|
||||
)
|
||||
return row.attempt
|
||||
return row.attempt, row.trainee_id
|
||||
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
|
||||
log.exception("журнал: сессию завести не удалось")
|
||||
return 1
|
||||
return 1, trainee_id
|
||||
|
||||
async def utterance(self, session_id: UUID, entry) -> None:
|
||||
await self._write(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from uuid import UUID
|
|||
|
||||
from app.domain.events import (
|
||||
CallEndReason,
|
||||
Exercise,
|
||||
Mood,
|
||||
SessionMode,
|
||||
SessionSnapshot,
|
||||
|
|
@ -27,12 +28,13 @@ from app.domain.statuses import (
|
|||
ServiceStatus,
|
||||
StationSnapshot,
|
||||
StatusEntry,
|
||||
PhoneReportRecord,
|
||||
card_status,
|
||||
check,
|
||||
current,
|
||||
)
|
||||
from app.domain.timers import NORMATIVES, TimerCode
|
||||
from app.domain.kio import KIO, apply_patch
|
||||
from app.domain.kio import KIO, ResponseStatus, apply_patch
|
||||
from app.session.timers import SessionTimers
|
||||
|
||||
|
||||
|
|
@ -48,6 +50,7 @@ class SessionState:
|
|||
scenario_title: str
|
||||
level: str
|
||||
mode: SessionMode
|
||||
exercise: Exercise = Exercise.CALL
|
||||
required_fields: list[str] = field(default_factory=list)
|
||||
trainee_name: str | None = None
|
||||
#: Чьё это занятие. Проставляется при запуске, когда курсант известен
|
||||
|
|
@ -96,6 +99,9 @@ class SessionState:
|
|||
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
|
||||
#: Отметки служб по списку оповещения — предмет второго режима занятия.
|
||||
status_log: list[StatusEntry] = field(default_factory=list)
|
||||
crew_selected: str | None = None
|
||||
crew_assignments: dict[str, str] = field(default_factory=dict)
|
||||
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
|
||||
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
|
||||
resolved_outcome: str | None = None
|
||||
resolve_comment: str = ""
|
||||
|
|
@ -119,10 +125,12 @@ class SessionState:
|
|||
self.kio = apply_patch(self.kio, fields)
|
||||
return self.kio
|
||||
|
||||
def dispatch(self, service: str) -> KIO:
|
||||
def dispatch(self, service: str | None = None) -> KIO:
|
||||
"""Карточка замораживается снимком: оператор не должен иметь
|
||||
возможности дописать задним числом поле, которое забыл."""
|
||||
self.kio = apply_patch(self.kio, {"dds": service, "response_status": "transferred"})
|
||||
if service is not None:
|
||||
self.kio = apply_patch(self.kio, {"dds": service})
|
||||
self.kio.response_status = ResponseStatus.TRANSFERRED
|
||||
self.dispatched_card = self.kio.model_copy(deep=True)
|
||||
self.dispatched_at = now_utc()
|
||||
return self.dispatched_card
|
||||
|
|
@ -133,6 +141,14 @@ class SessionState:
|
|||
card = self.dispatched_card or self.kio
|
||||
return list(card.notify)
|
||||
|
||||
def crew_options(self) -> list[str]:
|
||||
return [f"{service} — бригада №{number}"
|
||||
for service in self.notified_services() for number in (1, 2)]
|
||||
|
||||
def crew_service(self, crew: str) -> str | None:
|
||||
return next((service for service in self.notified_services()
|
||||
if crew in (f"{service} — бригада №1", f"{service} — бригада №2")), None)
|
||||
|
||||
def set_service_status(
|
||||
self, service: str, status: ServiceStatus, comment: str = "", author: str = ""
|
||||
) -> StatusEntry:
|
||||
|
|
@ -161,6 +177,9 @@ class SessionState:
|
|||
available={service: list(NEXT[status]) for service, status in statuses.items()},
|
||||
card=card_status(self.status_log, services, notify_deadline_passed=overdue),
|
||||
log=list(self.status_log),
|
||||
crew_options=self.crew_options(),
|
||||
crew_selected=self.crew_selected,
|
||||
phone_reports=list(self.phone_reports),
|
||||
)
|
||||
|
||||
def card_received_event(self):
|
||||
|
|
@ -169,7 +188,8 @@ class SessionState:
|
|||
|
||||
return CardReceived(
|
||||
card=self.dispatched_card,
|
||||
from_operator=self.trainee_name or "оператор 112",
|
||||
from_operator=("учебный сценарий" if self.exercise is Exercise.DDS
|
||||
else self.trainee_name or "оператор 112"),
|
||||
at=self.dispatched_at or now_utc(),
|
||||
)
|
||||
|
||||
|
|
@ -186,6 +206,7 @@ class SessionState:
|
|||
scenario_title=self.scenario_title,
|
||||
level=self.level,
|
||||
mode=self.mode,
|
||||
exercise=self.exercise,
|
||||
trainee_name=self.trainee_name,
|
||||
started_at=self.started_at,
|
||||
kio=self.kio,
|
||||
|
|
|
|||
Loading…
Reference in a new issue