308 lines
13 KiB
Python
308 lines
13 KiB
Python
|
|
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)
|