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)