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])
|