lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный руками, он разошёлся бы с фактами при первой же правке сценария, и курсанта оштрафовали бы за правильный ответ. Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый недобытый факт с эталонным вопросом, расхождение самооценки — что курсант заметил сам, чего не заметил, что отметил зря. Не заметил — самое ценное для разбора. Внешний монитор — не отдельное приложение, а другой режим отрисовки тех же событий: крупный таймер опроса, ход разговора, карточка, после оценки разбор на весь экран. Коррекция преподавателем сохраняет автооценку рядом: видно, что скорректировано и кем. Найдено: все метрики весили одинаково, и курсант, не задавший ни одного вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные веса (полнота опроса — 4) дают 74; окончательные утверждает методист, вопрос записан в DEBRIEF.md.
This commit is contained in:
parent
25143645d3
commit
fadfa3e479
20 changed files with 781 additions and 9 deletions
|
|
@ -13,8 +13,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db import repo
|
from app.db import repo
|
||||||
from app.db.base import get_session
|
from app.db.base import get_session
|
||||||
from app.domain.events import SessionMode
|
from app.domain.events import SessionMode, SessionReport
|
||||||
from app.scenarios import store
|
from app.scenarios import store
|
||||||
|
from app.scoring.report import build as build_report
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
||||||
|
|
@ -103,6 +104,53 @@ async def checklist(session_id: UUID) -> list[ChecklistItemOut]:
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ScoreOverride(BaseModel):
|
||||||
|
"""Коррекция оценки преподавателем. Автооценка сохраняется рядом."""
|
||||||
|
|
||||||
|
score_final: float
|
||||||
|
comment: str = ""
|
||||||
|
author: str = "преподаватель"
|
||||||
|
|
||||||
|
|
||||||
|
def _live(session_id: UUID):
|
||||||
|
state = hub.get(session_id)
|
||||||
|
if state is None:
|
||||||
|
raise HTTPException(status_code=404, detail="session_not_found")
|
||||||
|
scenario = store.get(state.scenario_id)
|
||||||
|
if scenario is None:
|
||||||
|
raise HTTPException(status_code=404, detail="scenario_not_found")
|
||||||
|
return state, scenario
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{session_id}/report", response_model=SessionReport)
|
||||||
|
async def report(session_id: UUID) -> SessionReport:
|
||||||
|
"""Разбор сессии: метрики, отметки, эталонные вопросы, самооценка, пометки.
|
||||||
|
|
||||||
|
Оценка курсанту открывается событием `score.ready` после самооценки; здесь
|
||||||
|
прав нет — в прототипе нет входа, и точка доступна всем, у кого есть номер
|
||||||
|
занятия (docs/arch/CONTRACT.md).
|
||||||
|
"""
|
||||||
|
state, scenario = _live(session_id)
|
||||||
|
if state.score is None:
|
||||||
|
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||||
|
return build_report(session_id, state, scenario)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{session_id}/report", response_model=SessionReport)
|
||||||
|
async def override(session_id: UUID, body: ScoreOverride) -> SessionReport:
|
||||||
|
"""Тренажёр готовит материал, преподаватель имеет последнее слово."""
|
||||||
|
state, scenario = _live(session_id)
|
||||||
|
if state.score is None:
|
||||||
|
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||||
|
state.score = {
|
||||||
|
**state.score,
|
||||||
|
"score_final": body.score_final,
|
||||||
|
"overridden_by": body.author,
|
||||||
|
"override_comment": body.comment,
|
||||||
|
}
|
||||||
|
return build_report(session_id, state, scenario)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[SessionOut])
|
@router.get("", response_model=list[SessionOut])
|
||||||
async def listing(
|
async def listing(
|
||||||
trainee: UUID | None = None,
|
trainee: UUID | None = None,
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ async def _handle(session_id: UUID, state, event) -> None:
|
||||||
return
|
return
|
||||||
checklist_id, question = nxt
|
checklist_id, question = nxt
|
||||||
state.hints_shown.append(checklist_id)
|
state.hints_shown.append(checklist_id)
|
||||||
|
state.hints_log.append((checklist_id, now_utc()))
|
||||||
shown = HintShown(checklist_id=checklist_id, question=question)
|
shown = HintShown(checklist_id=checklist_id, question=question)
|
||||||
hub.broadcast(session_id, shown)
|
hub.broadcast(session_id, shown)
|
||||||
if hub.journal:
|
if hub.journal:
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from pydantic import TypeAdapter, ValidationError
|
||||||
|
|
||||||
from app.domain.events import (
|
from app.domain.events import (
|
||||||
CallEndReason,
|
CallEndReason,
|
||||||
|
ScoreReady,
|
||||||
CallIncoming,
|
CallIncoming,
|
||||||
ErrorEvent,
|
ErrorEvent,
|
||||||
ErrorKind,
|
ErrorKind,
|
||||||
|
|
@ -131,6 +132,14 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
||||||
case "session.stop":
|
case "session.stop":
|
||||||
await _stop(session_id)
|
await _stop(session_id)
|
||||||
case "instructor_note.add":
|
case "instructor_note.add":
|
||||||
|
state = hub.get(session_id)
|
||||||
|
if state is not None:
|
||||||
|
state.notes.append({
|
||||||
|
"type": "instructor_note.shown",
|
||||||
|
"transcript_ref": event.transcript_ref,
|
||||||
|
"text": event.text,
|
||||||
|
"author": "преподаватель",
|
||||||
|
})
|
||||||
hub.to_observers(
|
hub.to_observers(
|
||||||
session_id,
|
session_id,
|
||||||
InstructorNoteShown(
|
InstructorNoteShown(
|
||||||
|
|
@ -147,6 +156,17 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
||||||
state = hub.get(session_id)
|
state = hub.get(session_id)
|
||||||
if state is not None:
|
if state is not None:
|
||||||
hub.to_observers(session_id, ReferenceStarted(scenario_id=state.scenario_id))
|
hub.to_observers(session_id, ReferenceStarted(scenario_id=state.scenario_id))
|
||||||
|
case "score.override":
|
||||||
|
state = hub.get(session_id)
|
||||||
|
if state is not None and state.score is not None:
|
||||||
|
# Автооценка остаётся рядом: видно, что скорректировано и кем.
|
||||||
|
state.score = {
|
||||||
|
**state.score,
|
||||||
|
"score_final": float(event.verdict) if event.verdict.replace(".", "", 1).isdigit() else state.score["score_auto"],
|
||||||
|
"overridden_by": "преподаватель",
|
||||||
|
"override_comment": event.comment,
|
||||||
|
}
|
||||||
|
hub.to_observers(session_id, ScoreReady(session_id=session_id))
|
||||||
case "director.inject":
|
case "director.inject":
|
||||||
# Поведение звонящего — карточка lct-07, пульт — lct-22.
|
# Поведение звонящего — карточка lct-07, пульт — lct-22.
|
||||||
# До них директива копится в состоянии и видна в разборе.
|
# До них директива копится в состоянии и видна в разборе.
|
||||||
|
|
|
||||||
|
|
@ -511,6 +511,21 @@ class SelfAssessment(BaseModel):
|
||||||
submitted_at: datetime
|
submitted_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SelfAssessmentDiff(BaseModel):
|
||||||
|
"""Расхождение самооценки с автооценкой — отдельный материал для преподавателя.
|
||||||
|
|
||||||
|
Курсант, не заметивший, что пропустил вопрос о пострадавших, — более важный
|
||||||
|
случай, чем сама ошибка (docs/product/DEBRIEF.md).
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Пропустил и сам это заметил.
|
||||||
|
noticed: list[str] = []
|
||||||
|
#: Пропустил и не заметил — самое ценное для разбора.
|
||||||
|
unnoticed: list[str] = []
|
||||||
|
#: Отметил как пропущенное, хотя на деле спросил.
|
||||||
|
overcautious: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
class SessionReport(BaseModel):
|
class SessionReport(BaseModel):
|
||||||
"""Единица истории: из отчётов складываются профиль, дельта попыток, аналитика."""
|
"""Единица истории: из отчётов складываются профиль, дельта попыток, аналитика."""
|
||||||
|
|
||||||
|
|
@ -526,6 +541,8 @@ class SessionReport(BaseModel):
|
||||||
missed_checklist: list[str]
|
missed_checklist: list[str]
|
||||||
hints_used: list[HintUsage]
|
hints_used: list[HintUsage]
|
||||||
self_assessment: SelfAssessment | None = None
|
self_assessment: SelfAssessment | None = None
|
||||||
|
self_assessment_diff: SelfAssessmentDiff | None = None
|
||||||
|
notes: list[InstructorNoteShown] = []
|
||||||
score_auto: float
|
score_auto: float
|
||||||
score_final: float
|
score_final: float
|
||||||
overridden_by: str | None = None
|
overridden_by: str | None = None
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from app.domain.kio import KIO, missing_fields
|
||||||
from app.domain.taxonomy import ERRORS, Competency, Finding, FindingSource
|
from app.domain.taxonomy import ERRORS, Competency, Finding, FindingSource
|
||||||
from app.domain.timers import GOST_REF, NORMATIVES, TimerCode
|
from app.domain.timers import GOST_REF, NORMATIVES, TimerCode
|
||||||
from app.scenarios.schema import Scenario
|
from app.scenarios.schema import Scenario
|
||||||
from app.scoring.taxonomy import METRIC_MAP
|
from app.scoring.taxonomy import METRIC_MAP, METRIC_WEIGHTS
|
||||||
from app.session.timers import SessionTimers
|
from app.session.timers import SessionTimers
|
||||||
|
|
||||||
SOURCE_BY_CODE = {
|
SOURCE_BY_CODE = {
|
||||||
|
|
@ -64,7 +64,8 @@ class _Builder:
|
||||||
def add(self, key: str, title: str, fact: str, norm: str, passed: bool, ref: str | None = None,
|
def add(self, key: str, title: str, fact: str, norm: str, passed: bool, ref: str | None = None,
|
||||||
finding: str | None = None) -> None:
|
finding: str | None = None) -> None:
|
||||||
self.result.metrics.append(
|
self.result.metrics.append(
|
||||||
Metric(key=key, title=title, fact=fact, norm=norm, ref=ref, passed=passed)
|
Metric(key=key, title=title, fact=fact, norm=norm, ref=ref, passed=passed,
|
||||||
|
weight=METRIC_WEIGHTS.get(key, 1.0))
|
||||||
)
|
)
|
||||||
if passed:
|
if passed:
|
||||||
return
|
return
|
||||||
|
|
@ -156,6 +157,7 @@ def evaluate(
|
||||||
norm="все обязательные факты",
|
norm="все обязательные факты",
|
||||||
ref="чек-лист сценария",
|
ref="чек-лист сценария",
|
||||||
passed=len(got) == len(required),
|
passed=len(got) == len(required),
|
||||||
|
weight=METRIC_WEIGHTS["checklist_completeness"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# По отметке на каждый недобытый факт: в разборе нужен конкретный
|
# По отметке на каждый недобытый факт: в разборе нужен конкретный
|
||||||
|
|
|
||||||
70
backend/app/scoring/reference.py
Normal file
70
backend/app/scoring/reference.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""Эталонный диалог: как должен был пройти опрос.
|
||||||
|
|
||||||
|
Собирается **кодом** из чек-листа и фактов сценария. Писать его руками нельзя:
|
||||||
|
методист поправит факт и забудет эталон, и курсант получит штраф за правильный
|
||||||
|
ответ (docs/spec/SCENARIO-FORMAT.md).
|
||||||
|
|
||||||
|
Работает в двух местах: в разборе — «вот какой вопрос стоял на этом шаге»
|
||||||
|
(docs/product/DEBRIEF.md), и в эталон-плеере на внешнем мониторе, когда
|
||||||
|
появится предгенерация озвучки (lct-19, требует ключа LLM).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.dialog.caller import REVEAL
|
||||||
|
from app.dialog.persona import BASE_MOOD
|
||||||
|
from app.domain.events import Mood
|
||||||
|
from app.scenarios.schema import Scenario
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceStep(BaseModel):
|
||||||
|
"""Шаг эталонного опроса: вопрос оператора и ответ звонящего на него."""
|
||||||
|
|
||||||
|
checklist_id: str
|
||||||
|
question: str
|
||||||
|
fact_id: str | None = None
|
||||||
|
answer: str | None = None
|
||||||
|
required: bool = False
|
||||||
|
hidden: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceDialog(BaseModel):
|
||||||
|
scenario_id: str
|
||||||
|
first_line: str
|
||||||
|
steps: list[ReferenceStep]
|
||||||
|
|
||||||
|
|
||||||
|
def build(scenario: Scenario) -> ReferenceDialog:
|
||||||
|
facts = {fact.id: fact for fact in scenario.facts}
|
||||||
|
required = set(scenario.ground_truth.required_facts)
|
||||||
|
mood = BASE_MOOD.get(scenario.persona.base, Mood.CALM)
|
||||||
|
|
||||||
|
steps: list[ReferenceStep] = []
|
||||||
|
for item in scenario.checklist:
|
||||||
|
if not item.question:
|
||||||
|
continue
|
||||||
|
fact = facts.get(item.fact) if item.fact else None
|
||||||
|
steps.append(
|
||||||
|
ReferenceStep(
|
||||||
|
checklist_id=item.id,
|
||||||
|
question=item.question,
|
||||||
|
fact_id=fact.id if fact else None,
|
||||||
|
# Ответ звонящего — тем же шаблоном, каким он отвечает в живом
|
||||||
|
# звонке: эталон должен звучать так же, а не литературно.
|
||||||
|
answer=REVEAL[mood].format(fact=fact.value) if fact else None,
|
||||||
|
required=bool(fact and fact.id in required),
|
||||||
|
hidden=bool(fact and fact.hidden),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ReferenceDialog(scenario_id=scenario.id, first_line=scenario.first_line, steps=steps)
|
||||||
|
|
||||||
|
|
||||||
|
def missed_steps(scenario: Scenario, revealed: list[str] | None) -> list[ReferenceStep]:
|
||||||
|
"""Шаги, которые курсант не отработал. `None` — слот-автомата не было."""
|
||||||
|
if revealed is None:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
step
|
||||||
|
for step in build(scenario).steps
|
||||||
|
if step.fact_id and step.fact_id not in revealed and step.required
|
||||||
|
]
|
||||||
93
backend/app/scoring/report.py
Normal file
93
backend/app/scoring/report.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""Сборка отчёта сессии — единицы истории.
|
||||||
|
|
||||||
|
Из отчётов складываются профиль курсанта, дельта попыток и аналитика группы.
|
||||||
|
Ни одной отметки без обоснования: у каждой есть код, факт и норматив
|
||||||
|
(docs/product/DEBRIEF.md).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.domain.events import (
|
||||||
|
CompetencyScore,
|
||||||
|
HintShown,
|
||||||
|
HintUsage,
|
||||||
|
InstructorNoteShown,
|
||||||
|
Metric,
|
||||||
|
SelfAssessment,
|
||||||
|
SelfAssessmentDiff,
|
||||||
|
SessionReport,
|
||||||
|
)
|
||||||
|
from app.domain.taxonomy import Finding
|
||||||
|
from app.scenarios.schema import Scenario
|
||||||
|
from app.scoring.reference import build as build_reference
|
||||||
|
|
||||||
|
|
||||||
|
def assess_difference(
|
||||||
|
scenario: Scenario, revealed: list[str] | None, missed_claimed: list[str]
|
||||||
|
) -> SelfAssessmentDiff:
|
||||||
|
"""Сверить, что курсант считает пропущенным, с тем, что он пропустил на деле."""
|
||||||
|
reference = build_reference(scenario)
|
||||||
|
required = {step.checklist_id: step.fact_id for step in reference.steps if step.required}
|
||||||
|
really_missed = {
|
||||||
|
checklist_id
|
||||||
|
for checklist_id, fact_id in required.items()
|
||||||
|
if revealed is not None and fact_id not in revealed
|
||||||
|
}
|
||||||
|
claimed = set(missed_claimed)
|
||||||
|
return SelfAssessmentDiff(
|
||||||
|
noticed=sorted(claimed & really_missed),
|
||||||
|
unnoticed=sorted(really_missed - claimed),
|
||||||
|
overcautious=sorted(claimed - really_missed),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
reference = build_reference(scenario)
|
||||||
|
|
||||||
|
self_assessment = None
|
||||||
|
difference = None
|
||||||
|
if state.self_assessment is not None:
|
||||||
|
self_assessment = SelfAssessment(
|
||||||
|
missed=state.self_assessment["missed"],
|
||||||
|
comment=state.self_assessment.get("comment", ""),
|
||||||
|
submitted_at=state.ended_at or state.transcript[-1].at,
|
||||||
|
)
|
||||||
|
difference = assess_difference(scenario, revealed, self_assessment.missed)
|
||||||
|
|
||||||
|
questions = {step.checklist_id: step.question for step in reference.steps}
|
||||||
|
missed_checklist = [
|
||||||
|
step.checklist_id
|
||||||
|
for step in reference.steps
|
||||||
|
if step.required and revealed is not None and step.fact_id not in revealed
|
||||||
|
]
|
||||||
|
|
||||||
|
return SessionReport(
|
||||||
|
session_id=session_id,
|
||||||
|
scenario_id=scenario.id,
|
||||||
|
mode=state.mode,
|
||||||
|
attempt=state.attempt,
|
||||||
|
transcript=list(state.transcript),
|
||||||
|
findings=[Finding.model_validate(item) for item in score.get("findings", [])],
|
||||||
|
metrics=[Metric.model_validate(item) for item in score.get("metrics", [])],
|
||||||
|
competencies=[CompetencyScore.model_validate(item) for item in score.get("competencies", [])],
|
||||||
|
# Эталонные вопросы по шагам: по каждому пропущенному пункту видно,
|
||||||
|
# какой вопрос был правильным.
|
||||||
|
reference_questions=[
|
||||||
|
HintShown(checklist_id=step.checklist_id, question=step.question)
|
||||||
|
for step in reference.steps
|
||||||
|
],
|
||||||
|
missed_checklist=missed_checklist,
|
||||||
|
hints_used=[
|
||||||
|
HintUsage(checklist_id=checklist_id, question=questions.get(checklist_id, ""), at=at)
|
||||||
|
for checklist_id, at in state.hints_log
|
||||||
|
],
|
||||||
|
self_assessment=self_assessment,
|
||||||
|
self_assessment_diff=difference,
|
||||||
|
notes=[InstructorNoteShown.model_validate(note) for note in state.notes],
|
||||||
|
score_auto=score.get("score_auto", 0.0),
|
||||||
|
score_final=score.get("score_final", score.get("score_auto", 0.0)),
|
||||||
|
overridden_by=score.get("overridden_by"),
|
||||||
|
override_comment=score.get("override_comment"),
|
||||||
|
)
|
||||||
|
|
@ -19,6 +19,25 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
|
||||||
"required_fields": (ErrorCode.E5, Competency.CARD),
|
"required_fields": (ErrorCode.E5, Competency.CARD),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#: Вес метрики в детерминированной оценке.
|
||||||
|
#:
|
||||||
|
#: **Предварительные значения, требуют утверждения методистом.** Без весов все
|
||||||
|
#: метрики равны, и курсант, не задавший ни одного вопроса, но заполнивший
|
||||||
|
#: карточку руками, получает 87 из 100: «ответ за секунду» стоит столько же,
|
||||||
|
#: сколько «добыл все обязательные факты». Опрос — то, ради чего существует
|
||||||
|
#: тренажёр, поэтому он весит больше всего.
|
||||||
|
METRIC_WEIGHTS: dict[str, float] = {
|
||||||
|
"checklist_completeness": 4.0,
|
||||||
|
"incident_type": 2.0,
|
||||||
|
"dds_choice": 2.0,
|
||||||
|
"address": 2.0,
|
||||||
|
"required_fields": 2.0,
|
||||||
|
"interview_time": 1.5,
|
||||||
|
"victims_count": 1.0,
|
||||||
|
"answer_time": 1.0,
|
||||||
|
"callback": 1.0,
|
||||||
|
}
|
||||||
|
|
||||||
#: Вес детерминированного слоя в итоговой оценке. Остальное — LLM-судья
|
#: Вес детерминированного слоя в итоговой оценке. Остальное — LLM-судья
|
||||||
#: на мягкие критерии (E4), и не больше (docs/arch/BACKEND.md).
|
#: на мягкие критерии (E4), и не больше (docs/arch/BACKEND.md).
|
||||||
DETERMINISTIC_WEIGHT = 0.6
|
DETERMINISTIC_WEIGHT = 0.6
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ class SessionState:
|
||||||
transcript: list[TranscriptEntry] = field(default_factory=list)
|
transcript: list[TranscriptEntry] = field(default_factory=list)
|
||||||
timers: SessionTimers = field(default_factory=SessionTimers)
|
timers: SessionTimers = field(default_factory=SessionTimers)
|
||||||
hints_shown: list[str] = field(default_factory=list)
|
hints_shown: list[str] = field(default_factory=list)
|
||||||
|
#: Когда именно подсказывали — в разборе видно, какой пункт и на какой минуте.
|
||||||
|
hints_log: list[tuple[str, datetime]] = field(default_factory=list)
|
||||||
|
notes: list[dict] = field(default_factory=list)
|
||||||
directives: list[str] = field(default_factory=list)
|
directives: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
# Звонящий. Автомата нет, если не скачана модель эмбеддингов:
|
# Звонящий. Автомата нет, если не скачана модель эмбеддингов:
|
||||||
|
|
|
||||||
48
backend/tests/test_reference.py
Normal file
48
backend/tests/test_reference.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"""Эталонный диалог собирается из фактов, а не пишется руками."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.scenarios.loader import load_file
|
||||||
|
from app.scoring.reference import build, missed_steps
|
||||||
|
|
||||||
|
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def scenario():
|
||||||
|
return load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_checklist_question_becomes_a_step(scenario):
|
||||||
|
reference = build(scenario)
|
||||||
|
assert [step.checklist_id for step in reference.steps] == [
|
||||||
|
item.id for item in scenario.checklist if item.question
|
||||||
|
]
|
||||||
|
assert reference.first_line == scenario.first_line
|
||||||
|
|
||||||
|
|
||||||
|
def test_answers_come_from_facts_not_from_prose(scenario):
|
||||||
|
"""Эталон не может разойтись с фактами: ответы взяты из них же."""
|
||||||
|
reference = build(scenario)
|
||||||
|
address = next(step for step in reference.steps if step.fact_id == "f_address")
|
||||||
|
fact = next(fact for fact in scenario.facts if fact.id == "f_address")
|
||||||
|
assert fact.value in address.answer
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_without_a_fact_has_no_answer(scenario):
|
||||||
|
"""«Представьтесь» факта не добывает — и ответа в эталоне у него нет."""
|
||||||
|
caller = next(step for step in build(scenario).steps if step.checklist_id == "q_caller")
|
||||||
|
assert caller.fact_id is None and caller.answer is None
|
||||||
|
assert not caller.required
|
||||||
|
|
||||||
|
|
||||||
|
def test_missed_steps_are_only_the_required_ones(scenario):
|
||||||
|
missed = missed_steps(scenario, revealed=["f_address"])
|
||||||
|
assert [step.fact_id for step in missed] == ["f_what_burns", "f_people", "f_smoke", "f_gas"]
|
||||||
|
assert all(step.question for step in missed), "в разборе нужен текст эталонного вопроса"
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_slot_machine_nothing_is_declared_missed(scenario):
|
||||||
|
assert missed_steps(scenario, revealed=None) == []
|
||||||
|
|
@ -258,3 +258,52 @@ def test_checklist_is_closed_until_the_call_is_over(client):
|
||||||
after = client.get(f"/api/sessions/{session_id}/checklist")
|
after = client.get(f"/api/sessions/{session_id}/checklist")
|
||||||
assert after.status_code == 200
|
assert after.status_code == 200
|
||||||
assert all(item["question"] for item in after.json())
|
assert all(item["question"] for item in after.json())
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_shows_missed_questions_and_self_assessment_gap(client):
|
||||||
|
"""Главное в разборе: по пропущенному пункту виден эталонный вопрос,
|
||||||
|
а расхождение самооценки — отдельным блоком."""
|
||||||
|
with lesson(client) as (session_id, _):
|
||||||
|
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||||
|
trainee.send_json({"type": "call.answer"})
|
||||||
|
trainee.send_json({"type": "hint.request"})
|
||||||
|
trainee.send_json({"type": "kio.patch", "fields": {"address": "улица Ленина, 14", "dds": "01"}})
|
||||||
|
trainee.send_json({"type": "call.hangup"})
|
||||||
|
wait_for(lambda: hub.get(session_id).score is not None)
|
||||||
|
# Курсант считает, что пропустил только адрес — а не добыл он всё.
|
||||||
|
trainee.send_json({"type": "self_assessment.submit", "missed": ["q_address"], "comment": "торопился"})
|
||||||
|
wait_for(lambda: hub.get(session_id).self_assessed)
|
||||||
|
|
||||||
|
response = client.get(f"/api/sessions/{session_id}/report")
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
report = response.json()
|
||||||
|
|
||||||
|
assert report["missed_checklist"], "не добытые пункты должны быть перечислены"
|
||||||
|
questions = {item["checklist_id"]: item["question"] for item in report["reference_questions"]}
|
||||||
|
assert all(questions.get(item) for item in report["missed_checklist"]), "у пропущенного нет эталонного вопроса"
|
||||||
|
|
||||||
|
for finding in report["findings"]:
|
||||||
|
assert finding["code"] and finding["fact"], "отметка без обоснования"
|
||||||
|
|
||||||
|
diff = report["self_assessment_diff"]
|
||||||
|
assert "q_address" in diff["overcautious"] or "q_address" in diff["noticed"]
|
||||||
|
assert diff["unnoticed"], "курсант не заметил часть пропущенного — это и есть материал разбора"
|
||||||
|
assert report["hints_used"], "использованные подсказки попадают в разбор"
|
||||||
|
|
||||||
|
|
||||||
|
def test_instructor_correction_keeps_the_automatic_score(client):
|
||||||
|
with lesson(client) as (session_id, _):
|
||||||
|
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||||
|
trainee.send_json({"type": "call.answer"})
|
||||||
|
trainee.send_json({"type": "call.hangup"})
|
||||||
|
wait_for(lambda: hub.get(session_id).score is not None)
|
||||||
|
|
||||||
|
auto = client.get(f"/api/sessions/{session_id}/report").json()["score_auto"]
|
||||||
|
corrected = client.patch(
|
||||||
|
f"/api/sessions/{session_id}/report",
|
||||||
|
json={"score_final": 80.0, "comment": "связь рвалась не по вине курсанта"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
assert corrected["score_final"] == 80.0
|
||||||
|
assert corrected["score_auto"] == auto, "автооценка должна сохраниться рядом"
|
||||||
|
assert corrected["overridden_by"] == "преподаватель"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { createBrowserRouter, Navigate } from "react-router-dom";
|
import { createBrowserRouter, Navigate } from "react-router-dom";
|
||||||
|
|
||||||
import { Call } from "@/pages/trainee/Call";
|
import { Call } from "@/pages/trainee/Call";
|
||||||
|
import { Wall } from "@/pages/wall/Wall";
|
||||||
import { Stub } from "@/shared/ui/Stub";
|
import { Stub } from "@/shared/ui/Stub";
|
||||||
|
|
||||||
// Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md).
|
// Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md).
|
||||||
|
|
@ -9,6 +10,6 @@ export const router = createBrowserRouter([
|
||||||
{ path: "/", element: <Navigate to="/trainee" replace /> },
|
{ path: "/", element: <Navigate to="/trainee" replace /> },
|
||||||
{ path: "/trainee", element: <Call /> },
|
{ path: "/trainee", element: <Call /> },
|
||||||
{ path: "/instructor", element: <Stub title="Пульт преподавателя" card="lct-17" /> },
|
{ path: "/instructor", element: <Stub title="Пульт преподавателя" card="lct-17" /> },
|
||||||
{ path: "/wall", element: <Stub title="Внешний монитор" card="lct-16" /> },
|
{ path: "/wall", element: <Wall /> },
|
||||||
{ path: "/dds", element: <Stub title="АРМ диспетчера ДДС" card="lct-20" /> },
|
{ path: "/dds", element: <Stub title="АРМ диспетчера ДДС" card="lct-20" /> },
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,13 @@ import { EnergyGate } from "@/shared/audio/levels";
|
||||||
import { CALLER_RATE, Playback } from "@/shared/audio/playback";
|
import { CALLER_RATE, Playback } from "@/shared/audio/playback";
|
||||||
import { type CardState, applyPatch, edit, empty as emptyCard } from "@/features/kio-card/merge";
|
import { type CardState, applyPatch, edit, empty as emptyCard } from "@/features/kio-card/merge";
|
||||||
import type { ChecklistItem } from "@/features/self-assessment/SelfAssessment";
|
import type { ChecklistItem } from "@/features/self-assessment/SelfAssessment";
|
||||||
import type { CallIncoming, DDSCode, ServerToTrainee, TimerSnapshot } from "@/shared/types/generated";
|
import type {
|
||||||
|
CallIncoming,
|
||||||
|
DDSCode,
|
||||||
|
ServerToTrainee,
|
||||||
|
SessionReport,
|
||||||
|
TimerSnapshot,
|
||||||
|
} from "@/shared/types/generated";
|
||||||
|
|
||||||
/** Правки копятся и уходят одной дельтой: 300 мс тишины — и отправка. */
|
/** Правки копятся и уходят одной дельтой: 300 мс тишины — и отправка. */
|
||||||
const PATCH_DEBOUNCE_MS = 300;
|
const PATCH_DEBOUNCE_MS = 300;
|
||||||
|
|
@ -37,6 +43,7 @@ export function useCall(sessionId: string | null) {
|
||||||
const [checklist, setChecklist] = useState<ChecklistItem[]>([]);
|
const [checklist, setChecklist] = useState<ChecklistItem[]>([]);
|
||||||
const [selfAssessed, setSelfAssessed] = useState(false);
|
const [selfAssessed, setSelfAssessed] = useState(false);
|
||||||
const [scoreReady, setScoreReady] = useState(false);
|
const [scoreReady, setScoreReady] = useState(false);
|
||||||
|
const [report, setReport] = useState<SessionReport | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
|
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
|
||||||
|
|
@ -111,6 +118,10 @@ export function useCall(sessionId: string | null) {
|
||||||
break;
|
break;
|
||||||
case "score.ready":
|
case "score.ready":
|
||||||
setScoreReady(true);
|
setScoreReady(true);
|
||||||
|
void fetch(`/api/sessions/${sessionId}/report`)
|
||||||
|
.then((response) => (response.ok ? response.json() : null))
|
||||||
|
.then(setReport)
|
||||||
|
.catch(() => setReport(null));
|
||||||
break;
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
setError(event.message);
|
setError(event.message);
|
||||||
|
|
@ -182,7 +193,7 @@ export function useCall(sessionId: string | null) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status, phase, incoming, lines, timers, callerSpeaking, micOn, error, card,
|
status, phase, incoming, lines, timers, callerSpeaking, micOn, error, card,
|
||||||
checklist, selfAssessed, scoreReady,
|
checklist, selfAssessed, scoreReady, report,
|
||||||
answer, hangup, hint, patchKio, dispatch, submitSelfAssessment,
|
answer, hangup, hint, patchKio, dispatch, submitSelfAssessment,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
148
frontend/src/features/debrief/Debrief.tsx
Normal file
148
frontend/src/features/debrief/Debrief.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
// Разбор после звонка — главный учебный момент занятия.
|
||||||
|
//
|
||||||
|
// Ни одной отметки без обоснования: у каждой есть код, факт и норматив.
|
||||||
|
// По каждому пропущенному пункту видно, каким был эталонный вопрос
|
||||||
|
// (docs/product/DEBRIEF.md).
|
||||||
|
|
||||||
|
import type { SessionReport } from "@/shared/types/generated";
|
||||||
|
|
||||||
|
import { Radar } from "./Radar";
|
||||||
|
|
||||||
|
export function Debrief({ report, big = false }: { report: SessionReport; big?: boolean }) {
|
||||||
|
const questions = new Map(report.reference_questions.map((item) => [item.checklist_id, item.question]));
|
||||||
|
const diff = report.self_assessment_diff;
|
||||||
|
// Поля со значениями по умолчанию приходят из контракта необязательными.
|
||||||
|
const unnoticed = diff?.unnoticed ?? [];
|
||||||
|
const noticed = diff?.noticed ?? [];
|
||||||
|
const overcautious = diff?.overcautious ?? [];
|
||||||
|
const notes = report.notes ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={big ? "debrief debrief-big" : "debrief"}>
|
||||||
|
<h2>
|
||||||
|
Разбор · оценка {report.score_final.toFixed(0)} из 100
|
||||||
|
{report.overridden_by && ` · скорректировано (${report.overridden_by}), автооценка ${report.score_auto.toFixed(0)}`}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="columns">
|
||||||
|
<section>
|
||||||
|
<h3>Нормативы и факты</h3>
|
||||||
|
<table className="grid">
|
||||||
|
<tbody>
|
||||||
|
{report.metrics.map((metric) => (
|
||||||
|
<tr key={metric.key}>
|
||||||
|
<th>{metric.title}</th>
|
||||||
|
<td className={metric.passed ? "state-ok" : "state-violated"}>
|
||||||
|
{metric.fact} · норматив {metric.norm}
|
||||||
|
{metric.ref && <span className="ref"> ({metric.ref})</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Отметки</h3>
|
||||||
|
<table className="grid">
|
||||||
|
<tbody>
|
||||||
|
{report.findings.map((finding, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
<th>{finding.code}</th>
|
||||||
|
<td>
|
||||||
|
{finding.summary}
|
||||||
|
<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{report.findings.length === 0 && <tr><td colSpan={2}>ошибок не найдено</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Компетенции</h3>
|
||||||
|
<Radar values={report.competencies} size={big ? 320 : 220} />
|
||||||
|
|
||||||
|
{report.missed_checklist.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h3>Пропущенные вопросы</h3>
|
||||||
|
<table className="grid">
|
||||||
|
<tbody>
|
||||||
|
{report.missed_checklist.map((id) => (
|
||||||
|
<tr key={id}>
|
||||||
|
<th>{id}</th>
|
||||||
|
<td>эталонный вопрос: «{questions.get(id)}»</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{diff && (
|
||||||
|
<>
|
||||||
|
<h3>Самооценка</h3>
|
||||||
|
<table className="grid">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>Не заметил, что пропустил</th>
|
||||||
|
<td className={unnoticed.length ? "state-violated" : ""}>
|
||||||
|
{unnoticed.map((id) => questions.get(id) ?? id).join("; ") || "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Заметил сам</th>
|
||||||
|
<td>{noticed.map((id) => questions.get(id) ?? id).join("; ") || "—"}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Отметил зря — спрашивал</th>
|
||||||
|
<td>{overcautious.map((id) => questions.get(id) ?? id).join("; ") || "—"}</td>
|
||||||
|
</tr>
|
||||||
|
{report.self_assessment?.comment && (
|
||||||
|
<tr><th>Комментарий курсанта</th><td>{report.self_assessment.comment}</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report.hints_used.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h3>Использованные подсказки</h3>
|
||||||
|
<table className="grid">
|
||||||
|
<tbody>
|
||||||
|
{report.hints_used.map((hint, index) => (
|
||||||
|
<tr key={index}><th>{hint.checklist_id}</th><td>{hint.question}</td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Транскрипт</h3>
|
||||||
|
<table className="grid">
|
||||||
|
<tbody>
|
||||||
|
{report.transcript.map((line) => {
|
||||||
|
const marks = report.findings.filter((finding) => finding.transcript_ref === line.ref);
|
||||||
|
const note = notes.find((item) => item.transcript_ref === line.ref);
|
||||||
|
return (
|
||||||
|
<tr key={line.ref}>
|
||||||
|
<th>{line.speaker === "caller" ? "звонящий" : "оператор"}</th>
|
||||||
|
<td>
|
||||||
|
{line.text}
|
||||||
|
{marks.map((mark, index) => (
|
||||||
|
<div key={index} className="state-violated" title={`${mark.fact} · ${mark.norm ?? ""}`}>
|
||||||
|
{mark.code}: {mark.summary}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{note && <div className="ref">пометка преподавателя: {note.text}</div>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/features/debrief/Radar.tsx
Normal file
48
frontend/src/features/debrief/Radar.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
// Радар компетенций: проекция уже посчитанных метрик, без пересчёта весов.
|
||||||
|
// Преподаватель читает его за секунду: где провал — там тема следующего занятия
|
||||||
|
// (docs/product/METHODOLOGY.md).
|
||||||
|
|
||||||
|
import type { CompetencyScore } from "@/shared/types/generated";
|
||||||
|
|
||||||
|
export const COMPETENCY_LABELS: Record<string, string> = {
|
||||||
|
intake: "Приём вызова",
|
||||||
|
interview: "Опрос",
|
||||||
|
card: "Карточка",
|
||||||
|
routing: "Маршрутизация",
|
||||||
|
norms: "Нормативы",
|
||||||
|
communication: "Коммуникация",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Radar({ values, size = 220 }: { values: CompetencyScore[]; size?: number }) {
|
||||||
|
if (values.length < 3) return null;
|
||||||
|
const center = size / 2;
|
||||||
|
const radius = center - 28;
|
||||||
|
|
||||||
|
const point = (index: number, value: number) => {
|
||||||
|
const angle = (Math.PI * 2 * index) / values.length - Math.PI / 2;
|
||||||
|
return [center + radius * value * Math.cos(angle), center + radius * value * Math.sin(angle)];
|
||||||
|
};
|
||||||
|
const polygon = values.map((item, index) => point(index, item.value).join(",")).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} className="radar" role="img" aria-label="радар компетенций">
|
||||||
|
{[0.25, 0.5, 0.75, 1].map((ring) => (
|
||||||
|
<polygon
|
||||||
|
key={ring}
|
||||||
|
points={values.map((_, index) => point(index, ring).join(",")).join(" ")}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--line)"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<polygon points={polygon} fill="rgba(31,111,61,0.18)" stroke="var(--ok)" strokeWidth={2} />
|
||||||
|
{values.map((item, index) => {
|
||||||
|
const [x, y] = point(index, 1.16);
|
||||||
|
return (
|
||||||
|
<text key={item.competency} x={x} y={y} textAnchor="middle" fontSize={10}>
|
||||||
|
{COMPETENCY_LABELS[item.competency] ?? item.competency}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
frontend/src/features/observe/useObserve.ts
Normal file
98
frontend/src/features/observe/useObserve.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
// Наблюдатель: внешний монитор и пульт преподавателя.
|
||||||
|
//
|
||||||
|
// Снимок при подключении обязателен — монитор в классе включают посреди занятия,
|
||||||
|
// и он должен показать текущее состояние, а не ждать событий. Отправлять в этот
|
||||||
|
// канал нечего и нельзя: тип `Out` — `never` (docs/arch/CONTRACT.md).
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { type CardState, applyState, empty as emptyCard } from "@/features/kio-card/merge";
|
||||||
|
import { observeChannel, type ChannelStatus } from "@/shared/api/ws";
|
||||||
|
import type {
|
||||||
|
SessionMode,
|
||||||
|
SessionReport,
|
||||||
|
TimerSnapshot,
|
||||||
|
TranscriptEntry,
|
||||||
|
} from "@/shared/types/generated";
|
||||||
|
|
||||||
|
export interface ObservedSession {
|
||||||
|
status: ChannelStatus;
|
||||||
|
title: string | null;
|
||||||
|
mode: SessionMode | undefined;
|
||||||
|
trainee: string | null;
|
||||||
|
card: CardState;
|
||||||
|
requiredFields: string[];
|
||||||
|
transcript: TranscriptEntry[];
|
||||||
|
timers: TimerSnapshot[];
|
||||||
|
hintsUsed: number;
|
||||||
|
notes: Map<string, string>;
|
||||||
|
ended: boolean;
|
||||||
|
report: SessionReport | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useObserve(sessionId: string | null): ObservedSession {
|
||||||
|
const [status, setStatus] = useState<ChannelStatus>("connecting");
|
||||||
|
const [title, setTitle] = useState<string | null>(null);
|
||||||
|
const [mode, setMode] = useState<SessionMode | undefined>(undefined);
|
||||||
|
const [trainee, setTrainee] = useState<string | null>(null);
|
||||||
|
const [card, setCard] = useState<CardState>(emptyCard);
|
||||||
|
const [requiredFields, setRequiredFields] = useState<string[]>([]);
|
||||||
|
const [transcript, setTranscript] = useState<TranscriptEntry[]>([]);
|
||||||
|
const [timers, setTimers] = useState<TimerSnapshot[]>([]);
|
||||||
|
const [hintsUsed, setHintsUsed] = useState(0);
|
||||||
|
const [notes, setNotes] = useState<Map<string, string>>(new Map());
|
||||||
|
const [ended, setEnded] = useState(false);
|
||||||
|
const [report, setReport] = useState<SessionReport | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
const channel = observeChannel(sessionId, {
|
||||||
|
onStatus: setStatus,
|
||||||
|
onEvent: (event) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case "session.snapshot":
|
||||||
|
setTitle(event.scenario_title);
|
||||||
|
setMode(event.mode);
|
||||||
|
setTrainee(event.trainee_name ?? null);
|
||||||
|
setCard((prev) => applyState(prev, event.kio as unknown as Record<string, unknown>));
|
||||||
|
setRequiredFields(event.required_fields ?? []);
|
||||||
|
setTranscript(event.transcript);
|
||||||
|
setTimers(event.timers);
|
||||||
|
setHintsUsed(event.hints_used ?? 0);
|
||||||
|
setEnded(event.ended ?? false);
|
||||||
|
break;
|
||||||
|
case "kio.state":
|
||||||
|
setCard((prev) => applyState(prev, event.kio as unknown as Record<string, unknown>));
|
||||||
|
break;
|
||||||
|
case "transcript.append":
|
||||||
|
setTranscript((prev) => [...prev, event.entry]);
|
||||||
|
break;
|
||||||
|
case "timer.tick":
|
||||||
|
setTimers(event.timers);
|
||||||
|
break;
|
||||||
|
case "mode.set":
|
||||||
|
setMode(event.mode);
|
||||||
|
break;
|
||||||
|
case "hint.shown":
|
||||||
|
setHintsUsed((count) => count + 1);
|
||||||
|
break;
|
||||||
|
case "instructor_note.shown":
|
||||||
|
setNotes((prev) => new Map(prev).set(event.transcript_ref, event.text));
|
||||||
|
break;
|
||||||
|
case "session.ended":
|
||||||
|
setEnded(true);
|
||||||
|
break;
|
||||||
|
case "score.ready":
|
||||||
|
void fetch(`/api/sessions/${sessionId}/report`)
|
||||||
|
.then((response) => (response.ok ? response.json() : null))
|
||||||
|
.then(setReport)
|
||||||
|
.catch(() => setReport(null));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}).connect();
|
||||||
|
return () => channel.close();
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, report };
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
import { KioCard } from "@/features/kio-card/KioCard";
|
import { KioCard } from "@/features/kio-card/KioCard";
|
||||||
import { ModeBanner, hintsAllowed } from "@/features/mode-banner/ModeBanner";
|
import { ModeBanner, hintsAllowed } from "@/features/mode-banner/ModeBanner";
|
||||||
|
import { Debrief } from "@/features/debrief/Debrief";
|
||||||
import { SelfAssessment } from "@/features/self-assessment/SelfAssessment";
|
import { SelfAssessment } from "@/features/self-assessment/SelfAssessment";
|
||||||
import { InterviewTimer, Timers } from "@/features/timers/Timers";
|
import { InterviewTimer, Timers } from "@/features/timers/Timers";
|
||||||
import { display } from "@/features/kio-card/merge";
|
import { display } from "@/features/kio-card/merge";
|
||||||
|
|
@ -58,9 +59,8 @@ export function Call() {
|
||||||
{call.phase === "ended" && !call.selfAssessed && call.checklist.length > 0 && (
|
{call.phase === "ended" && !call.selfAssessed && call.checklist.length > 0 && (
|
||||||
<SelfAssessment checklist={call.checklist} onSubmit={call.submitSelfAssessment} />
|
<SelfAssessment checklist={call.checklist} onSubmit={call.submitSelfAssessment} />
|
||||||
)}
|
)}
|
||||||
{call.selfAssessed && (
|
{call.selfAssessed && !call.report && <p>Самооценка принята, оценка считается…</p>}
|
||||||
<p>{call.scoreReady ? "Оценка готова — разбор открывает преподаватель." : "Самооценка принята, оценка считается…"}</p>
|
{call.report && <Debrief report={call.report} />}
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="columns">
|
<div className="columns">
|
||||||
<section>
|
<section>
|
||||||
|
|
|
||||||
74
frontend/src/pages/wall/Wall.tsx
Normal file
74
frontend/src/pages/wall/Wall.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// Внешний монитор: группа смотрит, как идёт занятие, и разбирает вместе с ним.
|
||||||
|
//
|
||||||
|
// Это не отдельное приложение, а другой режим отрисовки тех же событий: крупный
|
||||||
|
// шрифт, высокий контраст, минимум деталей. Никакого взаимодействия — экран висит
|
||||||
|
// и показывает (docs/arch/FRONTEND.md).
|
||||||
|
|
||||||
|
import { Debrief } from "@/features/debrief/Debrief";
|
||||||
|
import { KioCard } from "@/features/kio-card/KioCard";
|
||||||
|
import { ModeBanner } from "@/features/mode-banner/ModeBanner";
|
||||||
|
import { InterviewTimer } from "@/features/timers/Timers";
|
||||||
|
import { useObserve } from "@/features/observe/useObserve";
|
||||||
|
import { sessionIdFromUrl } from "@/shared/api/session";
|
||||||
|
|
||||||
|
export function Wall() {
|
||||||
|
const sessionId = sessionIdFromUrl();
|
||||||
|
const session = useObserve(sessionId);
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return (
|
||||||
|
<main className="page wall">
|
||||||
|
<h1>Внешний монитор</h1>
|
||||||
|
<p className="warn">Откройте ссылку вида /wall?session=…</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.report) {
|
||||||
|
return (
|
||||||
|
<main className="page wall">
|
||||||
|
<ModeBanner mode={session.mode} />
|
||||||
|
<h1>{session.title} · разбор</h1>
|
||||||
|
<Debrief report={session.report} big />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page wall">
|
||||||
|
<ModeBanner mode={session.mode} />
|
||||||
|
<h1>
|
||||||
|
{session.title ?? "Ожидание занятия"}
|
||||||
|
{session.trainee && ` · ${session.trainee}`}
|
||||||
|
</h1>
|
||||||
|
<InterviewTimer timers={session.timers} big />
|
||||||
|
|
||||||
|
<div className="columns">
|
||||||
|
<section>
|
||||||
|
<h2>Ход разговора</h2>
|
||||||
|
<table className="grid">
|
||||||
|
<tbody>
|
||||||
|
{session.transcript.slice(-12).map((line) => (
|
||||||
|
<tr key={line.ref}>
|
||||||
|
<th>{line.speaker === "caller" ? "звонящий" : "оператор"}</th>
|
||||||
|
<td>
|
||||||
|
{line.text}
|
||||||
|
{session.notes.has(line.ref) && (
|
||||||
|
<div className="ref">пометка: {session.notes.get(line.ref)}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{session.transcript.length === 0 && <tr><td colSpan={2}>—</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p className="ref">подсказок использовано: {session.hintsUsed}</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Карточка</h2>
|
||||||
|
<KioCard state={session.card} required={session.requiredFields} readOnly />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -338,6 +338,13 @@ export interface SelfAssessment {
|
||||||
submitted_at: string;
|
submitted_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Расхождение самооценки с автооценкой — отдельный материал для преподавателя. Курсант, не заметивший, что пропустил вопрос о пострадавших, — более важный случай, чем сама ошибка (docs/product/DEBRIEF.md). */
|
||||||
|
export interface SelfAssessmentDiff {
|
||||||
|
noticed?: Array<string>;
|
||||||
|
unnoticed?: Array<string>;
|
||||||
|
overcautious?: Array<string>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Самооценка до показа автооценки. */
|
/** Самооценка до показа автооценки. */
|
||||||
export interface SelfAssessmentSubmit {
|
export interface SelfAssessmentSubmit {
|
||||||
type: "self_assessment.submit";
|
type: "self_assessment.submit";
|
||||||
|
|
@ -367,6 +374,8 @@ export interface SessionReport {
|
||||||
missed_checklist: Array<string>;
|
missed_checklist: Array<string>;
|
||||||
hints_used: Array<HintUsage>;
|
hints_used: Array<HintUsage>;
|
||||||
self_assessment?: SelfAssessment | null;
|
self_assessment?: SelfAssessment | null;
|
||||||
|
self_assessment_diff?: SelfAssessmentDiff | null;
|
||||||
|
notes?: Array<InstructorNoteShown>;
|
||||||
score_auto: number;
|
score_auto: number;
|
||||||
score_final: number;
|
score_final: number;
|
||||||
overridden_by?: string | null;
|
overridden_by?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -69,3 +69,16 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||||
.mode-training { background: #e8f0fb; }
|
.mode-training { background: #e8f0fb; }
|
||||||
.mode-exam { background: #fde8e8; }
|
.mode-exam { background: #fde8e8; }
|
||||||
.mode-self { background: #eef3ea; }
|
.mode-self { background: #eef3ea; }
|
||||||
|
|
||||||
|
/* Разбор. Ссылка на норматив — мелким, но рядом с фактом: отметка без
|
||||||
|
обоснования не появляется ни в одном интерфейсе. */
|
||||||
|
.debrief h3 { font-size: 15px; margin: 16px 0 6px; }
|
||||||
|
.ref { color: #5a6068; font-size: 12px; }
|
||||||
|
.radar { display: block; margin: 4px 0 8px; }
|
||||||
|
|
||||||
|
/* Внешний монитор: читается с последнего ряда. */
|
||||||
|
.wall { font-size: 20px; }
|
||||||
|
.wall h1 { font-size: 30px; }
|
||||||
|
.wall h2 { font-size: 24px; }
|
||||||
|
.wall .grid th { width: 160px; }
|
||||||
|
.debrief-big { font-size: 20px; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue