60 lines
2.6 KiB
Python
60 lines
2.6 KiB
Python
from pydantic import BaseModel, Field, model_validator
|
|
|
|
|
|
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[str] = Field(default_factory=list)
|
|
score_levels: list[ScoreLevel] = Field(default_factory=list)
|
|
|
|
@model_validator(mode="after")
|
|
def check_levels(self):
|
|
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}
|