lct-35: преподаватель подтверждает, снимает и добавляет отметки D1–D6 с пересчётом балла
This commit is contained in:
parent
b4420cea16
commit
f98c464f4b
21 changed files with 923 additions and 21 deletions
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])
|
||||
Loading…
Reference in a new issue