147 lines
7.3 KiB
Python
147 lines
7.3 KiB
Python
"""Разбор по отметкам: преподаватель подтверждает, снимает и добавляет отметку.
|
||
|
||
Баллы по-прежнему считает код. Преподаватель меняет входы — какие отметки
|
||
действуют, — а не формулу: балл пересчитывается теми же метриками и весами.
|
||
Связь «отметка → метрика» — методика `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])
|