Merge branch 'feat/finding-review'

lct-35: разбор по отметкам — подтвердить, снять, добавить с пересчётом балла
This commit is contained in:
GGlamer 2026-09-27 23:21:24 +03:00 • committed by GitHub
commit c1a67badbf
24 changed files with 1409 additions and 27 deletions

View file

@ -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

View file

@ -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