Merge branch 'feat/finding-review'
lct-35: разбор по отметкам — подтвердить, снять, добавить с пересчётом балла
This commit is contained in:
commit
c1a67badbf
24 changed files with 1409 additions and 27 deletions
|
|
@ -23,14 +23,26 @@ from app.db import repo
|
|||
from app.db.base import get_session
|
||||
from app.db.models import Group, Score, Session, Trainee
|
||||
from app.domain.events import Exercise, SessionMode, SessionReport
|
||||
from app.domain.taxonomy import (
|
||||
ERRORS,
|
||||
ErrorCode,
|
||||
Finding,
|
||||
FindingDecision,
|
||||
FindingReview,
|
||||
FindingSource,
|
||||
)
|
||||
from app.domain.roles import Role
|
||||
from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current
|
||||
from app.domain.timers import TimerCode, TimerState
|
||||
from app.scoring.export import to_csv, to_pdf
|
||||
from app.scoring.report import build as build_report
|
||||
from app.scoring.review import has_finding
|
||||
from app.scoring.taxonomy import METRIC_MAP
|
||||
from app.session.access import can_access
|
||||
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.store import FindingAdded, FindingReviewed
|
||||
from app.session.timers import now_utc
|
||||
from app.session.score import scoring_scenario
|
||||
from app.session.hub import hub
|
||||
from app.session.state import SessionState
|
||||
|
|
@ -649,6 +661,157 @@ async def override(
|
|||
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)
|
||||
#: Ключ идемпотентности с клиента: повтор запроса не добавит отметку второй раз.
|
||||
client_id: UUID | None = None
|
||||
|
||||
_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):
|
||||
if not _repeated(state.score, change):
|
||||
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")
|
||||
current = dict(score.report)
|
||||
change = make(who, current)
|
||||
await db.rollback() # чтение закончено; запись — одной транзакцией хранилища
|
||||
if not _repeated(current, change):
|
||||
await hub.store.commit_archived(session_id, [change])
|
||||
# Ответ — по строке после commit: пересчёт шёл под блокировкой и мог учесть
|
||||
# решение из соседней вкладки, которого не было в прочитанном выше отчёте.
|
||||
score = await db.scalar(
|
||||
select(Score).where(Score.session_id == session_id)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
report = SessionReport.model_validate(score.report["full_report"])
|
||||
await db.rollback()
|
||||
return report
|
||||
|
||||
|
||||
def _repeated(report: dict, change: FindingReviewed | FindingAdded) -> bool:
|
||||
return isinstance(change, FindingAdded) and has_finding(report, change.finding.client_id)
|
||||
|
||||
|
||||
@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 = ""
|
||||
exercise = (score.get("full_report") or {}).get("exercise")
|
||||
# E1–E6 в связке 112 → ДДС — ошибки приёма вызова: их метрики у занятия,
|
||||
# а не у карточки очереди. С номером карточки отметка не нашла бы свою
|
||||
# метрику 112 и штрафовала бы отдельной метрикой преподавателя.
|
||||
if (body.card is not None and body.code.value.startswith("E")
|
||||
and exercise != Exercise.DDS.value):
|
||||
raise HTTPException(status_code=422, detail="call_code_without_card")
|
||||
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,
|
||||
client_id=body.client_id,
|
||||
))
|
||||
|
||||
return await _change_findings(session_id, who, db, make)
|
||||
|
||||
|
||||
@router.get("", response_model=list[SessionOut])
|
||||
async def listing(
|
||||
request: Request,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from app.domain.roles import Role
|
|||
from app.domain.statuses import ServiceStatus, StationSnapshot
|
||||
from app.domain.taxonomy import Finding
|
||||
from app.domain.timers import TimerSnapshot
|
||||
from app.scoring.taxonomy import METRIC_MAP
|
||||
from app.scoring.taxonomy import INSTRUCTOR_METRIC, METRIC_MAP
|
||||
|
||||
|
||||
class SessionMode(StrEnum):
|
||||
|
|
@ -60,7 +60,7 @@ class LessonCriteria(BaseModel):
|
|||
@field_validator("score_weights")
|
||||
@classmethod
|
||||
def validate_score_weights(cls, weights: dict[str, float]) -> dict[str, float]:
|
||||
unknown = weights.keys() - METRIC_MAP.keys()
|
||||
unknown = weights.keys() - METRIC_MAP.keys() - {INSTRUCTOR_METRIC}
|
||||
if unknown:
|
||||
raise ValueError(f"неизвестные метрики весов: {', '.join(sorted(unknown))}")
|
||||
if any(not 0 <= weight <= 10 for weight in weights.values()):
|
||||
|
|
@ -692,6 +692,12 @@ class Metric(BaseModel):
|
|||
passed: bool
|
||||
weight: float = 1.0
|
||||
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):
|
||||
|
|
@ -741,6 +747,8 @@ class DdsCardReport(BaseModel):
|
|||
card_id: UUID
|
||||
scenario_id: str
|
||||
score_auto: float
|
||||
#: Балл карточки после решений преподавателя по отметкам; None — решений не было.
|
||||
score_reviewed: float | None = None
|
||||
reply_text: str
|
||||
metrics: list[Metric]
|
||||
findings: list[Finding]
|
||||
|
|
@ -780,6 +788,8 @@ class SessionReport(BaseModel):
|
|||
self_assessment_diff: SelfAssessmentDiff | None = None
|
||||
notes: list[InstructorNoteShown] = []
|
||||
score_auto: float
|
||||
#: Балл после решений преподавателя по отметкам; None — решений не было.
|
||||
score_reviewed: float | None = None
|
||||
score_final: float
|
||||
overridden_by: str | None = None
|
||||
override_comment: str | None = None
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@
|
|||
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, model_validator
|
||||
|
||||
|
||||
class ErrorCode(StrEnum):
|
||||
|
|
@ -152,6 +154,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):
|
||||
"""Отметка в разборе. `fact` и `norm` — то самое обоснование."""
|
||||
|
||||
|
|
@ -164,3 +182,32 @@ class Finding(BaseModel):
|
|||
competency: Competency | None = None
|
||||
transcript_ref: str | 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
|
||||
#: Ключ идемпотентности ручной отметки: повтор запроса не добавляет её снова.
|
||||
client_id: UUID | None = None
|
||||
#: Все решения по отметке по порядку; действует последнее. Прежние не
|
||||
#: затираются: «кто, когда, почему» нужно по каждой правке, а не по итогу.
|
||||
reviews: list[FindingReview] = []
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _single_review(cls, data: Any) -> Any:
|
||||
# Разборы, сохранённые до истории решений, хранили одно поле `review`.
|
||||
if isinstance(data, dict) and "review" in data:
|
||||
data = dict(data)
|
||||
single = data.pop("review")
|
||||
if single and not data.get("reviews"):
|
||||
data["reviews"] = [single]
|
||||
return data
|
||||
|
||||
@property
|
||||
def review(self) -> FindingReview | None:
|
||||
"""Действующее решение — последнее."""
|
||||
return self.reviews[-1] if self.reviews else None
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ def evaluate_card(scenario: Scenario, kio: KIO) -> GostResult:
|
|||
source=FindingSource.KIO if code.value == "E5" else FindingSource.GROUND_TRUTH,
|
||||
summary=f"{title}: {fact}; требуется {norm}",
|
||||
fact=fact, norm=norm, ref=ref, competency=competency,
|
||||
metric_key=key,
|
||||
))
|
||||
|
||||
if truth.incident_code:
|
||||
|
|
|
|||
|
|
@ -28,8 +28,10 @@ from app.domain.timers import GOST_REF
|
|||
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(
|
||||
service=service,
|
||||
code=code,
|
||||
source=FindingSource.DISPATCHER,
|
||||
summary=summary,
|
||||
|
|
@ -117,7 +119,7 @@ def evaluate_dispatcher(
|
|||
]
|
||||
if missing_comment_statuses:
|
||||
findings.append(_finding(
|
||||
ErrorCode.D5,
|
||||
service, ErrorCode.D5,
|
||||
f"{service}: к статусу не добавлены основание и сведения",
|
||||
fact="не заполнены комментарии: " + ", ".join(missing_comment_statuses),
|
||||
norm="к каждой ручной отметке добавить основание и содержание полученных сведений",
|
||||
|
|
@ -132,7 +134,7 @@ def evaluate_dispatcher(
|
|||
]
|
||||
if incomplete_comment_statuses:
|
||||
findings.append(_finding(
|
||||
ErrorCode.D5,
|
||||
service, ErrorCode.D5,
|
||||
f"{service}: комментарии к статусам не разделяют основание и сведения",
|
||||
fact="неполные комментарии: " + ", ".join(incomplete_comment_statuses),
|
||||
norm="в каждом комментарии заполнить отдельные поля «Основание» и «Сведения»",
|
||||
|
|
@ -152,7 +154,7 @@ def evaluate_dispatcher(
|
|||
]
|
||||
if missing_recipients:
|
||||
findings.append(_finding(
|
||||
ErrorCode.D5,
|
||||
service, ErrorCode.D5,
|
||||
f"{service}: отдельный комментарий не указывает получателя сведений",
|
||||
fact="; ".join(missing_recipients)[:500],
|
||||
norm=(
|
||||
|
|
@ -166,7 +168,7 @@ def evaluate_dispatcher(
|
|||
if primary is None:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D1,
|
||||
service, ErrorCode.D1,
|
||||
f"{service}: статус реагирования не проставлен",
|
||||
fact=(
|
||||
f"прошло {elapsed_ms // 1000} с"
|
||||
|
|
@ -181,7 +183,7 @@ def evaluate_dispatcher(
|
|||
if elapsed_ms is None or elapsed_ms > deadline_ms:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D1,
|
||||
service, ErrorCode.D1,
|
||||
f"{service}: первичный статус проставлен с нарушением срока",
|
||||
fact=(
|
||||
f"прошло {elapsed_ms // 1000} с"
|
||||
|
|
@ -197,7 +199,7 @@ def evaluate_dispatcher(
|
|||
if mark.status in REFUSAL_COMMENT_REQUIRED and not mark.comment.strip():
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D4,
|
||||
service, ErrorCode.D4,
|
||||
f"{service}: «{SERVICE_STATUS_LABELS[mark.status]}» без комментария",
|
||||
fact="причина отказа не указана",
|
||||
norm="комментарий к отказу обязателен",
|
||||
|
|
@ -209,7 +211,7 @@ def evaluate_dispatcher(
|
|||
if latest is ServiceStatus.DECLINED and expected_decision == "accept":
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D3,
|
||||
service, ErrorCode.D3,
|
||||
f"{service}: отказ от происшествия, которое в её компетенции",
|
||||
fact="служба есть в списке оповещения по ЕКП",
|
||||
norm="принять согласно эталону сценария",
|
||||
|
|
@ -218,7 +220,7 @@ def evaluate_dispatcher(
|
|||
elif latest is ServiceStatus.ACCEPTED and expected_decision == "decline":
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
service, ErrorCode.D2,
|
||||
f"{service}: принято вопреки эталону сценария",
|
||||
fact="карточка принята службой",
|
||||
norm=expected_decision_reason or "отказать с указанной причиной",
|
||||
|
|
@ -232,7 +234,7 @@ def evaluate_dispatcher(
|
|||
if service not in crew_assignments:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
service, ErrorCode.D2,
|
||||
f"{service}: «Принята» без назначения бригады и хода реагирования",
|
||||
fact="бригада не выбрана, после приёма статусов не было",
|
||||
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
|
||||
|
|
@ -241,7 +243,7 @@ def evaluate_dispatcher(
|
|||
else:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
service, ErrorCode.D2,
|
||||
f"{service}: «Принята», но о реагировании ничего не отмечено",
|
||||
fact="после приёма статусов не было",
|
||||
norm="статус должен соответствовать фактическому состоянию заявки",
|
||||
|
|
@ -252,7 +254,7 @@ def evaluate_dispatcher(
|
|||
) and service not in crew_assignments:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
service, ErrorCode.D2,
|
||||
f"{service}: ход реагирования без назначения бригады",
|
||||
fact="статусы хода работ проставлены, бригада не выбрана",
|
||||
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
|
||||
|
|
@ -268,7 +270,7 @@ def evaluate_dispatcher(
|
|||
missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress)
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D6,
|
||||
service, ErrorCode.D6,
|
||||
f"{service}: ход реагирования не доведён до конца",
|
||||
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта"),
|
||||
norm=(
|
||||
|
|
@ -301,7 +303,7 @@ def dispatcher_metrics(
|
|||
|
||||
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0):
|
||||
metrics.append(Metric(
|
||||
key=key, title=f"{service}: {title}", fact=fact, norm=norm,
|
||||
key=key, title=f"{service}: {title}", fact=fact, norm=norm, service=service,
|
||||
ref=GOST_REF if key == "dds_ack" else "памятка «Работа на АРМ-112», статусы реагирования",
|
||||
passed=passed, weight=weight,
|
||||
))
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from html import escape
|
|||
from pathlib import Path
|
||||
|
||||
from app.domain.events import SessionReport
|
||||
from app.domain.taxonomy import Finding, FindingDecision, FindingSource
|
||||
|
||||
|
||||
def _cell(value: object) -> str:
|
||||
|
|
@ -18,12 +19,27 @@ def _cell(value: object) -> str:
|
|||
if value is None:
|
||||
return ""
|
||||
result = str(value)
|
||||
probe = result.lstrip(" \t\r\n\ufeff\u200b")
|
||||
probe = result.lstrip(" \t\r\n\N{ZERO WIDTH NO-BREAK SPACE}\N{ZERO WIDTH SPACE}")
|
||||
if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")):
|
||||
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:
|
||||
"""Решения по отметке рядом с ней, все по порядку: действует последнее."""
|
||||
return "".join(
|
||||
f"; {'снята' if review.decision is FindingDecision.DISMISSED else 'подтверждена'} "
|
||||
f"преподавателем {review.author}: {review.reason}"
|
||||
for review in item.reviews
|
||||
)
|
||||
|
||||
|
||||
def to_csv(report: SessionReport) -> bytes:
|
||||
"""UTF-8 с BOM: кириллица открывается в Excel без выбора кодировки."""
|
||||
output = io.StringIO(newline="")
|
||||
|
|
@ -44,6 +60,7 @@ def to_csv(report: SessionReport) -> bytes:
|
|||
row("Критерии", "", "Нарушенных метрик", report.failed_metrics)
|
||||
row("Критерии", "", "Зачёт", "да" if report.passed else "нет")
|
||||
row("Оценка", "", "Автоматическая", report.score_auto)
|
||||
row("Оценка", "", "После разбора отметок", report.score_reviewed)
|
||||
row("Оценка", "", "Итоговая", report.score_final)
|
||||
row("Оценка", "", "Изменена преподавателем", report.overridden_by)
|
||||
row("Оценка", "", "Причина изменения", report.override_comment)
|
||||
|
|
@ -54,7 +71,9 @@ def to_csv(report: SessionReport) -> bytes:
|
|||
f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; "
|
||||
f"вес: {item.weight:g}{credit}; источник: {item.ref or ''}")
|
||||
for number, item in enumerate(report.findings, 1):
|
||||
row("Ошибки", number, item.code.value, item.summary, f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}")
|
||||
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):
|
||||
row("Компетенции", number, item.competency, item.value)
|
||||
for number, item in enumerate(report.transcript, 1):
|
||||
|
|
@ -77,7 +96,7 @@ def to_csv(report: SessionReport) -> bytes:
|
|||
row("Сверка самооценки", "", label, ", ".join(values))
|
||||
for number, item in enumerate(report.notes, 1):
|
||||
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
|
||||
return ("\ufeff" + output.getvalue()).encode("utf-8")
|
||||
return ("\N{ZERO WIDTH NO-BREAK SPACE}" + output.getvalue()).encode("utf-8")
|
||||
|
||||
|
||||
def _font_paths() -> tuple[Path, Path | None]:
|
||||
|
|
@ -160,6 +179,9 @@ def to_pdf(report: SessionReport) -> bytes:
|
|||
("Итоговая оценка", report.score_final),
|
||||
):
|
||||
story.append(Paragraph(f"<b>{escape(label)}:</b> {escape(str(value))}", base))
|
||||
# Когда итог правили вручную, без этой строки не понять, какой балл дал разбор.
|
||||
if report.score_reviewed is not None:
|
||||
story.append(Paragraph(f"<b>После разбора отметок:</b> {report.score_reviewed}", base))
|
||||
if report.overridden_by:
|
||||
story.append(Paragraph(f"<b>Оценка изменена:</b> {escape(report.overridden_by)}", base))
|
||||
if report.override_comment:
|
||||
|
|
@ -183,7 +205,9 @@ def to_pdf(report: SessionReport) -> bytes:
|
|||
story.append(p("Ошибок не выявлено", muted))
|
||||
for item in report.findings:
|
||||
story.append(p(f"{item.code.value} - {item.summary}"))
|
||||
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. Источник: {item.ref or 'не указан'}", muted))
|
||||
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. "
|
||||
f"Источник: {item.ref or 'не указан'}"
|
||||
f"{_finding_origin(item)}{_finding_decision(item)}", muted))
|
||||
|
||||
section("Компетенции")
|
||||
for item in report.competencies:
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ class _Builder:
|
|||
norm=norm,
|
||||
ref=ref,
|
||||
competency=competency,
|
||||
metric_key=key,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -174,6 +175,7 @@ def evaluate(
|
|||
norm=f"эталонный вопрос: «{question}»" if question else "обязательный факт сценария",
|
||||
ref="чек-лист сценария",
|
||||
competency=Competency.INTERVIEW,
|
||||
metric_key="checklist_completeness",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
|
|||
self_assessment_diff=difference,
|
||||
notes=[InstructorNoteShown.model_validate(note) for note in state.notes],
|
||||
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)),
|
||||
overridden_by=score.get("overridden_by"),
|
||||
override_comment=score.get("override_comment"),
|
||||
|
|
|
|||
193
backend/app/scoring/review.py
Normal file
193
backend/app/scoring/review.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"""Разбор по отметкам: преподаватель подтверждает, снимает и добавляет отметку.
|
||||
|
||||
Баллы по-прежнему считает код. Преподаватель меняет входы — какие отметки
|
||||
действуют, — а не формулу: балл пересчитывается теми же метриками и весами.
|
||||
Связь «отметка → метрика» — методика `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 INSTRUCTOR_METRIC, METRIC_MAP
|
||||
|
||||
__all__ = ["INSTRUCTOR_METRIC", "add_finding", "has_finding", "linked", "review_finding",
|
||||
"reviewed_metrics"]
|
||||
|
||||
|
||||
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],
|
||||
instructor_weight: float = 1.0) -> 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,
|
||||
weight=instructor_weight,
|
||||
card=finding.card,
|
||||
service=finding.service,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def _instructor_weight(report: dict) -> float:
|
||||
"""Вес метрики преподавателя из весов занятия: веса настраивает преподаватель."""
|
||||
criteria = (report.get("full_report") or {}).get("criteria") or {}
|
||||
return float((criteria.get("score_weights") or {}).get(INSTRUCTOR_METRIC, 1.0))
|
||||
|
||||
|
||||
def _codes(findings: list[Finding]) -> dict[str, int]:
|
||||
"""Коды действующих отметок: по ним строятся частые ошибки группы и профиль."""
|
||||
codes: dict[str, int] = {}
|
||||
for finding in findings:
|
||||
if not _dismissed(finding):
|
||||
codes[finding.code.value] = codes.get(finding.code.value, 0) + 1
|
||||
return codes
|
||||
|
||||
|
||||
def _cards(cards: list[dict], metrics: list[Metric], findings: list[Finding]) -> list[dict]:
|
||||
"""Карточки очереди ДДС с тем же разбором: их метрики — часть метрик занятия."""
|
||||
updated = []
|
||||
for number, card in enumerate(cards, 1):
|
||||
own = [metric for metric in metrics if metric.card == number]
|
||||
updated.append({
|
||||
**card,
|
||||
"metrics": [metric.model_dump(mode="json") for metric in own],
|
||||
"findings": [finding.model_dump(mode="json")
|
||||
for finding in findings if finding.card == number],
|
||||
"score_reviewed": GostResult(metrics=own).score,
|
||||
})
|
||||
return updated
|
||||
|
||||
|
||||
def _rescore(report: dict, findings: list[Finding]) -> dict:
|
||||
metrics = reviewed_metrics(
|
||||
[Metric.model_validate(item) for item in report.get("metrics", [])], findings,
|
||||
_instructor_weight(report),
|
||||
)
|
||||
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}
|
||||
if "summary" in report:
|
||||
updated["summary"] = {**(report.get("summary") or {}), "codes": _codes(findings)}
|
||||
if report.get("card_results"):
|
||||
updated["card_results"] = _cards(report["card_results"], metrics, findings)
|
||||
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,
|
||||
"card_results": _cards(archived.get("card_results") or [], metrics, findings),
|
||||
"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={"reviews": [*findings[index].reviews, review]})
|
||||
return _rescore(report, findings)
|
||||
|
||||
|
||||
def has_finding(report: dict, client_id) -> bool:
|
||||
"""Ручная отметка с этим ключом уже в разборе — повтор запроса."""
|
||||
return client_id is not None and any(
|
||||
finding.client_id == client_id for finding in _findings(report))
|
||||
|
||||
|
||||
def add_finding(report: dict, finding: Finding) -> dict:
|
||||
"""Добавить отметку преподавателя; она штрафует связанную метрику."""
|
||||
if finding.source is not FindingSource.INSTRUCTOR:
|
||||
raise ValueError("вручную добавляется только отметка преподавателя")
|
||||
# Повтор (ретрай сети, вторая вкладка) не штрафует второй раз.
|
||||
if has_finding(report, finding.client_id):
|
||||
return report
|
||||
return _rescore(report, [*_findings(report), finding])
|
||||
|
|
@ -35,6 +35,11 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
|
|||
"description_grammar": (ErrorCode.E4, Competency.COMMUNICATION),
|
||||
}
|
||||
|
||||
#: Метрика ручной отметки, которой нечего провалить (у кода нет метрики в
|
||||
#: METRIC_MAP или в карточке она не считалась). Кода у неё нет: код несёт сама
|
||||
#: отметка. Вес задаёт преподаватель в весах занятия, по умолчанию 1.
|
||||
INSTRUCTOR_METRIC = "instructor_finding"
|
||||
|
||||
#: Вес метрики в детерминированной оценке.
|
||||
#:
|
||||
#: **Предварительные значения, требуют утверждения методистом.** Без весов все
|
||||
|
|
|
|||
|
|
@ -95,4 +95,5 @@ def time_metric(
|
|||
norm=norm_text,
|
||||
ref=TIME_REF,
|
||||
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.state import DdsCardRecord, DdsLiveCard, now_utc
|
||||
from app.session.store import (
|
||||
FindingAdded,
|
||||
FindingReviewed,
|
||||
LessonEnded,
|
||||
ScoreArchived,
|
||||
ScoreCalculated,
|
||||
ScoreOverridden,
|
||||
apply_finding_change,
|
||||
apply_score_override,
|
||||
)
|
||||
|
||||
|
|
@ -100,9 +103,11 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
|||
card_id=card.card_id,
|
||||
scenario_id=card.scenario.id,
|
||||
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],
|
||||
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],
|
||||
actions=actions,
|
||||
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)
|
||||
state.score = apply_score_override(state.score, override)
|
||||
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.state import SessionState, now_utc
|
||||
from app.session.store import (
|
||||
FindingAdded,
|
||||
FindingReviewed,
|
||||
HintRecorded,
|
||||
LessonEnded,
|
||||
LessonIdentity,
|
||||
|
|
@ -42,8 +44,10 @@ from app.session.store import (
|
|||
SelfAssessed,
|
||||
SessionLeaseLost,
|
||||
UtteranceAppended,
|
||||
apply_finding_change,
|
||||
apply_score_override,
|
||||
)
|
||||
from app.scoring.review import has_finding
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
LEASE_SECONDS = 15
|
||||
|
|
@ -239,6 +243,40 @@ class PostgresSessionStore:
|
|||
detail=(f"{score.score_auto} → {record.score_final}; "
|
||||
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}")
|
||||
# Повтор ручной отметки под блокировкой: ни изменения, ни второй строки аудита.
|
||||
if (isinstance(record, FindingAdded)
|
||||
and has_finding(score.report or {}, record.finding.client_id)):
|
||||
return
|
||||
before = score.score_final
|
||||
findings = (score.report or {}).get("findings", [])
|
||||
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):
|
||||
code = (findings[record.index].get("code", "?")
|
||||
if 0 <= record.index < len(findings) else "?")
|
||||
actor, action = record.review.author, "finding.review"
|
||||
what = (f"#{record.index} {code} {record.review.decision.value}; "
|
||||
f"причина: {record.review.reason}")
|
||||
else:
|
||||
finding = record.finding
|
||||
actor, action = finding.author or "instructor", "finding.add"
|
||||
where = f", карточка {finding.card}" if finding.card is not None else ""
|
||||
what = (f"{finding.code.value}{where}; факт: {finding.fact}; "
|
||||
f"норма: {finding.norm or ''}")
|
||||
# Решение по отметке меняет чужой балл: аудит сам по себе должен
|
||||
# ответить «кто, когда, что и почему», даже если разбор потом правили.
|
||||
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()
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -210,6 +210,7 @@ async def score_session(
|
|||
norm="грамматически корректное описание",
|
||||
ref="критерий занятия; правила русского языка",
|
||||
competency=Competency.COMMUNICATION,
|
||||
metric_key="description_grammar",
|
||||
))
|
||||
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
|
||||
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ from typing import Protocol
|
|||
from uuid import UUID
|
||||
|
||||
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.state import SessionState
|
||||
|
||||
|
|
@ -83,8 +85,31 @@ class ScoreOverridden:
|
|||
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
|
||||
| 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:
|
||||
|
|
@ -200,6 +225,10 @@ class MemorySessionStore:
|
|||
if session_id not in self.scores:
|
||||
raise LookupError(f"нет оценки занятия {session_id}")
|
||||
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)
|
||||
|
||||
def snapshot(self, session_id: UUID) -> dict | None:
|
||||
|
|
|
|||
Loading…
Reference in a new issue