BuyingAgent/backend/app/domain/validation.py

308 lines
13 KiB
Python
Raw Normal View History

from __future__ import annotations
import hashlib
import json
from decimal import Decimal
from typing import Any
from app.domain.constraints import operator_allowed_for
from app.domain.enums import (
ConstraintOperator,
OfferCondition,
PreferenceDirection,
PreferenceStrategy,
ValueType,
)
from app.domain.models import (
AttributeDefinition,
Budget,
ClassificationBound,
Constraint,
Preference,
Profile,
ScoringModel,
)
SNAKE_CASE = r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$"
CURRENCY = r"^[A-Z]{3}$"
SUPPORTED_ALGORITHM = "linear-budget-history-v1"
class DomainValidationError(ValueError):
def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
super().__init__(message)
self.details = details or {}
def canonical_json(data: Any) -> str:
return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def sha256_text(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _decimal(value: Any, field: str) -> Decimal:
if isinstance(value, bool) or value is None:
raise DomainValidationError(f"{field} must be a decimal", {"field": field})
try:
return Decimal(str(value))
except Exception as exc:
raise DomainValidationError(f"{field} must be a decimal", {"field": field}) from exc
def _require(mapping: dict[str, Any], key: str) -> Any:
if key not in mapping:
raise DomainValidationError(f"missing field {key}", {"field": key})
return mapping[key]
def validate_attribute_key(key: str) -> None:
import re
if not re.fullmatch(SNAKE_CASE, key):
raise DomainValidationError("attribute key must be snake_case", {"key": key})
def validate_currency(value: str) -> str:
import re
if not re.fullmatch(CURRENCY, value):
raise DomainValidationError("currency must be a 3-letter uppercase code", {"currency": value})
return value
def parse_scoring_model(definition: dict[str, Any]) -> ScoringModel:
version = str(_require(definition, "version"))
algorithm_id = str(_require(definition, "algorithm_id"))
if algorithm_id != SUPPORTED_ALGORITHM:
raise DomainValidationError("unsupported algorithm_id", {"algorithm_id": algorithm_id})
strategies = tuple(str(item) for item in _require(definition, "supported_preference_strategies"))
if not strategies:
raise DomainValidationError("supported_preference_strategies must not be empty")
if PreferenceStrategy.NUMERIC_LINEAR.value not in strategies:
raise DomainValidationError("generic-v1 must support numeric_linear")
min_conf = _decimal(_require(definition, "minimum_confidence"), "minimum_confidence")
if not Decimal("0") <= min_conf <= Decimal("1"):
raise DomainValidationError("minimum_confidence must be between 0 and 1")
max_age = int(_require(definition, "maximum_price_age_hours"))
history_window = int(_require(definition, "history_window_days"))
min_history = int(_require(definition, "minimum_history_days"))
if max_age <= 0 or history_window <= 0 or min_history <= 0:
raise DomainValidationError("time windows must be positive")
product_weight = _decimal(_require(definition, "product_weight"), "product_weight")
price_weight = _decimal(_require(definition, "price_weight"), "price_weight")
budget_weight = _decimal(_require(definition, "budget_weight"), "budget_weight")
history_weight = _decimal(_require(definition, "history_weight"), "history_weight")
for name, weight in (
("product_weight", product_weight),
("price_weight", price_weight),
("budget_weight", budget_weight),
("history_weight", history_weight),
):
if weight < 0:
raise DomainValidationError(f"{name} must be non-negative")
if product_weight + price_weight != Decimal("1"):
raise DomainValidationError("product_weight + price_weight must equal 1")
if budget_weight + history_weight != Decimal("1"):
raise DomainValidationError("budget_weight + history_weight must equal 1")
bounds_raw = _require(definition, "classification_upper_bounds")
bounds: list[ClassificationBound] = []
previous = Decimal("0")
for index, raw in enumerate(bounds_raw):
upper = _decimal(raw["upper"], f"classification_upper_bounds[{index}].upper")
inclusive = bool(raw["inclusive"])
label = str(raw["label"])
if upper < previous or (index and upper == previous):
raise DomainValidationError("classification bounds must be strictly ascending")
if index == 0 and upper <= 0:
raise DomainValidationError("classification bounds must start above 0")
bounds.append(ClassificationBound(upper=upper, inclusive=inclusive, label=label))
previous = upper
if not bounds or bounds[-1].upper != Decimal("100"):
raise DomainValidationError("classification bounds must end at 100")
canonical = json.loads(canonical_json(definition))
return ScoringModel(
version=version,
algorithm_id=algorithm_id,
supported_preference_strategies=strategies,
minimum_confidence=min_conf,
maximum_price_age_hours=max_age,
optional_constraint_penalty_points=_decimal(
_require(definition, "optional_constraint_penalty_points"),
"optional_constraint_penalty_points",
),
missing_preference_score=_decimal(
_require(definition, "missing_preference_score"),
"missing_preference_score",
),
product_weight=product_weight,
price_weight=price_weight,
budget_weight=budget_weight,
history_weight=history_weight,
history_window_days=history_window,
minimum_history_days=min_history,
history_discount_for_max_score=_decimal(
_require(definition, "history_discount_for_max_score"),
"history_discount_for_max_score",
),
classification_upper_bounds=tuple(bounds),
definition=canonical,
definition_hash=sha256_text(canonical_json(canonical)),
)
def parse_profile(
definition: dict[str, Any],
*,
attribute_definitions: dict[str, AttributeDefinition],
scoring_versions: set[str],
supported_strategies: set[str],
) -> Profile:
profile_id = str(_require(definition, "id"))
version = int(_require(definition, "version"))
if version <= 0:
raise DomainValidationError("profile version must be > 0")
name = str(_require(definition, "name"))
category = str(_require(definition, "category"))
if not category:
raise DomainValidationError("category is required")
currency = validate_currency(str(_require(definition, "currency")))
market = str(_require(definition, "market"))
if not market:
raise DomainValidationError("market is required")
raw_conditions = _require(definition, "allowed_conditions")
if not raw_conditions:
raise DomainValidationError("allowed_conditions is required")
conditions = tuple(OfferCondition(item) for item in raw_conditions)
scoring_version = str(_require(definition, "scoring_version"))
if scoring_version not in scoring_versions:
raise DomainValidationError("unknown scoring_version", {"scoring_version": scoring_version})
budget_raw = _require(definition, "budget")
target = _decimal(_require(budget_raw, "target"), "budget.target")
maximum = _decimal(_require(budget_raw, "maximum"), "budget.maximum")
if not (Decimal("0") < target < maximum):
raise DomainValidationError("budget must satisfy 0 < target < maximum")
constraints = _parse_constraints(_require(definition, "constraints"), attribute_definitions)
preferences = _parse_preferences(
_require(definition, "preferences"),
attribute_definitions,
supported_strategies,
)
canonical = json.loads(canonical_json(definition))
return Profile(
id=profile_id,
version=version,
name=name,
category=category,
currency=currency,
market=market,
allowed_conditions=conditions,
budget=Budget(target=target, maximum=maximum),
scoring_version=scoring_version,
constraints=constraints,
preferences=preferences,
definition=canonical,
definition_hash=sha256_text(canonical_json(canonical)),
)
def _parse_constraints(
raw_items: list[dict[str, Any]],
attribute_definitions: dict[str, AttributeDefinition],
) -> tuple[Constraint, ...]:
seen: set[str] = set()
items: list[Constraint] = []
for raw in raw_items:
constraint_id = str(_require(raw, "id"))
if constraint_id in seen:
raise DomainValidationError("duplicate constraint id", {"id": constraint_id})
seen.add(constraint_id)
attribute = str(_require(raw, "attribute"))
definition = attribute_definitions.get(attribute)
if definition is None:
raise DomainValidationError("unknown attribute key", {"attribute": attribute})
operator = ConstraintOperator(str(_require(raw, "operator")))
if not operator_allowed_for(definition.value_type, operator):
raise DomainValidationError(
"operator is not valid for attribute type",
{"attribute": attribute, "operator": operator.value, "value_type": definition.value_type.value},
)
value = raw.get("value")
if operator == ConstraintOperator.EXISTS:
if "value" in raw and raw["value"] is not None:
raise DomainValidationError("exists must not have a value", {"id": constraint_id})
value = None
elif operator == ConstraintOperator.IN:
if not isinstance(value, list) or not value:
raise DomainValidationError("in requires a non-empty list", {"id": constraint_id})
elif operator == ConstraintOperator.CONTAINS:
if not isinstance(value, str):
raise DomainValidationError("contains requires a string", {"id": constraint_id})
items.append(
Constraint(
id=constraint_id,
attribute=attribute,
operator=operator,
value=value,
mandatory=bool(raw.get("mandatory", True)),
)
)
return tuple(items)
def _parse_preferences(
raw_items: list[dict[str, Any]],
attribute_definitions: dict[str, AttributeDefinition],
supported_strategies: set[str],
) -> tuple[Preference, ...]:
if not raw_items:
raise DomainValidationError("at least one preference is required")
seen: set[str] = set()
items: list[Preference] = []
for raw in raw_items:
preference_id = str(_require(raw, "id"))
if preference_id in seen:
raise DomainValidationError("duplicate preference id", {"id": preference_id})
seen.add(preference_id)
attribute = str(_require(raw, "attribute"))
definition = attribute_definitions.get(attribute)
if definition is None:
raise DomainValidationError("unknown attribute key", {"attribute": attribute})
strategy = str(_require(raw, "strategy"))
if strategy not in supported_strategies:
raise DomainValidationError("unsupported preference strategy", {"strategy": strategy})
if strategy != PreferenceStrategy.NUMERIC_LINEAR.value:
raise DomainValidationError("v0.1 only supports numeric_linear", {"strategy": strategy})
if definition.value_type != ValueType.NUMBER:
raise DomainValidationError("numeric_linear requires a number attribute", {"attribute": attribute})
direction = PreferenceDirection(str(_require(raw, "direction")))
acceptable = _decimal(_require(raw, "acceptable"), "acceptable")
ideal = _decimal(_require(raw, "ideal"), "ideal")
weight = _decimal(_require(raw, "weight"), "weight")
if weight <= 0:
raise DomainValidationError("preference weight must be positive", {"id": preference_id})
if direction == PreferenceDirection.HIGHER and not ideal > acceptable:
raise DomainValidationError("higher requires ideal > acceptable", {"id": preference_id})
if direction == PreferenceDirection.LOWER and not ideal < acceptable:
raise DomainValidationError("lower requires ideal < acceptable", {"id": preference_id})
items.append(
Preference(
id=preference_id,
attribute=attribute,
strategy=PreferenceStrategy.NUMERIC_LINEAR,
direction=direction,
acceptable=acceptable,
ideal=ideal,
weight=weight,
)
)
return tuple(items)