lct-35: преподаватель подтверждает, снимает и добавляет отметки D1–D6 с пересчётом балла
This commit is contained in:
parent
b4420cea16
commit
f98c464f4b
21 changed files with 923 additions and 21 deletions
|
|
@ -24,15 +24,31 @@ from app.db.models import Group, Score, Session, Trainee
|
||||||
from app.domain.events import Exercise, SessionMode, SessionReport
|
from app.domain.events import Exercise, SessionMode, SessionReport
|
||||||
from app.domain.roles import Role
|
from app.domain.roles import Role
|
||||||
from app.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current
|
from app.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current
|
||||||
|
from app.domain.taxonomy import (
|
||||||
|
ERRORS,
|
||||||
|
ErrorCode,
|
||||||
|
Finding,
|
||||||
|
FindingDecision,
|
||||||
|
FindingReview,
|
||||||
|
FindingSource,
|
||||||
|
)
|
||||||
from app.domain.timers import TimerCode
|
from app.domain.timers import TimerCode
|
||||||
from app.scoring.export import to_csv, to_pdf
|
from app.scoring.export import to_csv, to_pdf
|
||||||
from app.scoring.report import build as build_report
|
from app.scoring.report import build as build_report
|
||||||
|
from app.scoring.taxonomy import METRIC_MAP
|
||||||
from app.session.access import can_access
|
from app.session.access import can_access
|
||||||
from app.session.checkpoint import load_state
|
from app.session.checkpoint import load_state
|
||||||
from app.session.finish import override_score
|
from app.session.finish import change_findings, override_score
|
||||||
from app.session.score import scoring_scenario
|
from app.session.score import scoring_scenario
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
from app.session.store import ScoreOverridden, apply_score_override
|
from app.session.state import now_utc
|
||||||
|
from app.session.store import (
|
||||||
|
FindingAdded,
|
||||||
|
FindingReviewed,
|
||||||
|
ScoreOverridden,
|
||||||
|
apply_finding_change,
|
||||||
|
apply_score_override,
|
||||||
|
)
|
||||||
from app.voice.recording import recording_path
|
from app.voice.recording import recording_path
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
||||||
|
|
@ -584,6 +600,133 @@ async def override(
|
||||||
return SessionReport.model_validate(report["full_report"])
|
return SessionReport.model_validate(report["full_report"])
|
||||||
|
|
||||||
|
|
||||||
|
def _required_text(value: str) -> str:
|
||||||
|
cleaned = value.strip()
|
||||||
|
if not cleaned:
|
||||||
|
raise ValueError("поле обязательно")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
class FindingReviewIn(BaseModel):
|
||||||
|
"""Решение по отметке: причина обязательна и остаётся в разборе."""
|
||||||
|
|
||||||
|
decision: FindingDecision
|
||||||
|
reason: str = Field(min_length=1, max_length=1000)
|
||||||
|
|
||||||
|
_reason = field_validator("reason")(_required_text)
|
||||||
|
|
||||||
|
|
||||||
|
class FindingIn(BaseModel):
|
||||||
|
"""Отметка преподавателя: код, факт и норма — то же обоснование, что у автоматической."""
|
||||||
|
|
||||||
|
code: ErrorCode
|
||||||
|
fact: str = Field(min_length=1, max_length=1000)
|
||||||
|
norm: str = Field(min_length=1, max_length=1000)
|
||||||
|
#: Номер карточки очереди ДДС (с 1); без него отметка относится к занятию.
|
||||||
|
card: int | None = Field(default=None, ge=1)
|
||||||
|
|
||||||
|
_fact = field_validator("fact")(_required_text)
|
||||||
|
_norm = field_validator("norm")(_required_text)
|
||||||
|
|
||||||
|
|
||||||
|
async def _change_findings(
|
||||||
|
session_id: UUID, who, db: AsyncSession | None, make,
|
||||||
|
) -> SessionReport:
|
||||||
|
"""Общий путь решений по отметкам — тот же, что у правки итога.
|
||||||
|
|
||||||
|
`make(who, score)` строит запись изменения по сохранённой оценке или
|
||||||
|
отвечает HTTP-ошибкой. Роль проверяет маршрут: администратору сюда нельзя,
|
||||||
|
как и к правке балла.
|
||||||
|
"""
|
||||||
|
state = hub.get(session_id)
|
||||||
|
if state is not None:
|
||||||
|
_require_access(who, state)
|
||||||
|
if state.score is None:
|
||||||
|
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||||
|
scenario = scoring_scenario(state)
|
||||||
|
if scenario is None:
|
||||||
|
raise HTTPException(status_code=409, detail="scenario_not_found")
|
||||||
|
change = make(who, state.score)
|
||||||
|
async with hub.operation(session_id):
|
||||||
|
change_findings(state, change)
|
||||||
|
return build_report(session_id, state, scenario)
|
||||||
|
|
||||||
|
if db is None:
|
||||||
|
raise HTTPException(status_code=404, detail="session_not_found")
|
||||||
|
session = await repo.get_session(db, session_id)
|
||||||
|
if session is None:
|
||||||
|
raise HTTPException(status_code=404, detail="session_not_found")
|
||||||
|
_require_access(who, session)
|
||||||
|
score = await db.scalar(select(Score).where(Score.session_id == session_id))
|
||||||
|
if score is None:
|
||||||
|
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||||
|
if (score.report or {}).get("full_report") is None:
|
||||||
|
raise HTTPException(status_code=409, detail="report_not_archived")
|
||||||
|
change = make(who, dict(score.report))
|
||||||
|
report = apply_finding_change(dict(score.report), change)
|
||||||
|
await db.rollback() # чтение закончено; запись — одной транзакцией хранилища
|
||||||
|
await hub.store.commit_archived(session_id, [change])
|
||||||
|
return SessionReport.model_validate(report["full_report"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{session_id}/findings/{index}/review", response_model=SessionReport)
|
||||||
|
async def review_finding(
|
||||||
|
session_id: UUID,
|
||||||
|
index: int,
|
||||||
|
body: FindingReviewIn,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession | None = Depends(optional_session),
|
||||||
|
) -> SessionReport:
|
||||||
|
"""Подтвердить или снять отметку; снятая не штрафует свою метрику."""
|
||||||
|
who = require(request, Role.INSTRUCTOR)
|
||||||
|
|
||||||
|
def make(who, score: dict) -> FindingReviewed:
|
||||||
|
if not 0 <= index < len(score.get("findings", [])):
|
||||||
|
raise HTTPException(status_code=404, detail="finding_not_found")
|
||||||
|
return FindingReviewed(index=index, role=who.role.value, review=FindingReview(
|
||||||
|
decision=body.decision, reason=body.reason, author=who.login, at=now_utc(),
|
||||||
|
))
|
||||||
|
|
||||||
|
return await _change_findings(session_id, who, db, make)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{session_id}/findings", response_model=SessionReport)
|
||||||
|
async def add_finding(
|
||||||
|
session_id: UUID,
|
||||||
|
body: FindingIn,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession | None = Depends(optional_session),
|
||||||
|
) -> SessionReport:
|
||||||
|
"""Отметка преподавателя штрафует связанную метрику по той же методике."""
|
||||||
|
who = require(request, Role.INSTRUCTOR)
|
||||||
|
|
||||||
|
def make(who, score: dict) -> FindingAdded:
|
||||||
|
service = None
|
||||||
|
prefix = ""
|
||||||
|
if body.card is not None:
|
||||||
|
cards = score.get("card_results") or []
|
||||||
|
if body.card > len(cards):
|
||||||
|
raise HTTPException(status_code=422, detail="card_not_found")
|
||||||
|
service = cards[body.card - 1].get("managed_service")
|
||||||
|
prefix = f"Карточка {body.card}: " + (f"{service}: " if service else "")
|
||||||
|
competency = next((item for code, item in METRIC_MAP.values() if code is body.code), None)
|
||||||
|
return FindingAdded(role=who.role.value, finding=Finding(
|
||||||
|
code=body.code,
|
||||||
|
source=FindingSource.INSTRUCTOR,
|
||||||
|
summary=f"{prefix}{ERRORS[body.code].title}",
|
||||||
|
fact=body.fact,
|
||||||
|
norm=body.norm,
|
||||||
|
ref="отметка преподавателя на разборе",
|
||||||
|
competency=competency,
|
||||||
|
at=now_utc(),
|
||||||
|
card=body.card,
|
||||||
|
service=service,
|
||||||
|
author=who.login,
|
||||||
|
))
|
||||||
|
|
||||||
|
return await _change_findings(session_id, who, db, make)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[SessionOut])
|
@router.get("", response_model=list[SessionOut])
|
||||||
async def listing(
|
async def listing(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
|
||||||
|
|
@ -692,6 +692,12 @@ class Metric(BaseModel):
|
||||||
passed: bool
|
passed: bool
|
||||||
weight: float = 1.0
|
weight: float = 1.0
|
||||||
credit: float | None = Field(default=None, ge=0, le=1)
|
credit: float | None = Field(default=None, ge=0, le=1)
|
||||||
|
#: Номер карточки очереди ДДС (с 1) и служба — область метрики для разбора.
|
||||||
|
card: int | None = None
|
||||||
|
service: str | None = None
|
||||||
|
#: Автоматический вердикт, если его изменило решение преподавателя по отметке.
|
||||||
|
passed_auto: bool | None = None
|
||||||
|
credit_auto: float | None = Field(default=None, ge=0, le=1)
|
||||||
|
|
||||||
|
|
||||||
class CompetencyScore(BaseModel):
|
class CompetencyScore(BaseModel):
|
||||||
|
|
@ -780,6 +786,8 @@ class SessionReport(BaseModel):
|
||||||
self_assessment_diff: SelfAssessmentDiff | None = None
|
self_assessment_diff: SelfAssessmentDiff | None = None
|
||||||
notes: list[InstructorNoteShown] = []
|
notes: list[InstructorNoteShown] = []
|
||||||
score_auto: float
|
score_auto: float
|
||||||
|
#: Балл после решений преподавателя по отметкам; None — решений не было.
|
||||||
|
score_reviewed: float | None = None
|
||||||
score_final: float
|
score_final: float
|
||||||
overridden_by: str | None = None
|
overridden_by: str | None = None
|
||||||
override_comment: str | None = None
|
override_comment: str | None = None
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,22 @@ COMPETENCY_LABELS: dict[Competency, str] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FindingDecision(StrEnum):
|
||||||
|
"""Решение преподавателя по отметке на разборе."""
|
||||||
|
|
||||||
|
CONFIRMED = "confirmed"
|
||||||
|
DISMISSED = "dismissed"
|
||||||
|
|
||||||
|
|
||||||
|
class FindingReview(BaseModel):
|
||||||
|
"""Кто, когда и почему подтвердил или снял отметку. Сама отметка остаётся."""
|
||||||
|
|
||||||
|
decision: FindingDecision
|
||||||
|
reason: str
|
||||||
|
author: str
|
||||||
|
at: datetime
|
||||||
|
|
||||||
|
|
||||||
class Finding(BaseModel):
|
class Finding(BaseModel):
|
||||||
"""Отметка в разборе. `fact` и `norm` — то самое обоснование."""
|
"""Отметка в разборе. `fact` и `norm` — то самое обоснование."""
|
||||||
|
|
||||||
|
|
@ -164,3 +180,12 @@ class Finding(BaseModel):
|
||||||
competency: Competency | None = None
|
competency: Competency | None = None
|
||||||
transcript_ref: str | None = None
|
transcript_ref: str | None = None
|
||||||
at: datetime | None = None
|
at: datetime | None = None
|
||||||
|
#: Метрика, чей провал объясняет отметка. Без неё связь с метриками идёт
|
||||||
|
#: по методике METRIC_MAP в пределах той же карточки и службы.
|
||||||
|
metric_key: str | None = None
|
||||||
|
#: Номер карточки очереди ДДС (с 1) и служба, к которым относится отметка.
|
||||||
|
card: int | None = None
|
||||||
|
service: str | None = None
|
||||||
|
#: Автор ручной отметки преподавателя (`source=instructor`).
|
||||||
|
author: str | None = None
|
||||||
|
review: FindingReview | None = None
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ def evaluate_card(scenario: Scenario, kio: KIO) -> GostResult:
|
||||||
source=FindingSource.KIO if code.value == "E5" else FindingSource.GROUND_TRUTH,
|
source=FindingSource.KIO if code.value == "E5" else FindingSource.GROUND_TRUTH,
|
||||||
summary=f"{title}: {fact}; требуется {norm}",
|
summary=f"{title}: {fact}; требуется {norm}",
|
||||||
fact=fact, norm=norm, ref=ref, competency=competency,
|
fact=fact, norm=norm, ref=ref, competency=competency,
|
||||||
|
metric_key=key,
|
||||||
))
|
))
|
||||||
|
|
||||||
if truth.incident_code:
|
if truth.incident_code:
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,10 @@ from app.domain.timers import GOST_REF
|
||||||
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
|
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
|
||||||
|
|
||||||
|
|
||||||
def _finding(code: ErrorCode, summary: str, fact: str, norm: str | None = None) -> Finding:
|
def _finding(service: str, code: ErrorCode, summary: str, fact: str,
|
||||||
|
norm: str | None = None) -> Finding:
|
||||||
return Finding(
|
return Finding(
|
||||||
|
service=service,
|
||||||
code=code,
|
code=code,
|
||||||
source=FindingSource.DISPATCHER,
|
source=FindingSource.DISPATCHER,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
|
|
@ -117,7 +119,7 @@ def evaluate_dispatcher(
|
||||||
]
|
]
|
||||||
if missing_comment_statuses:
|
if missing_comment_statuses:
|
||||||
findings.append(_finding(
|
findings.append(_finding(
|
||||||
ErrorCode.D5,
|
service, ErrorCode.D5,
|
||||||
f"{service}: к статусу не добавлены основание и сведения",
|
f"{service}: к статусу не добавлены основание и сведения",
|
||||||
fact="не заполнены комментарии: " + ", ".join(missing_comment_statuses),
|
fact="не заполнены комментарии: " + ", ".join(missing_comment_statuses),
|
||||||
norm="к каждой ручной отметке добавить основание и содержание полученных сведений",
|
norm="к каждой ручной отметке добавить основание и содержание полученных сведений",
|
||||||
|
|
@ -132,7 +134,7 @@ def evaluate_dispatcher(
|
||||||
]
|
]
|
||||||
if incomplete_comment_statuses:
|
if incomplete_comment_statuses:
|
||||||
findings.append(_finding(
|
findings.append(_finding(
|
||||||
ErrorCode.D5,
|
service, ErrorCode.D5,
|
||||||
f"{service}: комментарии к статусам не разделяют основание и сведения",
|
f"{service}: комментарии к статусам не разделяют основание и сведения",
|
||||||
fact="неполные комментарии: " + ", ".join(incomplete_comment_statuses),
|
fact="неполные комментарии: " + ", ".join(incomplete_comment_statuses),
|
||||||
norm="в каждом комментарии заполнить отдельные поля «Основание» и «Сведения»",
|
norm="в каждом комментарии заполнить отдельные поля «Основание» и «Сведения»",
|
||||||
|
|
@ -152,7 +154,7 @@ def evaluate_dispatcher(
|
||||||
]
|
]
|
||||||
if missing_recipients:
|
if missing_recipients:
|
||||||
findings.append(_finding(
|
findings.append(_finding(
|
||||||
ErrorCode.D5,
|
service, ErrorCode.D5,
|
||||||
f"{service}: отдельный комментарий не указывает получателя сведений",
|
f"{service}: отдельный комментарий не указывает получателя сведений",
|
||||||
fact="; ".join(missing_recipients)[:500],
|
fact="; ".join(missing_recipients)[:500],
|
||||||
norm=(
|
norm=(
|
||||||
|
|
@ -166,7 +168,7 @@ def evaluate_dispatcher(
|
||||||
if primary is None:
|
if primary is None:
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
ErrorCode.D1,
|
service, ErrorCode.D1,
|
||||||
f"{service}: статус реагирования не проставлен",
|
f"{service}: статус реагирования не проставлен",
|
||||||
fact=(
|
fact=(
|
||||||
f"прошло {elapsed_ms // 1000} с"
|
f"прошло {elapsed_ms // 1000} с"
|
||||||
|
|
@ -181,7 +183,7 @@ def evaluate_dispatcher(
|
||||||
if elapsed_ms is None or elapsed_ms > deadline_ms:
|
if elapsed_ms is None or elapsed_ms > deadline_ms:
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
ErrorCode.D1,
|
service, ErrorCode.D1,
|
||||||
f"{service}: первичный статус проставлен с нарушением срока",
|
f"{service}: первичный статус проставлен с нарушением срока",
|
||||||
fact=(
|
fact=(
|
||||||
f"прошло {elapsed_ms // 1000} с"
|
f"прошло {elapsed_ms // 1000} с"
|
||||||
|
|
@ -197,7 +199,7 @@ def evaluate_dispatcher(
|
||||||
if mark.status in REFUSAL_COMMENT_REQUIRED and not mark.comment.strip():
|
if mark.status in REFUSAL_COMMENT_REQUIRED and not mark.comment.strip():
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
ErrorCode.D4,
|
service, ErrorCode.D4,
|
||||||
f"{service}: «{SERVICE_STATUS_LABELS[mark.status]}» без комментария",
|
f"{service}: «{SERVICE_STATUS_LABELS[mark.status]}» без комментария",
|
||||||
fact="причина отказа не указана",
|
fact="причина отказа не указана",
|
||||||
norm="комментарий к отказу обязателен",
|
norm="комментарий к отказу обязателен",
|
||||||
|
|
@ -209,7 +211,7 @@ def evaluate_dispatcher(
|
||||||
if latest is ServiceStatus.DECLINED and expected_decision == "accept":
|
if latest is ServiceStatus.DECLINED and expected_decision == "accept":
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
ErrorCode.D3,
|
service, ErrorCode.D3,
|
||||||
f"{service}: отказ от происшествия, которое в её компетенции",
|
f"{service}: отказ от происшествия, которое в её компетенции",
|
||||||
fact="служба есть в списке оповещения по ЕКП",
|
fact="служба есть в списке оповещения по ЕКП",
|
||||||
norm="принять согласно эталону сценария",
|
norm="принять согласно эталону сценария",
|
||||||
|
|
@ -218,7 +220,7 @@ def evaluate_dispatcher(
|
||||||
elif latest is ServiceStatus.ACCEPTED and expected_decision == "decline":
|
elif latest is ServiceStatus.ACCEPTED and expected_decision == "decline":
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
ErrorCode.D2,
|
service, ErrorCode.D2,
|
||||||
f"{service}: принято вопреки эталону сценария",
|
f"{service}: принято вопреки эталону сценария",
|
||||||
fact="карточка принята службой",
|
fact="карточка принята службой",
|
||||||
norm=expected_decision_reason or "отказать с указанной причиной",
|
norm=expected_decision_reason or "отказать с указанной причиной",
|
||||||
|
|
@ -232,7 +234,7 @@ def evaluate_dispatcher(
|
||||||
if service not in crew_assignments:
|
if service not in crew_assignments:
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
ErrorCode.D2,
|
service, ErrorCode.D2,
|
||||||
f"{service}: «Принята» без назначения бригады и хода реагирования",
|
f"{service}: «Принята» без назначения бригады и хода реагирования",
|
||||||
fact="бригада не выбрана, после приёма статусов не было",
|
fact="бригада не выбрана, после приёма статусов не было",
|
||||||
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
|
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
|
||||||
|
|
@ -241,7 +243,7 @@ def evaluate_dispatcher(
|
||||||
else:
|
else:
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
ErrorCode.D2,
|
service, ErrorCode.D2,
|
||||||
f"{service}: «Принята», но о реагировании ничего не отмечено",
|
f"{service}: «Принята», но о реагировании ничего не отмечено",
|
||||||
fact="после приёма статусов не было",
|
fact="после приёма статусов не было",
|
||||||
norm="статус должен соответствовать фактическому состоянию заявки",
|
norm="статус должен соответствовать фактическому состоянию заявки",
|
||||||
|
|
@ -252,7 +254,7 @@ def evaluate_dispatcher(
|
||||||
) and service not in crew_assignments:
|
) and service not in crew_assignments:
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
ErrorCode.D2,
|
service, ErrorCode.D2,
|
||||||
f"{service}: ход реагирования без назначения бригады",
|
f"{service}: ход реагирования без назначения бригады",
|
||||||
fact="статусы хода работ проставлены, бригада не выбрана",
|
fact="статусы хода работ проставлены, бригада не выбрана",
|
||||||
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
|
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
|
||||||
|
|
@ -268,7 +270,7 @@ def evaluate_dispatcher(
|
||||||
missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress)
|
missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress)
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
ErrorCode.D6,
|
service, ErrorCode.D6,
|
||||||
f"{service}: ход реагирования не доведён до конца",
|
f"{service}: ход реагирования не доведён до конца",
|
||||||
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта"),
|
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта"),
|
||||||
norm=(
|
norm=(
|
||||||
|
|
@ -301,7 +303,7 @@ def dispatcher_metrics(
|
||||||
|
|
||||||
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0):
|
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0):
|
||||||
metrics.append(Metric(
|
metrics.append(Metric(
|
||||||
key=key, title=f"{service}: {title}", fact=fact, norm=norm,
|
key=key, title=f"{service}: {title}", fact=fact, norm=norm, service=service,
|
||||||
ref=GOST_REF if key == "dds_ack" else "памятка «Работа на АРМ-112», статусы реагирования",
|
ref=GOST_REF if key == "dds_ack" else "памятка «Работа на АРМ-112», статусы реагирования",
|
||||||
passed=passed, weight=weight,
|
passed=passed, weight=weight,
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from html import escape
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.domain.events import SessionReport
|
from app.domain.events import SessionReport
|
||||||
|
from app.domain.taxonomy import Finding, FindingDecision, FindingSource
|
||||||
|
|
||||||
|
|
||||||
def _cell(value: object) -> str:
|
def _cell(value: object) -> str:
|
||||||
|
|
@ -24,6 +25,21 @@ def _cell(value: object) -> str:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _finding_origin(item: Finding) -> str:
|
||||||
|
if item.source is FindingSource.INSTRUCTOR:
|
||||||
|
return f"; добавлена преподавателем {item.author or ''}".rstrip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _finding_decision(item: Finding) -> str:
|
||||||
|
"""Решение по отметке рядом с ней: снятая отметка видна вместе с причиной."""
|
||||||
|
if item.review is None:
|
||||||
|
return ""
|
||||||
|
verdict = ("снята" if item.review.decision is FindingDecision.DISMISSED
|
||||||
|
else "подтверждена")
|
||||||
|
return f"; {verdict} преподавателем {item.review.author}: {item.review.reason}"
|
||||||
|
|
||||||
|
|
||||||
def to_csv(report: SessionReport) -> bytes:
|
def to_csv(report: SessionReport) -> bytes:
|
||||||
"""UTF-8 с BOM: кириллица открывается в Excel без выбора кодировки."""
|
"""UTF-8 с BOM: кириллица открывается в Excel без выбора кодировки."""
|
||||||
output = io.StringIO(newline="")
|
output = io.StringIO(newline="")
|
||||||
|
|
@ -44,6 +60,7 @@ def to_csv(report: SessionReport) -> bytes:
|
||||||
row("Критерии", "", "Нарушенных метрик", report.failed_metrics)
|
row("Критерии", "", "Нарушенных метрик", report.failed_metrics)
|
||||||
row("Критерии", "", "Зачёт", "да" if report.passed else "нет")
|
row("Критерии", "", "Зачёт", "да" if report.passed else "нет")
|
||||||
row("Оценка", "", "Автоматическая", report.score_auto)
|
row("Оценка", "", "Автоматическая", report.score_auto)
|
||||||
|
row("Оценка", "", "После разбора отметок", report.score_reviewed)
|
||||||
row("Оценка", "", "Итоговая", report.score_final)
|
row("Оценка", "", "Итоговая", report.score_final)
|
||||||
row("Оценка", "", "Изменена преподавателем", report.overridden_by)
|
row("Оценка", "", "Изменена преподавателем", report.overridden_by)
|
||||||
row("Оценка", "", "Причина изменения", report.override_comment)
|
row("Оценка", "", "Причина изменения", report.override_comment)
|
||||||
|
|
@ -54,7 +71,9 @@ def to_csv(report: SessionReport) -> bytes:
|
||||||
f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; "
|
f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; "
|
||||||
f"вес: {item.weight:g}{credit}; источник: {item.ref or ''}")
|
f"вес: {item.weight:g}{credit}; источник: {item.ref or ''}")
|
||||||
for number, item in enumerate(report.findings, 1):
|
for number, item in enumerate(report.findings, 1):
|
||||||
row("Ошибки", number, item.code.value, item.summary, f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}")
|
row("Ошибки", number, item.code.value, item.summary,
|
||||||
|
f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}"
|
||||||
|
f"{_finding_origin(item)}{_finding_decision(item)}")
|
||||||
for number, item in enumerate(report.competencies, 1):
|
for number, item in enumerate(report.competencies, 1):
|
||||||
row("Компетенции", number, item.competency, item.value)
|
row("Компетенции", number, item.competency, item.value)
|
||||||
for number, item in enumerate(report.transcript, 1):
|
for number, item in enumerate(report.transcript, 1):
|
||||||
|
|
@ -183,7 +202,9 @@ def to_pdf(report: SessionReport) -> bytes:
|
||||||
story.append(p("Ошибок не выявлено", muted))
|
story.append(p("Ошибок не выявлено", muted))
|
||||||
for item in report.findings:
|
for item in report.findings:
|
||||||
story.append(p(f"{item.code.value} - {item.summary}"))
|
story.append(p(f"{item.code.value} - {item.summary}"))
|
||||||
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. Источник: {item.ref or 'не указан'}", muted))
|
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. "
|
||||||
|
f"Источник: {item.ref or 'не указан'}"
|
||||||
|
f"{_finding_origin(item)}{_finding_decision(item)}", muted))
|
||||||
|
|
||||||
section("Компетенции")
|
section("Компетенции")
|
||||||
for item in report.competencies:
|
for item in report.competencies:
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ class _Builder:
|
||||||
norm=norm,
|
norm=norm,
|
||||||
ref=ref,
|
ref=ref,
|
||||||
competency=competency,
|
competency=competency,
|
||||||
|
metric_key=key,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -174,6 +175,7 @@ def evaluate(
|
||||||
norm=f"эталонный вопрос: «{question}»" if question else "обязательный факт сценария",
|
norm=f"эталонный вопрос: «{question}»" if question else "обязательный факт сценария",
|
||||||
ref="чек-лист сценария",
|
ref="чек-лист сценария",
|
||||||
competency=Competency.INTERVIEW,
|
competency=Competency.INTERVIEW,
|
||||||
|
metric_key="checklist_completeness",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
|
||||||
self_assessment_diff=difference,
|
self_assessment_diff=difference,
|
||||||
notes=[InstructorNoteShown.model_validate(note) for note in state.notes],
|
notes=[InstructorNoteShown.model_validate(note) for note in state.notes],
|
||||||
score_auto=score.get("score_auto", 0.0),
|
score_auto=score.get("score_auto", 0.0),
|
||||||
|
score_reviewed=score.get("score_reviewed"),
|
||||||
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"),
|
||||||
|
|
|
||||||
147
backend/app/scoring/review.py
Normal file
147
backend/app/scoring/review.py
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
"""Разбор по отметкам: преподаватель подтверждает, снимает и добавляет отметку.
|
||||||
|
|
||||||
|
Баллы по-прежнему считает код. Преподаватель меняет входы — какие отметки
|
||||||
|
действуют, — а не формулу: балл пересчитывается теми же метриками и весами.
|
||||||
|
Связь «отметка → метрика» — методика `METRIC_MAP` в пределах карточки и службы;
|
||||||
|
если генератор знает метрику точно, она записана в `Finding.metric_key`.
|
||||||
|
|
||||||
|
Функции чистые и работают со словарём сохранённой оценки: живое занятие,
|
||||||
|
строка `Score` и архивный разбор меняются одним и тем же путём, как правка
|
||||||
|
итога в `apply_score_override`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.domain.events import Metric
|
||||||
|
from app.domain.taxonomy import Finding, FindingDecision, FindingReview, FindingSource
|
||||||
|
from app.scoring.competency import radar
|
||||||
|
from app.scoring.gost import GostResult
|
||||||
|
from app.scoring.taxonomy import METRIC_MAP
|
||||||
|
|
||||||
|
#: Метрика для ручной отметки, которой нечего провалить: у кода нет метрики
|
||||||
|
#: в METRIC_MAP (D4) или в карточке она не считалась. Вес — как у метрики по
|
||||||
|
#: умолчанию, чтобы отметка преподавателя не весила больше автоматической.
|
||||||
|
INSTRUCTOR_METRIC = "instructor_finding"
|
||||||
|
|
||||||
|
|
||||||
|
def _in_scope(finding: Finding, metric: Metric) -> bool:
|
||||||
|
if finding.card != metric.card:
|
||||||
|
return False
|
||||||
|
return finding.service is None or metric.service is None or finding.service == metric.service
|
||||||
|
|
||||||
|
|
||||||
|
def linked(finding: Finding, metric: Metric) -> bool:
|
||||||
|
"""Отметка объясняет провал этой метрики."""
|
||||||
|
if not _in_scope(finding, metric):
|
||||||
|
return False
|
||||||
|
if finding.metric_key is not None:
|
||||||
|
return finding.metric_key == metric.key
|
||||||
|
mapping = METRIC_MAP.get(metric.key)
|
||||||
|
return mapping is not None and mapping[0] is finding.code
|
||||||
|
|
||||||
|
|
||||||
|
def _dismissed(finding: Finding) -> bool:
|
||||||
|
return finding.review is not None and finding.review.decision is FindingDecision.DISMISSED
|
||||||
|
|
||||||
|
|
||||||
|
def _credit(metric: Metric) -> float:
|
||||||
|
return metric.credit if metric.credit is not None else float(metric.passed)
|
||||||
|
|
||||||
|
|
||||||
|
def reviewed_metrics(metrics: list[Metric], findings: list[Finding]) -> list[Metric]:
|
||||||
|
"""Метрики с учётом решений преподавателя.
|
||||||
|
|
||||||
|
Проваленная метрика засчитывается, только если все её автоматические
|
||||||
|
отметки сняты. Ручная отметка проваливает связанные метрики; если их нет,
|
||||||
|
появляется отдельная метрика преподавателя. Исходный вердикт остаётся в
|
||||||
|
`passed_auto`/`credit_auto`, повторный пересчёт начинается с него.
|
||||||
|
"""
|
||||||
|
base = [
|
||||||
|
metric.model_copy(update={
|
||||||
|
"passed": metric.passed_auto if metric.passed_auto is not None else metric.passed,
|
||||||
|
"credit": metric.credit_auto if metric.passed_auto is not None else metric.credit,
|
||||||
|
"passed_auto": None,
|
||||||
|
"credit_auto": None,
|
||||||
|
})
|
||||||
|
for metric in metrics
|
||||||
|
if metric.key != INSTRUCTOR_METRIC
|
||||||
|
]
|
||||||
|
result: list[Metric] = []
|
||||||
|
for metric in base:
|
||||||
|
links = [finding for finding in findings if linked(finding, metric)]
|
||||||
|
manual = [finding for finding in links
|
||||||
|
if finding.source is FindingSource.INSTRUCTOR and not _dismissed(finding)]
|
||||||
|
automatic = [finding for finding in links if finding.source is not FindingSource.INSTRUCTOR]
|
||||||
|
if manual and _credit(metric) > 0:
|
||||||
|
metric = metric.model_copy(update={
|
||||||
|
"passed": False, "credit": 0.0,
|
||||||
|
"passed_auto": metric.passed, "credit_auto": metric.credit,
|
||||||
|
})
|
||||||
|
elif (not manual and _credit(metric) < 1 and automatic
|
||||||
|
and all(_dismissed(finding) for finding in automatic)):
|
||||||
|
metric = metric.model_copy(update={
|
||||||
|
"passed": True, "credit": 1.0,
|
||||||
|
"passed_auto": metric.passed, "credit_auto": metric.credit,
|
||||||
|
})
|
||||||
|
result.append(metric)
|
||||||
|
|
||||||
|
for finding in findings:
|
||||||
|
if (finding.source is FindingSource.INSTRUCTOR and not _dismissed(finding)
|
||||||
|
and not any(linked(finding, metric) for metric in base)):
|
||||||
|
result.append(Metric(
|
||||||
|
key=INSTRUCTOR_METRIC,
|
||||||
|
title=f"Отметка преподавателя: {finding.summary}",
|
||||||
|
fact=finding.fact,
|
||||||
|
norm=finding.norm or "по решению преподавателя",
|
||||||
|
ref=finding.ref,
|
||||||
|
passed=False,
|
||||||
|
card=finding.card,
|
||||||
|
service=finding.service,
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _rescore(report: dict, findings: list[Finding]) -> dict:
|
||||||
|
metrics = reviewed_metrics(
|
||||||
|
[Metric.model_validate(item) for item in report.get("metrics", [])], findings,
|
||||||
|
)
|
||||||
|
score = GostResult(metrics=metrics).score
|
||||||
|
fields = {
|
||||||
|
"findings": [finding.model_dump(mode="json") for finding in findings],
|
||||||
|
"metrics": [metric.model_dump(mode="json") for metric in metrics],
|
||||||
|
"competencies": [item.model_dump() for item in radar(metrics)],
|
||||||
|
"score_reviewed": score,
|
||||||
|
}
|
||||||
|
# Правка итога — последнее слово преподавателя; решение по отметке её не отменяет.
|
||||||
|
if not report.get("overridden_by"):
|
||||||
|
fields["score_final"] = score
|
||||||
|
updated = {**report, **fields}
|
||||||
|
archived = report.get("full_report")
|
||||||
|
if isinstance(archived, dict):
|
||||||
|
failed = sum(not metric.passed and metric.weight > 0 for metric in metrics)
|
||||||
|
allowed = (archived.get("criteria") or {}).get("allowed_errors", 0)
|
||||||
|
updated["full_report"] = {
|
||||||
|
**archived, **fields,
|
||||||
|
"failed_metrics": failed,
|
||||||
|
"passed": failed <= allowed,
|
||||||
|
"score_final": updated.get("score_final", archived.get("score_final")),
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def _findings(report: dict) -> list[Finding]:
|
||||||
|
return [Finding.model_validate(item) for item in report.get("findings", [])]
|
||||||
|
|
||||||
|
|
||||||
|
def review_finding(report: dict, index: int, review: FindingReview) -> dict:
|
||||||
|
"""Подтвердить или снять отметку. Повторное решение заменяет прежнее."""
|
||||||
|
findings = _findings(report)
|
||||||
|
if not 0 <= index < len(findings):
|
||||||
|
raise LookupError(f"нет отметки {index}")
|
||||||
|
findings[index] = findings[index].model_copy(update={"review": review})
|
||||||
|
return _rescore(report, findings)
|
||||||
|
|
||||||
|
|
||||||
|
def add_finding(report: dict, finding: Finding) -> dict:
|
||||||
|
"""Добавить отметку преподавателя; она штрафует связанную метрику."""
|
||||||
|
if finding.source is not FindingSource.INSTRUCTOR:
|
||||||
|
raise ValueError("вручную добавляется только отметка преподавателя")
|
||||||
|
return _rescore(report, [*_findings(report), finding])
|
||||||
|
|
@ -95,4 +95,5 @@ def time_metric(
|
||||||
norm=norm_text,
|
norm=norm_text,
|
||||||
ref=TIME_REF,
|
ref=TIME_REF,
|
||||||
competency=Competency.NORMS,
|
competency=Competency.NORMS,
|
||||||
|
metric_key=norm.key,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,13 @@ from app.session.hub import hub
|
||||||
from app.session.score import ScoringSnapshot, score_session, scoring_scenario
|
from app.session.score import ScoringSnapshot, score_session, scoring_scenario
|
||||||
from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
|
from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
|
||||||
from app.session.store import (
|
from app.session.store import (
|
||||||
|
FindingAdded,
|
||||||
|
FindingReviewed,
|
||||||
LessonEnded,
|
LessonEnded,
|
||||||
ScoreArchived,
|
ScoreArchived,
|
||||||
ScoreCalculated,
|
ScoreCalculated,
|
||||||
ScoreOverridden,
|
ScoreOverridden,
|
||||||
|
apply_finding_change,
|
||||||
apply_score_override,
|
apply_score_override,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -100,9 +103,11 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
||||||
card_id=card.card_id,
|
card_id=card.card_id,
|
||||||
scenario_id=card.scenario.id,
|
scenario_id=card.scenario.id,
|
||||||
reply_text=card.reply_text,
|
reply_text=card.reply_text,
|
||||||
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}"})
|
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}",
|
||||||
|
"card": number})
|
||||||
for metric in weighted.metrics],
|
for metric in weighted.metrics],
|
||||||
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}"})
|
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}",
|
||||||
|
"card": number})
|
||||||
for finding in weighted.findings],
|
for finding in weighted.findings],
|
||||||
actions=actions,
|
actions=actions,
|
||||||
duration_ms=max(0, int((now_utc() - card.dispatched_at).total_seconds() * 1000)),
|
duration_ms=max(0, int((now_utc() - card.dispatched_at).total_seconds() * 1000)),
|
||||||
|
|
@ -234,3 +239,14 @@ def override_score(state, override: ScoreOverridden) -> None:
|
||||||
hub.record(state.session_id, override)
|
hub.record(state.session_id, override)
|
||||||
state.score = apply_score_override(state.score, override)
|
state.score = apply_score_override(state.score, override)
|
||||||
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))
|
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))
|
||||||
|
|
||||||
|
|
||||||
|
def change_findings(state, change: FindingReviewed | FindingAdded) -> None:
|
||||||
|
"""Решение преподавателя по отметке — тем же путём, что правка итога.
|
||||||
|
|
||||||
|
Вызывается внутри `hub.operation`: отметки, пересчитанный балл, архивный
|
||||||
|
разбор и строка аудита уходят одним commit.
|
||||||
|
"""
|
||||||
|
state.score = apply_finding_change(state.score, change)
|
||||||
|
hub.record(state.session_id, change)
|
||||||
|
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ from app.domain.events import Mood, Speaker, TranscriptEntry
|
||||||
from app.session.checkpoint import dump_state, load_state
|
from app.session.checkpoint import dump_state, load_state
|
||||||
from app.session.state import SessionState, now_utc
|
from app.session.state import SessionState, now_utc
|
||||||
from app.session.store import (
|
from app.session.store import (
|
||||||
|
FindingAdded,
|
||||||
|
FindingReviewed,
|
||||||
HintRecorded,
|
HintRecorded,
|
||||||
LessonEnded,
|
LessonEnded,
|
||||||
LessonIdentity,
|
LessonIdentity,
|
||||||
|
|
@ -42,6 +44,7 @@ from app.session.store import (
|
||||||
SelfAssessed,
|
SelfAssessed,
|
||||||
SessionLeaseLost,
|
SessionLeaseLost,
|
||||||
UtteranceAppended,
|
UtteranceAppended,
|
||||||
|
apply_finding_change,
|
||||||
apply_score_override,
|
apply_score_override,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -239,6 +242,30 @@ class PostgresSessionStore:
|
||||||
detail=(f"{score.score_auto} → {record.score_final}; "
|
detail=(f"{score.score_auto} → {record.score_final}; "
|
||||||
f"comment_chars={len(record.comment)}"),
|
f"comment_chars={len(record.comment)}"),
|
||||||
))
|
))
|
||||||
|
case FindingReviewed() | FindingAdded():
|
||||||
|
await db.flush()
|
||||||
|
score = await db.scalar(
|
||||||
|
select(Score).where(Score.session_id == session_id).with_for_update()
|
||||||
|
)
|
||||||
|
if score is None:
|
||||||
|
raise LookupError(f"нет оценки занятия {session_id}")
|
||||||
|
before = score.score_final
|
||||||
|
score.report = apply_finding_change(dict(score.report or {}), record)
|
||||||
|
score.score_final = score.report.get("score_final", score.score_final)
|
||||||
|
if isinstance(record, FindingReviewed):
|
||||||
|
actor, action = record.review.author, "finding.review"
|
||||||
|
what = (f"#{record.index} {record.review.decision.value}; "
|
||||||
|
f"reason_chars={len(record.review.reason)}")
|
||||||
|
else:
|
||||||
|
actor, action = record.finding.author or "instructor", "finding.add"
|
||||||
|
what = f"{record.finding.code.value}; fact_chars={len(record.finding.fact)}"
|
||||||
|
# Как у правки итога: текст решения живёт в разборе, в аудите —
|
||||||
|
# что изменено, кем и как сдвинулся балл.
|
||||||
|
db.add(AuditLog(
|
||||||
|
actor=actor, role=record.role, action=action,
|
||||||
|
object_id=str(session_id),
|
||||||
|
detail=f"{what}; {before} → {score.score_final}",
|
||||||
|
))
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,7 @@ async def score_session(
|
||||||
norm="грамматически корректное описание",
|
norm="грамматически корректное описание",
|
||||||
ref="критерий занятия; правила русского языка",
|
ref="критерий занятия; правила русского языка",
|
||||||
competency=Competency.COMMUNICATION,
|
competency=Competency.COMMUNICATION,
|
||||||
|
metric_key="description_grammar",
|
||||||
))
|
))
|
||||||
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
|
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
|
||||||
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
|
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ from typing import Protocol
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from app.domain.events import TranscriptEntry
|
from app.domain.events import TranscriptEntry
|
||||||
|
from app.domain.taxonomy import Finding, FindingReview
|
||||||
|
from app.scoring.review import add_finding, review_finding
|
||||||
from app.session.checkpoint import dump_state
|
from app.session.checkpoint import dump_state
|
||||||
from app.session.state import SessionState
|
from app.session.state import SessionState
|
||||||
|
|
||||||
|
|
@ -83,8 +85,31 @@ class ScoreOverridden:
|
||||||
comment: str
|
comment: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FindingReviewed:
|
||||||
|
"""Преподаватель подтвердил или снял отметку разбора."""
|
||||||
|
index: int
|
||||||
|
review: FindingReview
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FindingAdded:
|
||||||
|
"""Преподаватель добавил свою отметку (`source=instructor`)."""
|
||||||
|
finding: Finding
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
Record = (UtteranceAppended | HintRecorded | NoteAdded | SelfAssessed | LessonStarted
|
Record = (UtteranceAppended | HintRecorded | NoteAdded | SelfAssessed | LessonStarted
|
||||||
| LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden)
|
| LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden
|
||||||
|
| FindingReviewed | FindingAdded)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_finding_change(report: dict, change: "FindingReviewed | FindingAdded") -> dict:
|
||||||
|
"""Решение по отметке одинаково для живой сессии, строки `Score` и архива."""
|
||||||
|
if isinstance(change, FindingReviewed):
|
||||||
|
return review_finding(report, change.index, change.review)
|
||||||
|
return add_finding(report, change.finding)
|
||||||
|
|
||||||
|
|
||||||
def apply_score_override(report: dict, override: ScoreOverridden) -> dict:
|
def apply_score_override(report: dict, override: ScoreOverridden) -> dict:
|
||||||
|
|
@ -200,6 +225,10 @@ class MemorySessionStore:
|
||||||
if session_id not in self.scores:
|
if session_id not in self.scores:
|
||||||
raise LookupError(f"нет оценки занятия {session_id}")
|
raise LookupError(f"нет оценки занятия {session_id}")
|
||||||
self.scores[session_id] = apply_score_override(self.scores[session_id], record)
|
self.scores[session_id] = apply_score_override(self.scores[session_id], record)
|
||||||
|
elif isinstance(record, FindingReviewed | FindingAdded):
|
||||||
|
if session_id not in self.scores:
|
||||||
|
raise LookupError(f"нет оценки занятия {session_id}")
|
||||||
|
self.scores[session_id] = apply_finding_change(self.scores[session_id], record)
|
||||||
self.records.setdefault(session_id, []).extend(records)
|
self.records.setdefault(session_id, []).extend(records)
|
||||||
|
|
||||||
def snapshot(self, session_id: UUID) -> dict | None:
|
def snapshot(self, session_id: UUID) -> dict | None:
|
||||||
|
|
|
||||||
298
backend/tests/test_finding_review.py
Normal file
298
backend/tests/test_finding_review.py
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
"""Разбор по отметкам (lct-35): преподаватель подтверждает, снимает и добавляет отметку.
|
||||||
|
|
||||||
|
Балл пересчитывается теми же метриками и весами: снятая отметка не штрафует
|
||||||
|
свою метрику, ручная — штрафует. Автоматическая оценка остаётся рядом.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.auth import Principal
|
||||||
|
from app.api.http import sessions
|
||||||
|
from app.domain.events import Metric
|
||||||
|
from app.domain.roles import Role
|
||||||
|
from app.domain.taxonomy import (
|
||||||
|
ErrorCode,
|
||||||
|
Finding,
|
||||||
|
FindingDecision,
|
||||||
|
FindingReview,
|
||||||
|
FindingSource,
|
||||||
|
)
|
||||||
|
from app.main import app
|
||||||
|
from app.scoring.export import to_csv
|
||||||
|
from app.scoring.review import INSTRUCTOR_METRIC, add_finding, review_finding
|
||||||
|
from app.session import finish as finish_module
|
||||||
|
from app.session.hub import hub
|
||||||
|
from app.session.store import FindingAdded, FindingReviewed, MemorySessionStore
|
||||||
|
|
||||||
|
from tests.test_dds_desk_scores import dds_exercise_early_finish
|
||||||
|
|
||||||
|
AT = datetime(2026, 9, 27, 12, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def review(decision: FindingDecision) -> FindingReview:
|
||||||
|
return FindingReview(decision=decision, reason="доклад пришёл по рации", author="teacher", at=AT)
|
||||||
|
|
||||||
|
|
||||||
|
def report_with(metrics: list[Metric], findings: list[Finding]) -> dict:
|
||||||
|
return {
|
||||||
|
"score_auto": 50.0,
|
||||||
|
"metrics": [metric.model_dump(mode="json") for metric in metrics],
|
||||||
|
"findings": [finding.model_dump(mode="json") for finding in findings],
|
||||||
|
"full_report": {"criteria": {"allowed_errors": 0}, "score_auto": 50.0,
|
||||||
|
"score_final": 50.0, "failed_metrics": 1, "passed": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dds_metric(key: str, passed: bool, card: int = 1) -> Metric:
|
||||||
|
return Metric(key=key, title=key, fact="факт", norm="норма", passed=passed,
|
||||||
|
weight=1.0, card=card, service="01")
|
||||||
|
|
||||||
|
|
||||||
|
def dds_finding(code: ErrorCode, card: int = 1) -> Finding:
|
||||||
|
return Finding(code=code, source=FindingSource.DISPATCHER, summary=code.value,
|
||||||
|
fact="факт", norm="норма", card=card, service="01")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dismissed_finding_stops_penalizing_its_metric_but_stays_in_report():
|
||||||
|
report = report_with(
|
||||||
|
[dds_metric("dds_primary", True), dds_metric("dds_progress", False)],
|
||||||
|
[dds_finding(ErrorCode.D6)],
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = review_finding(report, 0, review(FindingDecision.DISMISSED))
|
||||||
|
|
||||||
|
assert updated["score_reviewed"] == 100.0
|
||||||
|
assert updated["score_final"] == 100.0
|
||||||
|
assert updated["score_auto"] == 50.0, "автооценка остаётся рядом"
|
||||||
|
progress = updated["metrics"][1]
|
||||||
|
assert (progress["passed"], progress["passed_auto"]) == (True, False)
|
||||||
|
finding = updated["findings"][0]
|
||||||
|
assert finding["review"]["decision"] == "dismissed"
|
||||||
|
assert finding["review"]["reason"] == "доклад пришёл по рации"
|
||||||
|
archived = updated["full_report"]
|
||||||
|
assert (archived["score_final"], archived["failed_metrics"], archived["passed"]) == (100.0, 0, True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirmed_finding_and_other_card_keep_the_metric_failed():
|
||||||
|
report = report_with(
|
||||||
|
[dds_metric("dds_progress", False, card=1), dds_metric("dds_progress", False, card=2)],
|
||||||
|
[dds_finding(ErrorCode.D6, card=1), dds_finding(ErrorCode.D6, card=2)],
|
||||||
|
)
|
||||||
|
|
||||||
|
confirmed = review_finding(report, 0, review(FindingDecision.CONFIRMED))
|
||||||
|
assert confirmed["score_reviewed"] == 0.0
|
||||||
|
|
||||||
|
dismissed = review_finding(report, 0, review(FindingDecision.DISMISSED))
|
||||||
|
assert [metric["passed"] for metric in dismissed["metrics"]] == [True, False], (
|
||||||
|
"снятие отметки первой карточки не трогает вторую"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_metric_stays_failed_while_one_of_its_findings_is_active():
|
||||||
|
report = report_with(
|
||||||
|
[dds_metric("dds_reply", False)],
|
||||||
|
[dds_finding(ErrorCode.D5), dds_finding(ErrorCode.D5)],
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = review_finding(report, 0, review(FindingDecision.DISMISSED))
|
||||||
|
|
||||||
|
assert updated["metrics"][0]["passed"] is False
|
||||||
|
again = review_finding(updated, 1, review(FindingDecision.DISMISSED))
|
||||||
|
assert again["metrics"][0]["passed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_instructor_finding_penalizes_linked_metric_and_unlinked_code_adds_one():
|
||||||
|
report = report_with([dds_metric("dds_progress", True), dds_metric("dds_primary", True)], [])
|
||||||
|
manual = Finding(code=ErrorCode.D6, source=FindingSource.INSTRUCTOR, summary="Карточка 1: D6",
|
||||||
|
fact="«Прибыла» отмечено до доклада", norm="по факту доклада",
|
||||||
|
card=1, service="01", author="teacher", at=AT)
|
||||||
|
|
||||||
|
updated = add_finding(report, manual)
|
||||||
|
|
||||||
|
assert updated["score_reviewed"] == 50.0
|
||||||
|
assert updated["metrics"][0]["passed"] is False
|
||||||
|
assert updated["metrics"][0]["passed_auto"] is True
|
||||||
|
assert updated["findings"][0]["source"] == "instructor"
|
||||||
|
|
||||||
|
# У D4 в методике нет метрики — ручная отметка всё равно должна штрафовать.
|
||||||
|
no_metric = manual.model_copy(update={"code": ErrorCode.D4})
|
||||||
|
extra = add_finding(report, no_metric)
|
||||||
|
assert [metric["key"] for metric in extra["metrics"]][-1] == INSTRUCTOR_METRIC
|
||||||
|
assert extra["score_reviewed"] == pytest.approx(66.7)
|
||||||
|
|
||||||
|
# Снятая ручная отметка не штрафует, и её метрика исчезает при пересчёте.
|
||||||
|
lifted = review_finding(extra, 0, review(FindingDecision.DISMISSED))
|
||||||
|
assert lifted["score_reviewed"] == 100.0
|
||||||
|
assert INSTRUCTOR_METRIC not in {metric["key"] for metric in lifted["metrics"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_score_override_stays_final_after_finding_review():
|
||||||
|
report = {**report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)]),
|
||||||
|
"score_final": 70.0, "overridden_by": "teacher"}
|
||||||
|
|
||||||
|
updated = review_finding(report, 0, review(FindingDecision.DISMISSED))
|
||||||
|
|
||||||
|
assert updated["score_reviewed"] == 100.0
|
||||||
|
assert updated["score_final"] == 70.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_finding_index_is_refused():
|
||||||
|
with pytest.raises(LookupError):
|
||||||
|
review_finding(report_with([], []), 0, review(FindingDecision.DISMISSED))
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTTP: живое занятие ДДС ──
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def lesson(monkeypatch):
|
||||||
|
"""Завершённое занятие ДДС преподавателя `dev` в памяти узла."""
|
||||||
|
|
||||||
|
class NoCoaching:
|
||||||
|
def model_dump(self, **_kwargs):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def no_coach(_metrics):
|
||||||
|
return NoCoaching()
|
||||||
|
|
||||||
|
monkeypatch.setattr(finish_module, "coach", no_coach)
|
||||||
|
store = MemorySessionStore()
|
||||||
|
monkeypatch.setattr(hub, "store", store)
|
||||||
|
state = dds_exercise_early_finish()
|
||||||
|
state.owner_login = "dev"
|
||||||
|
with TestClient(app) as browser:
|
||||||
|
browser.post("/api/auth/dev-token")
|
||||||
|
hub.store = store
|
||||||
|
hub.register(state)
|
||||||
|
yield browser, state, store
|
||||||
|
|
||||||
|
|
||||||
|
def as_role(monkeypatch, role: Role, login: str = "dev") -> None:
|
||||||
|
def require(_request, *roles):
|
||||||
|
if roles and role not in roles:
|
||||||
|
raise HTTPException(status_code=403, detail="forbidden")
|
||||||
|
return Principal(login=login, full_name="Проверка", role=role,
|
||||||
|
trainee_id=uuid4() if role is Role.TRAINEE else None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(sessions, "require", require)
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_dismisses_and_adds_finding_through_http(lesson):
|
||||||
|
browser, state, store = lesson
|
||||||
|
session_id = state.session_id
|
||||||
|
before = browser.get(f"/api/sessions/{session_id}/report").json()
|
||||||
|
target = next(index for index, finding in enumerate(before["findings"])
|
||||||
|
if finding["code"] == "D1")
|
||||||
|
|
||||||
|
dismissed = browser.post(
|
||||||
|
f"/api/sessions/{session_id}/findings/{target}/review",
|
||||||
|
json={"decision": "dismissed", "reason": "карточку выдали с опозданием"},
|
||||||
|
)
|
||||||
|
assert dismissed.status_code == 200, dismissed.text
|
||||||
|
body = dismissed.json()
|
||||||
|
assert body["findings"][target]["review"]["decision"] == "dismissed"
|
||||||
|
assert body["findings"][target]["review"]["author"] == "dev"
|
||||||
|
assert body["score_auto"] == before["score_auto"]
|
||||||
|
assert body["score_reviewed"] >= before["score_auto"]
|
||||||
|
|
||||||
|
added = browser.post(
|
||||||
|
f"/api/sessions/{session_id}/findings",
|
||||||
|
json={"code": "D5", "fact": "в комментарии нет адресата", "norm": "назвать адресата",
|
||||||
|
"card": 1},
|
||||||
|
)
|
||||||
|
assert added.status_code == 200, added.text
|
||||||
|
manual = added.json()["findings"][-1]
|
||||||
|
assert (manual["source"], manual["card"], manual["author"]) == ("instructor", 1, "dev")
|
||||||
|
assert manual["summary"].startswith("Карточка 1: ")
|
||||||
|
|
||||||
|
records = [record for _, batch in store.commits for record in batch]
|
||||||
|
assert [type(record) for record in records[-2:]] == [FindingReviewed, FindingAdded]
|
||||||
|
assert store.scores[session_id]["findings"][target]["review"]["decision"] == "dismissed"
|
||||||
|
|
||||||
|
rows = to_csv(sessions.SessionReport.model_validate(added.json())).decode("utf-8-sig")
|
||||||
|
assert "карточку выдали с опозданием" in rows
|
||||||
|
assert "в комментарии нет адресата" in rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_requires_reason_and_existing_card(lesson):
|
||||||
|
browser, state, _store = lesson
|
||||||
|
session_id = state.session_id
|
||||||
|
|
||||||
|
blank = browser.post(f"/api/sessions/{session_id}/findings/0/review",
|
||||||
|
json={"decision": "dismissed", "reason": " "})
|
||||||
|
assert blank.status_code == 422
|
||||||
|
missing = browser.post(f"/api/sessions/{session_id}/findings/999/review",
|
||||||
|
json={"decision": "confirmed", "reason": "проверено"})
|
||||||
|
assert missing.status_code == 404
|
||||||
|
card = browser.post(f"/api/sessions/{session_id}/findings",
|
||||||
|
json={"code": "D1", "fact": "факт", "norm": "норма", "card": 99})
|
||||||
|
assert card.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role, login, status", [
|
||||||
|
(Role.TRAINEE, "trainee", 403),
|
||||||
|
(Role.INSTRUCTOR, "someone-else", 404),
|
||||||
|
])
|
||||||
|
def test_trainee_and_foreign_instructor_cannot_review(lesson, monkeypatch, role, login, status):
|
||||||
|
browser, state, store = lesson
|
||||||
|
commits = len(store.commits)
|
||||||
|
as_role(monkeypatch, role, login)
|
||||||
|
|
||||||
|
reviewed = browser.post(f"/api/sessions/{state.session_id}/findings/0/review",
|
||||||
|
json={"decision": "dismissed", "reason": "не согласен"})
|
||||||
|
added = browser.post(f"/api/sessions/{state.session_id}/findings",
|
||||||
|
json={"code": "D1", "fact": "факт", "norm": "норма"})
|
||||||
|
|
||||||
|
assert (reviewed.status_code, added.status_code) == (status, status)
|
||||||
|
assert len(store.commits) == commits, "отказ ничего не пишет"
|
||||||
|
|
||||||
|
|
||||||
|
def test_finding_review_is_audited_in_postgres(postgres_access):
|
||||||
|
"""Решение и аудит — одна транзакция: строка Score и AuditLog вместе."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.base import get_sessionmaker
|
||||||
|
from app.db.models import AuditLog, Score
|
||||||
|
from app.session.pg_store import PostgresSessionStore
|
||||||
|
from app.session.store import LessonRequest
|
||||||
|
|
||||||
|
store = PostgresSessionStore(get_sessionmaker())
|
||||||
|
session_id = uuid4()
|
||||||
|
report = report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)])
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
from app.session.state import SessionState
|
||||||
|
from app.domain.events import Exercise, SessionMode
|
||||||
|
|
||||||
|
def build(_identity):
|
||||||
|
return SessionState(session_id=session_id, scenario_id="fire-apartment-l2",
|
||||||
|
scenario_title="Пожар", level="L2", mode=SessionMode.TRAINING,
|
||||||
|
exercise=Exercise.DDS)
|
||||||
|
|
||||||
|
await store.open(LessonRequest(
|
||||||
|
session_id=session_id, scenario_id="fire-apartment-l2", mode="training",
|
||||||
|
trainee_name=None, trainee_id=None, owner_login="teacher", backend_node_id=None,
|
||||||
|
), build)
|
||||||
|
from app.session.store import ScoreCalculated
|
||||||
|
await store.commit_archived(session_id, [ScoreCalculated(50.0, report)])
|
||||||
|
await store.commit_archived(session_id, [FindingReviewed(
|
||||||
|
index=0, role="instructor", review=review(FindingDecision.DISMISSED),
|
||||||
|
)])
|
||||||
|
async with get_sessionmaker()() as db:
|
||||||
|
score = await db.scalar(select(Score).where(Score.session_id == session_id))
|
||||||
|
audit = await db.scalar(select(AuditLog).where(
|
||||||
|
AuditLog.object_id == str(session_id), AuditLog.action == "finding.review",
|
||||||
|
))
|
||||||
|
return score, audit
|
||||||
|
|
||||||
|
score, audit = asyncio.run(scenario())
|
||||||
|
assert score.score_auto == 50.0
|
||||||
|
assert score.score_final == 100.0
|
||||||
|
assert score.report["findings"][0]["review"]["decision"] == "dismissed"
|
||||||
|
assert audit is not None and audit.actor == "teacher"
|
||||||
|
assert "dismissed" in audit.detail and "50.0 → 100.0" in audit.detail
|
||||||
|
|
@ -83,6 +83,8 @@ HTTP_ROLE_GATE_POLICY = {
|
||||||
("http/sessions.py", "get", "/{session_id}/checklist"): ((),),
|
("http/sessions.py", "get", "/{session_id}/checklist"): ((),),
|
||||||
("http/sessions.py", "get", "/{session_id}/recording.wav"): (("INSTRUCTOR", "TRAINEE"),),
|
("http/sessions.py", "get", "/{session_id}/recording.wav"): (("INSTRUCTOR", "TRAINEE"),),
|
||||||
("http/sessions.py", "patch", "/{session_id}/report"): (("INSTRUCTOR",),),
|
("http/sessions.py", "patch", "/{session_id}/report"): (("INSTRUCTOR",),),
|
||||||
|
("http/sessions.py", "post", "/{session_id}/findings/{index}/review"): (("INSTRUCTOR",),),
|
||||||
|
("http/sessions.py", "post", "/{session_id}/findings"): (("INSTRUCTOR",),),
|
||||||
("http/sessions.py", "get", ""): ((),),
|
("http/sessions.py", "get", ""): ((),),
|
||||||
("http/trainees.py", "get", "/{trainee_id}/certificate.pdf"): ((),),
|
("http/trainees.py", "get", "/{trainee_id}/certificate.pdf"): ((),),
|
||||||
("http/trainees.py", "get", ""): (("ADMIN", "INSTRUCTOR"),),
|
("http/trainees.py", "get", ""): (("ADMIN", "INSTRUCTOR"),),
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
||||||
<article className="debrief-stat">
|
<article className="debrief-stat">
|
||||||
<span>Автоматическая оценка</span><strong>{report.score_auto.toFixed(0)}<small> из 100</small></strong>
|
<span>Автоматическая оценка</span><strong>{report.score_auto.toFixed(0)}<small> из 100</small></strong>
|
||||||
<small>{report.overridden_by ? "до корректировки преподавателя" : "расчёт по критериям занятия"}</small>
|
<small>{report.overridden_by ? "до корректировки преподавателя" : "расчёт по критериям занятия"}</small>
|
||||||
|
{report.score_reviewed != null && <small>после разбора отметок: {report.score_reviewed.toFixed(0)}</small>}
|
||||||
</article>
|
</article>
|
||||||
<article className="debrief-stat">
|
<article className="debrief-stat">
|
||||||
<span>Упражнение</span><strong>{report.exercise === "dds" ? "ДДС" : report.exercise === "card" ? "КИО" : report.exercise === "call" ? "Вызов 112" : "112 → ДДС"}</strong>
|
<span>Упражнение</span><strong>{report.exercise === "dds" ? "ДДС" : report.exercise === "card" ? "КИО" : report.exercise === "call" ? "Вызов 112" : "112 → ДДС"}</strong>
|
||||||
|
|
@ -144,6 +145,10 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
||||||
<td>
|
<td>
|
||||||
{finding.summary}
|
{finding.summary}
|
||||||
<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div>
|
<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div>
|
||||||
|
{finding.source === "instructor" && <div className="ref">отметка преподавателя {finding.author ?? ""}</div>}
|
||||||
|
{finding.review && <div className="ref">
|
||||||
|
{finding.review.decision === "dismissed" ? "снята" : "подтверждена"} преподавателем: {finding.review.reason}
|
||||||
|
</div>}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
146
frontend/src/features/debrief/FindingReview.tsx
Normal file
146
frontend/src/features/debrief/FindingReview.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
// Разбор по отметкам: преподаватель подтверждает, снимает и добавляет отметку.
|
||||||
|
//
|
||||||
|
// Балл пересчитывает сервер теми же метриками и весами (lct-35): здесь только
|
||||||
|
// решение и его причина. Причина обязательна — она остаётся в отчёте и в CSV/PDF.
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { ErrorCode, FindingDecision, SessionReport } from "@/shared/types/generated";
|
||||||
|
|
||||||
|
const DDS_CODES: ErrorCode[] = ["D1", "D2", "D3", "D4", "D5", "D6"];
|
||||||
|
const CALL_CODES: ErrorCode[] = ["E1", "E2", "E3", "E4", "E5", "E6"];
|
||||||
|
|
||||||
|
async function send(url: string, body: unknown): Promise<SessionReport> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = data && typeof data === "object" && "detail" in data ? data.detail : response.statusText;
|
||||||
|
throw new Error(typeof detail === "string" ? detail : "Не удалось сохранить решение.");
|
||||||
|
}
|
||||||
|
return data as SessionReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FindingReview({ report, onChange }: {
|
||||||
|
report: SessionReport;
|
||||||
|
onChange: (report: SessionReport) => void;
|
||||||
|
}) {
|
||||||
|
const cards = report.card_results ?? [];
|
||||||
|
const codes = report.exercise === "dds" ? DDS_CODES
|
||||||
|
: cards.length ? [...CALL_CODES, ...DDS_CODES] : CALL_CODES;
|
||||||
|
const [reasons, setReasons] = useState<Record<number, string>>({});
|
||||||
|
const [code, setCode] = useState<ErrorCode>(codes[0]);
|
||||||
|
const [card, setCard] = useState(cards.length ? "1" : "");
|
||||||
|
const [fact, setFact] = useState("");
|
||||||
|
const [norm, setNorm] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const base = `/api/sessions/${report.session_id}/findings`;
|
||||||
|
|
||||||
|
async function run(action: () => Promise<SessionReport>) {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
onChange(await action());
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause instanceof Error ? cause.message : "Не удалось сохранить решение.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decide(index: number, decision: FindingDecision) {
|
||||||
|
const reason = (reasons[index] ?? "").trim();
|
||||||
|
if (!reason) {
|
||||||
|
setError("Укажите причину решения по отметке.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void run(async () => {
|
||||||
|
const updated = await send(`${base}/${index}/review`, { decision, reason });
|
||||||
|
setReasons((current) => ({ ...current, [index]: "" }));
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFinding() {
|
||||||
|
if (!fact.trim() || !norm.trim()) {
|
||||||
|
setError("Для отметки нужны факт и норма.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void run(async () => {
|
||||||
|
const updated = await send(base, {
|
||||||
|
code, fact: fact.trim(), norm: norm.trim(), card: card ? Number(card) : null,
|
||||||
|
});
|
||||||
|
setFact("");
|
||||||
|
setNorm("");
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="debrief finding-review">
|
||||||
|
<h2>Разбор по отметкам</h2>
|
||||||
|
<p className="ref">
|
||||||
|
Снятая отметка не штрафует свою метрику, добавленная — штрафует. Балл пересчитывается
|
||||||
|
по тем же критериям и весам; автоматическая оценка ({report.score_auto.toFixed(1)}) сохраняется рядом.
|
||||||
|
{report.score_reviewed != null && ` После разбора: ${report.score_reviewed.toFixed(1)}.`}
|
||||||
|
</p>
|
||||||
|
<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>
|
||||||
|
{finding.review && <div className={finding.review.decision === "dismissed" ? "ref" : "state-violated"}>
|
||||||
|
{finding.review.decision === "dismissed" ? "Снята" : "Подтверждена"}: {finding.review.reason}
|
||||||
|
</div>}
|
||||||
|
<div className="finding-review-actions">
|
||||||
|
<input aria-label={`Причина решения по отметке ${index + 1}`} maxLength={1000}
|
||||||
|
placeholder="Причина решения" value={reasons[index] ?? ""}
|
||||||
|
onChange={(event) => setReasons((current) => ({ ...current, [index]: event.target.value }))} />
|
||||||
|
<button type="button" disabled={busy} onClick={() => decide(index, "confirmed")}>Подтвердить</button>
|
||||||
|
<button type="button" disabled={busy} onClick={() => decide(index, "dismissed")}>Снять</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{report.findings.length === 0 && <tr><td colSpan={2}>Автоматических отметок нет.</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Добавить отметку</h3>
|
||||||
|
<div className="finding-review-form">
|
||||||
|
<label>Код
|
||||||
|
<select value={code} onChange={(event) => setCode(event.target.value as ErrorCode)}>
|
||||||
|
{codes.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{cards.length > 0 && <label>Карточка
|
||||||
|
<select value={card} onChange={(event) => setCard(event.target.value)}>
|
||||||
|
{cards.map((item, index) => <option key={item.card_id} value={String(index + 1)}>
|
||||||
|
{index + 1}: {item.title || item.scenario_id}
|
||||||
|
</option>)}
|
||||||
|
</select>
|
||||||
|
</label>}
|
||||||
|
<label>Факт
|
||||||
|
<input maxLength={1000} value={fact} onChange={(event) => setFact(event.target.value)}
|
||||||
|
placeholder="Что сделал курсант" />
|
||||||
|
</label>
|
||||||
|
<label>Норма
|
||||||
|
<input maxLength={1000} value={norm} onChange={(event) => setNorm(event.target.value)}
|
||||||
|
placeholder="Как требовалось" />
|
||||||
|
</label>
|
||||||
|
<button type="button" disabled={busy || !fact.trim() || !norm.trim()} onClick={addFinding}>
|
||||||
|
{busy ? "Сохраняем…" : "Добавить отметку"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="violated" role="alert">{error}</p>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { Debrief } from "@/features/debrief/Debrief";
|
import { Debrief } from "@/features/debrief/Debrief";
|
||||||
|
import { FindingReview } from "@/features/debrief/FindingReview";
|
||||||
import { Director } from "@/features/instructor/Director";
|
import { Director } from "@/features/instructor/Director";
|
||||||
import { ScoreWeightsEditor } from "@/features/instructor/ScoreWeightsEditor";
|
import { ScoreWeightsEditor } from "@/features/instructor/ScoreWeightsEditor";
|
||||||
import { ScenarioEditor } from "@/features/instructor/ScenarioEditor";
|
import { ScenarioEditor } from "@/features/instructor/ScenarioEditor";
|
||||||
|
|
@ -801,6 +802,8 @@ export function Instructor() {
|
||||||
{session.report && <>
|
{session.report && <>
|
||||||
<Debrief report={overrideReport?.session_id === session.report.session_id
|
<Debrief report={overrideReport?.session_id === session.report.session_id
|
||||||
? overrideReport : session.report} />
|
? overrideReport : session.report} />
|
||||||
|
<FindingReview report={overrideReport?.session_id === session.report.session_id
|
||||||
|
? overrideReport : session.report} onChange={setOverrideReport} />
|
||||||
<section className="debrief debrief-score-override">
|
<section className="debrief debrief-score-override">
|
||||||
<h2>Ручная корректировка оценки</h2>
|
<h2>Ручная корректировка оценки</h2>
|
||||||
<p className="ref">Автоматическая оценка: {session.report.score_auto.toFixed(1)} из 100. Меняйте её только при необходимости: причина сохранится в журнале вместе с вашим логином.</p>
|
<p className="ref">Автоматическая оценка: {session.report.score_auto.toFixed(1)} из 100. Меняйте её только при необходимости: причина сохранится в журнале вместе с вашим логином.</p>
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,22 @@ export interface Finding {
|
||||||
competency?: Competency | null;
|
competency?: Competency | null;
|
||||||
transcript_ref?: string | null;
|
transcript_ref?: string | null;
|
||||||
at?: string | null;
|
at?: string | null;
|
||||||
|
metric_key?: string | null;
|
||||||
|
card?: number | null;
|
||||||
|
service?: string | null;
|
||||||
|
author?: string | null;
|
||||||
|
review?: FindingReview | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Решение преподавателя по отметке на разборе. */
|
||||||
|
export type FindingDecision = "confirmed" | "dismissed";
|
||||||
|
|
||||||
|
/** Кто, когда и почему подтвердил или снял отметку. Сама отметка остаётся. */
|
||||||
|
export interface FindingReview {
|
||||||
|
decision: FindingDecision;
|
||||||
|
reason: string;
|
||||||
|
author: string;
|
||||||
|
at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Источник объяснимой отметки; `judge` оставлен для старых отчётов. */
|
/** Источник объяснимой отметки; `judge` оставлен для старых отчётов. */
|
||||||
|
|
@ -407,6 +423,10 @@ export interface Metric {
|
||||||
passed: boolean;
|
passed: boolean;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
credit?: number | null;
|
credit?: number | null;
|
||||||
|
card?: number | null;
|
||||||
|
service?: string | null;
|
||||||
|
passed_auto?: boolean | null;
|
||||||
|
credit_auto?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModeSet {
|
export interface ModeSet {
|
||||||
|
|
@ -608,6 +628,7 @@ export interface SessionReport {
|
||||||
self_assessment_diff?: SelfAssessmentDiff | null;
|
self_assessment_diff?: SelfAssessmentDiff | null;
|
||||||
notes?: Array<InstructorNoteShown>;
|
notes?: Array<InstructorNoteShown>;
|
||||||
score_auto: number;
|
score_auto: number;
|
||||||
|
score_reviewed?: number | null;
|
||||||
score_final: number;
|
score_final: number;
|
||||||
overridden_by?: string | null;
|
overridden_by?: string | null;
|
||||||
override_comment?: string | null;
|
override_comment?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,9 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||||
.debrief-stat > strong { font-size: 24px; line-height: 1.15; overflow-wrap: anywhere; }
|
.debrief-stat > strong { font-size: 24px; line-height: 1.15; overflow-wrap: anywhere; }
|
||||||
.debrief-stat > strong small { font-size: 14px; font-weight: 500; }
|
.debrief-stat > strong small { font-size: 14px; font-weight: 500; }
|
||||||
.debrief-stat > small { color: #5a6068; font-size: 12px; }
|
.debrief-stat > small { color: #5a6068; font-size: 12px; }
|
||||||
|
.finding-review-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||||
|
.finding-review-actions input { flex: 1 1 200px; min-width: 0; }
|
||||||
|
.finding-review-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; align-items: end; }
|
||||||
.debrief-stat.is-passed { border-left-color: var(--ok); }
|
.debrief-stat.is-passed { border-left-color: var(--ok); }
|
||||||
.debrief-stat.is-failed { border-left-color: var(--violated); }
|
.debrief-stat.is-failed { border-left-color: var(--violated); }
|
||||||
.debrief-next-step { margin: 12px 0 18px; padding: 12px 15px; border: 1px solid var(--line); border-left: 4px solid #6b7780; border-radius: 5px; background: #fff; }
|
.debrief-next-step { margin: 12px 0 18px; padding: 12px 15px; border: 1px solid var(--line); border-left: 4px solid #6b7780; border-radius: 5px; background: #fff; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue