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)