fix: подключить рекомендации модели к разбору, убрать лишнюю проверку грамматики ответа ДДС, задать настроение персоне worried
This commit is contained in:
parent
3ae682a354
commit
daa4f8c4f1
11 changed files with 63 additions and 34 deletions
|
|
@ -43,7 +43,6 @@ from app.domain.statuses import (
|
|||
)
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scoring.address import address_matches
|
||||
from app.scoring.grammar import assess
|
||||
from app.session.dds import deliver_due_cards
|
||||
from app.session.finish import finish, score_current_dds
|
||||
from app.session.hub import LEASE_FENCED_MESSAGE, hub
|
||||
|
|
@ -286,12 +285,11 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
return
|
||||
# A browser may lose the acknowledgement after the server has
|
||||
# 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.
|
||||
if state.reply_text == event.text:
|
||||
return
|
||||
state.reply_text = event.text
|
||||
state.reply_grammar = await assess(event.text)
|
||||
state.reply_log.append((now_utc(), event.text))
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from app.scenarios.schema import Persona
|
|||
#: Настроение базового профиля, если дуга не задана.
|
||||
BASE_MOOD: dict[str, Mood] = {
|
||||
"calm": Mood.CALM,
|
||||
"worried": Mood.WORRIED,
|
||||
"panic": Mood.PANIC,
|
||||
"aggressive": Mood.AGGRESSIVE,
|
||||
"elderly": Mood.CONFUSED,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
from uuid import UUID
|
||||
|
||||
from app.domain.events import (
|
||||
AICoaching,
|
||||
CompetencyScore,
|
||||
DdsCardReport,
|
||||
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)),
|
||||
overridden_by=score.get("overridden_by"),
|
||||
override_comment=score.get("override_comment"),
|
||||
ai_coaching=(AICoaching.model_validate(score["ai_coaching"])
|
||||
if score.get("ai_coaching") else None),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -33,19 +33,12 @@ from app.domain.statuses import (
|
|||
from app.domain.taxonomy import Finding
|
||||
from app.domain.timers import TimerCode
|
||||
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.timers import SessionTimers, Timer
|
||||
|
||||
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:
|
||||
return {
|
||||
"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_pending": item.phone_pending.model_dump(mode="json") if item.phone_pending else None,
|
||||
"reply_text": item.reply_text,
|
||||
"reply_grammar": _grammar(item.reply_grammar),
|
||||
"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
|
||||
],
|
||||
"reply_text": state.reply_text,
|
||||
"reply_grammar": _grammar(state.reply_grammar),
|
||||
"reply_log": [[at.isoformat(), text] for at, text in state.reply_log],
|
||||
"resolved_outcome": state.resolved_outcome,
|
||||
"resolve_comment": state.resolve_comment,
|
||||
|
|
@ -205,16 +196,6 @@ def _restore_timers(payload: dict, saved_at: datetime) -> SessionTimers:
|
|||
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:
|
||||
return DdsLiveCard(
|
||||
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"])
|
||||
if item.get("phone_pending") else None),
|
||||
reply_text=item.get("reply_text", ""),
|
||||
reply_grammar=_restore_grammar(item.get("reply_grammar")),
|
||||
reply_log=[(datetime.fromisoformat(at), text)
|
||||
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", [])
|
||||
],
|
||||
reply_text=payload.get("reply_text", ""),
|
||||
reply_grammar=_restore_grammar(payload.get("reply_grammar")),
|
||||
reply_log=[(datetime.fromisoformat(at), text)
|
||||
for at, text in payload.get("reply_log", [])],
|
||||
resolved_outcome=payload.get("resolved_outcome"),
|
||||
|
|
|
|||
|
|
@ -112,7 +112,6 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
|
|||
state.phone_lines = []
|
||||
state.phone_pending = None
|
||||
state.reply_text = ""
|
||||
state.reply_grammar = None
|
||||
state.reply_log = []
|
||||
# Настроенный преподавателем лимит копируется в независимый таймер карточки.
|
||||
state.timers = SessionTimers(limits=dict(state.timers.limits))
|
||||
|
|
@ -140,7 +139,6 @@ def _append_live_card(
|
|||
phone_lines=state.phone_lines,
|
||||
phone_pending=state.phone_pending,
|
||||
reply_text=state.reply_text,
|
||||
reply_grammar=state.reply_grammar,
|
||||
reply_log=state.reply_log,
|
||||
)
|
||||
state.dds_live_cards.append(card)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from app.domain.statuses import ServiceStatus, current
|
|||
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scenarios import store
|
||||
from app.scoring.ai_coach import coach
|
||||
from app.scoring.card import evaluate_card
|
||||
from app.scoring.competency import radar
|
||||
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
|
||||
] 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 и история должны
|
||||
# переживать перезапуск backend, а не зависеть от объекта в hub._sessions.
|
||||
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ from app.domain.statuses import (
|
|||
from app.domain.taxonomy import Finding
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.scoring.grammar import GrammarAssessment
|
||||
from app.session.timers import SessionTimers
|
||||
|
||||
|
||||
|
|
@ -98,7 +97,6 @@ class DdsLiveCard:
|
|||
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
|
||||
phone_pending: PhoneCallPending | None = None
|
||||
reply_text: str = ""
|
||||
reply_grammar: GrammarAssessment | None = None
|
||||
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
|
|
@ -196,7 +194,6 @@ class SessionState:
|
|||
dds_next_scenario_index: int = 0
|
||||
dds_next_arrival_at: datetime | None = None
|
||||
reply_text: str = ""
|
||||
reply_grammar: GrammarAssessment | None = None
|
||||
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
|
||||
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
|
||||
resolved_outcome: str | None = None
|
||||
|
|
@ -299,7 +296,6 @@ class SessionState:
|
|||
card.phone_lines = self.phone_lines
|
||||
card.phone_pending = self.phone_pending
|
||||
card.reply_text = self.reply_text
|
||||
card.reply_grammar = self.reply_grammar
|
||||
card.reply_log = self.reply_log
|
||||
self.dds_active_card_id = card.card_id
|
||||
|
||||
|
|
@ -329,7 +325,6 @@ class SessionState:
|
|||
self.phone_lines = card.phone_lines
|
||||
self.phone_pending = card.phone_pending
|
||||
self.reply_text = card.reply_text
|
||||
self.reply_grammar = card.reply_grammar
|
||||
self.reply_log = card.reply_log
|
||||
self.dds_card_index = card.original_index
|
||||
self.dds_active_card_id = card.card_id
|
||||
|
|
|
|||
|
|
@ -359,3 +359,41 @@ def test_handoff_queue_mixes_trainee_card_then_selected_prepared_card(client):
|
|||
assert state["score_auto"] < 100
|
||||
finally:
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from pathlib import Path
|
|||
|
||||
import pytest
|
||||
|
||||
from app.dialog.persona import BASE_MOOD
|
||||
from app.scenarios.loader import ScenarioError, load_file, load_library
|
||||
|
||||
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), "ДДС не выведен"
|
||||
|
||||
|
||||
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():
|
||||
"""Общий чек-лист по классификатору наследуется целиком,
|
||||
локальные пункты дополняют его."""
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from app.domain.kio import KIO
|
|||
from app.domain.statuses import PhoneCallPending, ServiceStatus
|
||||
from app.domain.timers import TimerCode
|
||||
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.dds import deliver_due_cards, prepare_handoff_queue, prepare_queue
|
||||
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"
|
||||
)
|
||||
state.reply_text = "Сообщение принято, бригада направлена."
|
||||
state.reply_grammar = basic_check(state.reply_text)
|
||||
state.reply_log.append((now_utc(), state.reply_text))
|
||||
state.dds_log.append(("crew.select", now_utc(), state.crew_selected))
|
||||
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.phone_pending == before.phone_pending
|
||||
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.dds_scenarios[0].id == before.scenario_id
|
||||
# Время простоя backend входит в норматив, а не обнуляет таймер.
|
||||
|
|
|
|||
Loading…
Reference in a new issue