135 lines
3.8 KiB
Python
135 lines
3.8 KiB
Python
"""领域模型。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass
|
|
from typing import Any
|
|
|
|
|
|
DIMENSIONS = (
|
|
"Clarity",
|
|
"Structure",
|
|
"Executability",
|
|
"Completeness",
|
|
"Constraint Salience",
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class SkillUnit:
|
|
unit_id: str
|
|
level: str
|
|
parent_id: str | None
|
|
heading: str
|
|
text: str
|
|
order: int
|
|
start: int
|
|
end: int
|
|
heading_depth: int | None = None
|
|
|
|
@dataclass
|
|
class CellScore:
|
|
score: float
|
|
evidence: list[str]
|
|
reason: str
|
|
|
|
@dataclass
|
|
class Coordinate:
|
|
unit_id: str
|
|
dimension: str
|
|
normalized_gap: float
|
|
|
|
@dataclass
|
|
class LocalEdit:
|
|
unit_id: str
|
|
dimension: str
|
|
new_text: str
|
|
edit_summary: str
|
|
reason: str
|
|
|
|
@classmethod
|
|
def from_dict(cls, value: dict[str, Any]) -> "LocalEdit":
|
|
required = {"unit_id", "dimension", "new_text", "edit_summary", "reason"}
|
|
missing = sorted(required - value.keys())
|
|
if missing:
|
|
raise ValueError(f"local edit missing fields: {', '.join(missing)}")
|
|
if not all(isinstance(value[key], str) for key in required):
|
|
raise ValueError("local edit fields must be strings")
|
|
return cls(**{key: value[key] for key in cls.__dataclass_fields__})
|
|
|
|
|
|
@dataclass
|
|
class ScoreMatrix:
|
|
level: str
|
|
units: list[SkillUnit]
|
|
columns: dict[str, dict[str, CellScore]]
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"level": self.level,
|
|
"units": [asdict(unit) for unit in self.units],
|
|
"columns": {
|
|
dimension: {unit_id: asdict(cell) for unit_id, cell in column.items()}
|
|
for dimension, column in self.columns.items()
|
|
},
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, value: dict[str, Any]) -> "ScoreMatrix":
|
|
return cls(
|
|
level=str(value["level"]),
|
|
units=[SkillUnit(**item) for item in value["units"]],
|
|
columns={
|
|
dimension: {
|
|
unit_id: CellScore(float(cell["score"]), list(cell["evidence"]), str(cell["reason"]))
|
|
for unit_id, cell in column.items()
|
|
}
|
|
for dimension, column in value["columns"].items()
|
|
},
|
|
)
|
|
|
|
def unit(self, unit_id: str) -> SkillUnit:
|
|
return next(unit for unit in self.units if unit.unit_id == unit_id)
|
|
|
|
def normalized_gaps(self) -> dict[str, float]:
|
|
gaps: dict[str, float] = {}
|
|
for dimension in DIMENSIONS:
|
|
values = [self.columns[dimension][unit.unit_id].score for unit in self.units]
|
|
gaps[dimension] = (max(values) - min(values)) / 4.0 if values else 0.0
|
|
return gaps
|
|
|
|
def select_coordinate(
|
|
self,
|
|
threshold: float,
|
|
dimension: str | None = None,
|
|
excluded: set[tuple[str, str]] | None = None,
|
|
) -> Coordinate | None:
|
|
gaps = self.normalized_gaps()
|
|
excluded = excluded or set()
|
|
|
|
def weak_units(item: str) -> list[SkillUnit]:
|
|
column = self.columns[item]
|
|
maximum = max((cell.score for cell in column.values()), default=0.0)
|
|
return [
|
|
unit for unit in self.units
|
|
if (unit.unit_id, item) not in excluded
|
|
and (maximum - column[unit.unit_id].score) / 4.0 > threshold
|
|
]
|
|
|
|
available = [
|
|
item for item in ([dimension] if dimension else DIMENSIONS)
|
|
if item is not None and gaps[item] > threshold and weak_units(item)
|
|
]
|
|
if not available:
|
|
return None
|
|
dimension = max(available, key=lambda item: gaps[item])
|
|
target = min(
|
|
weak_units(dimension),
|
|
key=lambda unit: (self.columns[dimension][unit.unit_id].score, unit.order),
|
|
)
|
|
return Coordinate(
|
|
target.unit_id,
|
|
dimension,
|
|
gaps[dimension],
|
|
)
|