ebe33392ca
Preserve document revisions and evidence, harden indexing and export behavior, add explicit material selection and direct-folder import, validate model JSON schemas with safe error messages, and retain student selection while loading scoring data.
88 lines
4.0 KiB
Python
88 lines
4.0 KiB
Python
from typing import Literal
|
|
from uuid import uuid4
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
|
|
|
|
class EvidenceTarget(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
target_id: str = Field(default_factory=lambda: uuid4().hex, min_length=1, max_length=50)
|
|
label: str = Field(min_length=1, max_length=300)
|
|
use: Literal["content", "structure"]
|
|
allowed_page_types: list[Literal["cover", "abstract", "toc", "body", "appendix", "references"]] = Field(default_factory=list)
|
|
query: str = ""
|
|
|
|
@model_validator(mode="after")
|
|
def valid_sources(self):
|
|
from .pages import CONTENT_PAGE_TYPES, STRUCTURE_PAGE_TYPES
|
|
permitted = CONTENT_PAGE_TYPES if self.use == "content" else STRUCTURE_PAGE_TYPES
|
|
if not self.label.strip() or not self.target_id.strip():
|
|
raise ValueError("证据目标名称和标识不能为空")
|
|
if not self.allowed_page_types:
|
|
self.allowed_page_types = list(permitted)
|
|
if any(value not in permitted for value in self.allowed_page_types):
|
|
raise ValueError("证据目标的页面类型不能扩大用途权限")
|
|
return self
|
|
|
|
|
|
class ScoreLevel(BaseModel):
|
|
min: float = Field(ge=0)
|
|
max: float = Field(ge=0)
|
|
description: str = Field(min_length=1)
|
|
|
|
|
|
class Criterion(BaseModel):
|
|
criterion_id: str = Field(min_length=1)
|
|
category: str
|
|
name: str = Field(min_length=1)
|
|
max_score: float = Field(gt=0)
|
|
description: str = Field(min_length=1)
|
|
linked_objectives: list[str] = Field(default_factory=list)
|
|
linked_outcomes: list[str] = Field(default_factory=list)
|
|
required_evidence: list[EvidenceTarget | str] = Field(default_factory=list)
|
|
score_levels: list[ScoreLevel] = Field(default_factory=list)
|
|
|
|
@model_validator(mode="after")
|
|
def check_levels(self):
|
|
ids = [target.target_id if isinstance(target, EvidenceTarget) else f"t{index}"
|
|
for index, target in enumerate(self.required_evidence) if not isinstance(target, str) or target.strip()]
|
|
if len(ids) != len(set(ids)):
|
|
raise ValueError("duplicate evidence target_id")
|
|
if not self.score_levels:
|
|
raise ValueError(f"{self.criterion_id}: score levels are required")
|
|
ranges = sorted(self.score_levels, key=lambda x: x.min)
|
|
if ranges[0].min != 0 or ranges[-1].max != self.max_score:
|
|
raise ValueError(f"{self.criterion_id}: score levels must cover 0 through max_score")
|
|
for previous, current in zip(ranges, ranges[1:]):
|
|
if current.min > previous.max + 1 or current.min <= previous.max:
|
|
raise ValueError(f"{self.criterion_id}: score levels overlap or have gaps")
|
|
return self
|
|
|
|
|
|
class RubricData(BaseModel):
|
|
criteria: list[Criterion]
|
|
weights: dict[str, float]
|
|
|
|
@model_validator(mode="after")
|
|
def check_weights(self):
|
|
expected = {c.category for c in self.criteria}
|
|
if not expected or set(self.weights) != expected or abs(sum(self.weights.values()) - 1) > 1e-8 or any(v < 0 for v in self.weights.values()):
|
|
raise ValueError("weights must cover all rubric categories and sum to 1")
|
|
for category in expected:
|
|
if abs(sum(c.max_score for c in self.criteria if c.category == category) - 100) > 1e-8:
|
|
raise ValueError(f"{category} criteria must total 100")
|
|
ids = [c.criterion_id for c in self.criteria]
|
|
if len(set(ids)) != len(ids):
|
|
raise ValueError("duplicate criterion_id")
|
|
return self
|
|
|
|
|
|
def totals(scores: dict[str, float | None], criteria: list[dict], weights: dict[str, float]):
|
|
grouped = {}
|
|
for category in weights:
|
|
group = [c for c in criteria if c["category"] == category]
|
|
values = [scores.get(c["criterion_id"]) for c in group]
|
|
grouped[category] = round(sum(values), 2) if values and all(v is not None for v in values) else None
|
|
overall = round(sum(grouped[k] * weights[k] for k in weights), 2) if all(v is not None for v in grouped.values()) else None
|
|
return {**grouped, "overall": overall}
|