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}