fix: подключить рекомендации модели к разбору, убрать лишнюю проверку грамматики ответа ДДС, задать настроение персоне worried

This commit is contained in:
gglamer 2026-09-26 17:12:29 +00:00
commit daa4f8c4f1
11 changed files with 63 additions and 34 deletions

View file

@ -43,7 +43,6 @@ from app.domain.statuses import (
) )
from app.domain.timers import TimerCode from app.domain.timers import TimerCode
from app.scoring.address import address_matches from app.scoring.address import address_matches
from app.scoring.grammar import assess
from app.session.dds import deliver_due_cards from app.session.dds import deliver_due_cards
from app.session.finish import finish, score_current_dds from app.session.finish import finish, score_current_dds
from app.session.hub import LEASE_FENCED_MESSAGE, hub from app.session.hub import LEASE_FENCED_MESSAGE, hub
@ -286,12 +285,11 @@ async def _handle(session_id: UUID, state, event) -> None:
return return
# A browser may lose the acknowledgement after the server has # A browser may lose the acknowledgement after the server has
# committed this replace-style value. Reconnect retries are safe: # committed this replace-style value. Reconnect retries are safe:
# don't create another journal row (or rerun grammar assessment) # don't create another journal row
# when the current card already contains exactly this text. # when the current card already contains exactly this text.
if state.reply_text == event.text: if state.reply_text == event.text:
return return
state.reply_text = event.text state.reply_text = event.text
state.reply_grammar = await assess(event.text)
state.reply_log.append((now_utc(), event.text)) state.reply_log.append((now_utc(), event.text))
case "card.open": case "card.open":
if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.activate_dds_card(event.card_id): if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.activate_dds_card(event.card_id):

View file

@ -13,6 +13,7 @@ from app.scenarios.schema import Persona
#: Настроение базового профиля, если дуга не задана. #: Настроение базового профиля, если дуга не задана.
BASE_MOOD: dict[str, Mood] = { BASE_MOOD: dict[str, Mood] = {
"calm": Mood.CALM, "calm": Mood.CALM,
"worried": Mood.WORRIED,
"panic": Mood.PANIC, "panic": Mood.PANIC,
"aggressive": Mood.AGGRESSIVE, "aggressive": Mood.AGGRESSIVE,
"elderly": Mood.CONFUSED, "elderly": Mood.CONFUSED,

View file

@ -8,6 +8,7 @@
from uuid import UUID from uuid import UUID
from app.domain.events import ( from app.domain.events import (
AICoaching,
CompetencyScore, CompetencyScore,
DdsCardReport, DdsCardReport,
HintShown, HintShown,
@ -108,4 +109,6 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
score_final=score.get("score_final", score.get("score_auto", 0.0)), score_final=score.get("score_final", score.get("score_auto", 0.0)),
overridden_by=score.get("overridden_by"), overridden_by=score.get("overridden_by"),
override_comment=score.get("override_comment"), override_comment=score.get("override_comment"),
ai_coaching=(AICoaching.model_validate(score["ai_coaching"])
if score.get("ai_coaching") else None),
) )

View file

@ -33,19 +33,12 @@ from app.domain.statuses import (
from app.domain.taxonomy import Finding from app.domain.taxonomy import Finding
from app.domain.timers import TimerCode from app.domain.timers import TimerCode
from app.scenarios.schema import Scenario from app.scenarios.schema import Scenario
from app.scoring.grammar import GrammarAssessment
from app.session.state import DdsCardRecord, DdsLiveCard, SessionState, now_utc from app.session.state import DdsCardRecord, DdsLiveCard, SessionState, now_utc
from app.session.timers import SessionTimers, Timer from app.session.timers import SessionTimers, Timer
CHECKPOINT_VERSION = 1 CHECKPOINT_VERSION = 1
def _grammar(value: GrammarAssessment | None) -> dict | None:
if value is None:
return None
return {"passed": value.passed, "errors": list(value.errors), "source": value.source}
def _dump_timers(timers: SessionTimers, now: float) -> dict: def _dump_timers(timers: SessionTimers, now: float) -> dict:
return { return {
"limits": {code.value: limit for code, limit in timers.limits.items()}, "limits": {code.value: limit for code, limit in timers.limits.items()},
@ -77,7 +70,6 @@ def _dump_live_card(item: DdsLiveCard, now: float) -> dict:
"phone_lines": [entry.model_dump(mode="json") for entry in item.phone_lines], "phone_lines": [entry.model_dump(mode="json") for entry in item.phone_lines],
"phone_pending": item.phone_pending.model_dump(mode="json") if item.phone_pending else None, "phone_pending": item.phone_pending.model_dump(mode="json") if item.phone_pending else None,
"reply_text": item.reply_text, "reply_text": item.reply_text,
"reply_grammar": _grammar(item.reply_grammar),
"reply_log": [[at.isoformat(), text] for at, text in item.reply_log], "reply_log": [[at.isoformat(), text] for at, text in item.reply_log],
} }
@ -163,7 +155,6 @@ def dump_state(state: SessionState) -> dict:
for item in state.dds_completed for item in state.dds_completed
], ],
"reply_text": state.reply_text, "reply_text": state.reply_text,
"reply_grammar": _grammar(state.reply_grammar),
"reply_log": [[at.isoformat(), text] for at, text in state.reply_log], "reply_log": [[at.isoformat(), text] for at, text in state.reply_log],
"resolved_outcome": state.resolved_outcome, "resolved_outcome": state.resolved_outcome,
"resolve_comment": state.resolve_comment, "resolve_comment": state.resolve_comment,
@ -205,16 +196,6 @@ def _restore_timers(payload: dict, saved_at: datetime) -> SessionTimers:
return restored return restored
def _restore_grammar(value: dict | None) -> GrammarAssessment | None:
if not value:
return None
return GrammarAssessment(
passed=bool(value["passed"]),
errors=tuple(value.get("errors") or []),
source=value["source"],
)
def _restore_live_card(item: dict, saved_at: datetime) -> DdsLiveCard: def _restore_live_card(item: dict, saved_at: datetime) -> DdsLiveCard:
return DdsLiveCard( return DdsLiveCard(
original_index=int(item["original_index"]), original_index=int(item["original_index"]),
@ -237,7 +218,6 @@ def _restore_live_card(item: dict, saved_at: datetime) -> DdsLiveCard:
phone_pending=(PhoneCallPending.model_validate(item["phone_pending"]) phone_pending=(PhoneCallPending.model_validate(item["phone_pending"])
if item.get("phone_pending") else None), if item.get("phone_pending") else None),
reply_text=item.get("reply_text", ""), reply_text=item.get("reply_text", ""),
reply_grammar=_restore_grammar(item.get("reply_grammar")),
reply_log=[(datetime.fromisoformat(at), text) reply_log=[(datetime.fromisoformat(at), text)
for at, text in item.get("reply_log", [])], for at, text in item.get("reply_log", [])],
) )
@ -336,7 +316,6 @@ def load_state(payload: dict, saved_at: datetime) -> SessionState:
for item in payload.get("dds_completed", []) for item in payload.get("dds_completed", [])
], ],
reply_text=payload.get("reply_text", ""), reply_text=payload.get("reply_text", ""),
reply_grammar=_restore_grammar(payload.get("reply_grammar")),
reply_log=[(datetime.fromisoformat(at), text) reply_log=[(datetime.fromisoformat(at), text)
for at, text in payload.get("reply_log", [])], for at, text in payload.get("reply_log", [])],
resolved_outcome=payload.get("resolved_outcome"), resolved_outcome=payload.get("resolved_outcome"),

View file

@ -112,7 +112,6 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
state.phone_lines = [] state.phone_lines = []
state.phone_pending = None state.phone_pending = None
state.reply_text = "" state.reply_text = ""
state.reply_grammar = None
state.reply_log = [] state.reply_log = []
# Настроенный преподавателем лимит копируется в независимый таймер карточки. # Настроенный преподавателем лимит копируется в независимый таймер карточки.
state.timers = SessionTimers(limits=dict(state.timers.limits)) state.timers = SessionTimers(limits=dict(state.timers.limits))
@ -140,7 +139,6 @@ def _append_live_card(
phone_lines=state.phone_lines, phone_lines=state.phone_lines,
phone_pending=state.phone_pending, phone_pending=state.phone_pending,
reply_text=state.reply_text, reply_text=state.reply_text,
reply_grammar=state.reply_grammar,
reply_log=state.reply_log, reply_log=state.reply_log,
) )
state.dds_live_cards.append(card) state.dds_live_cards.append(card)

View file

@ -17,6 +17,7 @@ from app.domain.statuses import ServiceStatus, current
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
from app.domain.timers import TimerCode from app.domain.timers import TimerCode
from app.scenarios import store from app.scenarios import store
from app.scoring.ai_coach import coach
from app.scoring.card import evaluate_card from app.scoring.card import evaluate_card
from app.scoring.competency import radar from app.scoring.competency import radar
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
@ -322,6 +323,9 @@ async def finish(session_id: UUID, state) -> None:
for card in cards for card in cards
] if state.exercise is Exercise.DDS or state.handoff_to_dds else [], ] if state.exercise is Exercise.DDS or state.handoff_to_dds else [],
} }
# Модель только поясняет уже посчитанные провалы и балл не трогает; без
# запущенной модели разбор выходит со статусом «недоступно», а не ждёт её.
state.score["ai_coaching"] = (await coach(result.metrics)).model_dump(mode="json")
# Полный разбор хранится вместе с оценкой: PDF/CSV и история должны # Полный разбор хранится вместе с оценкой: PDF/CSV и история должны
# переживать перезапуск backend, а не зависеть от объекта в hub._sessions. # переживать перезапуск backend, а не зависеть от объекта в hub._sessions.
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json") state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")

View file

@ -44,7 +44,6 @@ from app.domain.statuses import (
from app.domain.taxonomy import Finding from app.domain.taxonomy import Finding
from app.domain.timers import TimerCode from app.domain.timers import TimerCode
from app.scenarios.schema import Scenario from app.scenarios.schema import Scenario
from app.scoring.grammar import GrammarAssessment
from app.session.timers import SessionTimers from app.session.timers import SessionTimers
@ -98,7 +97,6 @@ class DdsLiveCard:
phone_lines: list[PhoneLineRecord] = field(default_factory=list) phone_lines: list[PhoneLineRecord] = field(default_factory=list)
phone_pending: PhoneCallPending | None = None phone_pending: PhoneCallPending | None = None
reply_text: str = "" reply_text: str = ""
reply_grammar: GrammarAssessment | None = None
reply_log: list[tuple[datetime, str]] = field(default_factory=list) reply_log: list[tuple[datetime, str]] = field(default_factory=list)
@property @property
@ -196,7 +194,6 @@ class SessionState:
dds_next_scenario_index: int = 0 dds_next_scenario_index: int = 0
dds_next_arrival_at: datetime | None = None dds_next_arrival_at: datetime | None = None
reply_text: str = "" reply_text: str = ""
reply_grammar: GrammarAssessment | None = None
reply_log: list[tuple[datetime, str]] = field(default_factory=list) reply_log: list[tuple[datetime, str]] = field(default_factory=list)
#: Чем курсант закрыл вызов, если не карточкой (lct-36). #: Чем курсант закрыл вызов, если не карточкой (lct-36).
resolved_outcome: str | None = None resolved_outcome: str | None = None
@ -299,7 +296,6 @@ class SessionState:
card.phone_lines = self.phone_lines card.phone_lines = self.phone_lines
card.phone_pending = self.phone_pending card.phone_pending = self.phone_pending
card.reply_text = self.reply_text card.reply_text = self.reply_text
card.reply_grammar = self.reply_grammar
card.reply_log = self.reply_log card.reply_log = self.reply_log
self.dds_active_card_id = card.card_id self.dds_active_card_id = card.card_id
@ -329,7 +325,6 @@ class SessionState:
self.phone_lines = card.phone_lines self.phone_lines = card.phone_lines
self.phone_pending = card.phone_pending self.phone_pending = card.phone_pending
self.reply_text = card.reply_text self.reply_text = card.reply_text
self.reply_grammar = card.reply_grammar
self.reply_log = card.reply_log self.reply_log = card.reply_log
self.dds_card_index = card.original_index self.dds_card_index = card.original_index
self.dds_active_card_id = card.card_id self.dds_active_card_id = card.card_id

View file

@ -359,3 +359,41 @@ def test_handoff_queue_mixes_trainee_card_then_selected_prepared_card(client):
assert state["score_auto"] < 100 assert state["score_auto"] < 100
finally: finally:
control.__exit__(None, None, None) control.__exit__(None, None, None)
def test_model_coaching_reaches_report_without_changing_score(client, monkeypatch):
from app.domain.events import AICoaching, AIRecommendation
seen: list[list[str]] = []
async def fake_coach(metrics):
failed = [item.key for item in metrics if not item.passed and item.weight > 0]
seen.append(failed)
return AICoaching(status="ready", model="test", recommendations=[
AIRecommendation(metric_key=failed[0], text="Перечитайте описание перед сдачей карточки."),
])
monkeypatch.setattr("app.session.finish.assess", rules_only_grammar)
monkeypatch.setattr("app.session.finish.coach", fake_coach)
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
read_until(trainee, "card.briefing")
trainee.send_json({"type": "kio.patch", "fields": {
"address": "улица Ленина, 14", "floor": "5", "incident_type": "fire",
"victims_count": 2, "description": "горит балкон",
"signs": ["жилой дом", "балкон", "открытое пламя"],
}})
wait_for(lambda: hub.get(session_id).kio.incident_code)
trainee.send_json({"type": "card.submit"})
read_until(trainee, "call.ended")
read_until(trainee, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
assert seen and "description_grammar" in seen[0]
coaching = score["full_report"]["ai_coaching"]
assert coaching["status"] == "ready"
assert coaching["recommendations"][0]["metric_key"] == seen[0][0]
assert score["score_auto"] < 100
finally:
control.__exit__(None, None, None)

View file

@ -6,6 +6,7 @@ from pathlib import Path
import pytest import pytest
from app.dialog.persona import BASE_MOOD
from app.scenarios.loader import ScenarioError, load_file, load_library from app.scenarios.loader import ScenarioError, load_file, load_library
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
@ -38,6 +39,13 @@ def test_library_loads():
assert all(s.ground_truth.dds for s in scenarios), "ДДС не выведен" assert all(s.ground_truth.dds for s in scenarios), "ДДС не выведен"
def test_library_personas_have_base_mood():
# base — свободная строка: неизвестное значение молча становится «спокойным»
# звонящим, и сценарий теряет задуманную подачу.
unknown = {s.id: s.persona.base for s in load_library(LIBRARY) if s.persona.base not in BASE_MOOD}
assert not unknown, f"персона без настроения в BASE_MOOD: {unknown}"
def test_extends_inherits_whole_checklist(): def test_extends_inherits_whole_checklist():
"""Общий чек-лист по классификатору наследуется целиком, """Общий чек-лист по классификатору наследуется целиком,
локальные пункты дополняют его.""" локальные пункты дополняют его."""

View file

@ -11,7 +11,6 @@ from app.domain.kio import KIO
from app.domain.statuses import PhoneCallPending, ServiceStatus from app.domain.statuses import PhoneCallPending, ServiceStatus
from app.domain.timers import TimerCode from app.domain.timers import TimerCode
from app.scenarios.loader import load_file from app.scenarios.loader import load_file
from app.scoring.grammar import basic_check
from app.session.checkpoint import dump_state, load_state from app.session.checkpoint import dump_state, load_state
from app.session.dds import deliver_due_cards, prepare_handoff_queue, prepare_queue from app.session.dds import deliver_due_cards, prepare_handoff_queue, prepare_queue
from app.session.hub import LEASE_FENCED_MESSAGE, SessionHub from app.session.hub import LEASE_FENCED_MESSAGE, SessionHub
@ -50,7 +49,6 @@ def dds_state() -> SessionState:
service=service, crew=state.crew_selected, phase="dispatched" service=service, crew=state.crew_selected, phase="dispatched"
) )
state.reply_text = "Сообщение принято, бригада направлена." state.reply_text = "Сообщение принято, бригада направлена."
state.reply_grammar = basic_check(state.reply_text)
state.reply_log.append((now_utc(), state.reply_text)) state.reply_log.append((now_utc(), state.reply_text))
state.dds_log.append(("crew.select", now_utc(), state.crew_selected)) state.dds_log.append(("crew.select", now_utc(), state.crew_selected))
return state return state
@ -74,7 +72,6 @@ def test_active_dds_session_round_trips_without_losing_work():
assert restored.crew_assignments == before.crew_assignments assert restored.crew_assignments == before.crew_assignments
assert restored.phone_pending == before.phone_pending assert restored.phone_pending == before.phone_pending
assert restored.reply_text == before.reply_text assert restored.reply_text == before.reply_text
assert restored.reply_grammar == before.reply_grammar
assert restored.processed_station_commands == before.processed_station_commands assert restored.processed_station_commands == before.processed_station_commands
assert restored.dds_scenarios[0].id == before.scenario_id assert restored.dds_scenarios[0].id == before.scenario_id
# Время простоя backend входит в норматив, а не обнуляет таймер. # Время простоя backend входит в норматив, а не обнуляет таймер.

View file

@ -18,6 +18,8 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
const notes = report.notes ?? []; const notes = report.notes ?? [];
const passedMetrics = Math.max(0, report.metrics.length - report.failed_metrics); const passedMetrics = Math.max(0, report.metrics.length - report.failed_metrics);
const improvementPoints = report.metrics.filter((metric) => !metric.passed).slice(0, 3); const improvementPoints = report.metrics.filter((metric) => !metric.passed).slice(0, 3);
const metricTitles = new Map(report.metrics.map((metric) => [metric.key, metric.title]));
const coaching = report.ai_coaching?.status === "ready" ? report.ai_coaching.recommendations ?? [] : [];
return ( return (
<section className={big ? "debrief debrief-big" : "debrief"}> <section className={big ? "debrief debrief-big" : "debrief"}>
@ -57,6 +59,12 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
) : ( ) : (
<p>Зачёт не получен. Сверьте отметки и факты ниже с ходом занятия.</p> <p>Зачёт не получен. Сверьте отметки и факты ниже с ходом занятия.</p>
)} )}
{coaching.length > 0 && <>
<p>Рекомендации локальной модели (на балл не влияют):</p>
<ul>{coaching.map((item) => <li key={item.metric_key}>
{metricTitles.get(item.metric_key) ?? item.metric_key}: {item.text}
</li>)}</ul>
</>}
</section> </section>
<p className="ref"> <p className="ref">
Скачать отчёт: <a href={`/api/sessions/${report.session_id}/report.csv`}>таблица (CSV)</a> Скачать отчёт: <a href={`/api/sessions/${report.session_id}/report.csv`}>таблица (CSV)</a>