BuyingAgent/backend/app/scoring/product.py

87 lines
3.3 KiB
Python
Raw Permalink Normal View History

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)