87 lines
3.3 KiB
Python
87 lines
3.3 KiB
Python
from decimal import Decimal
|
|
|
|
from app.domain.attributes import statement_is_usable
|
|
from app.domain.enums import ConstraintStatus, PreferenceDirection
|
|
from app.domain.models import (
|
|
AttributeStatement,
|
|
ConstraintResult,
|
|
Preference,
|
|
ScoreComponent,
|
|
ScoringModel,
|
|
)
|
|
from app.scoring.mathutil import as_decimal, clamp
|
|
|
|
|
|
def score_numeric_linear(value: Decimal, acceptable: Decimal, ideal: Decimal, direction: PreferenceDirection) -> Decimal:
|
|
if direction == PreferenceDirection.HIGHER:
|
|
return clamp(Decimal("50") + Decimal("50") * (value - acceptable) / (ideal - acceptable))
|
|
return clamp(Decimal("50") + Decimal("50") * (acceptable - value) / (acceptable - ideal))
|
|
|
|
|
|
def score_product(
|
|
preferences: tuple[Preference, ...],
|
|
snapshot: dict[str, AttributeStatement],
|
|
constraint_results: tuple[ConstraintResult, ...],
|
|
model: ScoringModel,
|
|
) -> tuple[Decimal, Decimal, tuple[ScoreComponent, ...]]:
|
|
components: list[ScoreComponent] = []
|
|
weighted = Decimal("0")
|
|
total_weight = sum((pref.weight for pref in preferences), Decimal("0"))
|
|
usable_weight = Decimal("0")
|
|
|
|
for preference in preferences:
|
|
statement = snapshot.get(preference.attribute)
|
|
usable = statement_is_usable(statement, model.minimum_confidence)
|
|
if usable:
|
|
raw = score_numeric_linear(
|
|
as_decimal(statement.normalized_value),
|
|
preference.acceptable,
|
|
preference.ideal,
|
|
preference.direction,
|
|
)
|
|
usable_weight += preference.weight
|
|
else:
|
|
raw = model.missing_preference_score
|
|
contribution = preference.weight * raw
|
|
weighted += contribution
|
|
components.append(
|
|
ScoreComponent(
|
|
kind="preference",
|
|
component_key=preference.id,
|
|
raw_score=raw,
|
|
weight=preference.weight,
|
|
contribution=contribution,
|
|
details={
|
|
"attribute": preference.attribute,
|
|
"strategy": preference.strategy.value,
|
|
"direction": preference.direction.value,
|
|
"acceptable": preference.acceptable,
|
|
"ideal": preference.ideal,
|
|
"usable": usable,
|
|
"attribute_row_id": None if statement is None else str(statement.id),
|
|
},
|
|
)
|
|
)
|
|
|
|
base = weighted / total_weight if total_weight else Decimal("0")
|
|
optional_issues = [
|
|
result
|
|
for result in constraint_results
|
|
if not result.mandatory and result.status in {ConstraintStatus.FAIL.value, ConstraintStatus.UNKNOWN.value}
|
|
]
|
|
penalty = model.optional_constraint_penalty_points * Decimal(len(optional_issues))
|
|
product_score = clamp(base - penalty)
|
|
if optional_issues:
|
|
components.append(
|
|
ScoreComponent(
|
|
kind="optional_penalty",
|
|
component_key="optional_constraints",
|
|
raw_score=penalty,
|
|
weight=None,
|
|
contribution=-penalty,
|
|
details={"count": len(optional_issues), "constraint_ids": [item.constraint_id for item in optional_issues]},
|
|
)
|
|
)
|
|
coverage = (usable_weight / total_weight) if total_weight else Decimal("0")
|
|
return product_score, coverage, tuple(components)
|