| """ |
| Personalization rule DSL. |
| |
| Rules are persisted in ``ix_personalization_rules`` with two JSON |
| fields: |
| |
| condition = a dict describing the match criteria |
| action = a dict describing what the rule does |
| |
| Supported condition keys (phase 1): |
| role str viewer role must equal |
| level str viewer level must equal |
| language str viewer language must equal |
| country str viewer country must equal |
| has_tag str viewer tags must contain |
| mood str character_mood must equal |
| min_affinity float character affinity_score >= value |
| max_affinity float character affinity_score <= value |
| metric dict { scheme, key, min?, max? } — progress metric in range |
| |
| Supported action keys (phase 1): |
| route_to_node str override the next_node_id with this |
| prefer_tone str override the tone for this turn |
| bump_affinity float delta to apply to affinity_score |
| |
| A rule is 'applicable' if ALL conditions pass. The evaluator picks |
| the highest-priority applicable rule (lowest priority wins ties). |
| """ |
| from __future__ import annotations |
|
|
| from dataclasses import dataclass, field |
| from typing import Any, Dict, List, Optional |
|
|
|
|
| _ALLOWED_CONDITION_KEYS = { |
| "role", "level", "language", "country", "has_tag", |
| "mood", "min_affinity", "max_affinity", "metric", |
| } |
| _ALLOWED_ACTION_KEYS = { |
| "route_to_node", "prefer_tone", "bump_affinity", |
| } |
|
|
|
|
| @dataclass(frozen=True) |
| class RuleCondition: |
| """Resolved condition object.""" |
|
|
| raw: Dict[str, Any] |
|
|
| def get(self, key: str, default: Any = None) -> Any: |
| return self.raw.get(key, default) |
|
|
|
|
| @dataclass(frozen=True) |
| class Rule: |
| """A personalization rule, ready for the evaluator.""" |
|
|
| id: str |
| name: str |
| condition: RuleCondition |
| action: Dict[str, Any] |
| priority: int = 100 |
| enabled: bool = True |
|
|
|
|
| def validate_rule(condition: Dict[str, Any], action: Dict[str, Any]) -> List[str]: |
| """Return a list of problems (empty list = valid).""" |
| problems: List[str] = [] |
| for k in condition: |
| if k not in _ALLOWED_CONDITION_KEYS: |
| problems.append(f"unknown condition key: {k}") |
| for k in action: |
| if k not in _ALLOWED_ACTION_KEYS: |
| problems.append(f"unknown action key: {k}") |
| metric = condition.get("metric") if isinstance(condition.get("metric"), dict) else None |
| if metric is not None: |
| if not metric.get("scheme") or not metric.get("key"): |
| problems.append("metric condition requires 'scheme' and 'key'") |
| if "bump_affinity" in action: |
| try: |
| float(action["bump_affinity"]) |
| except (TypeError, ValueError): |
| problems.append("bump_affinity must be numeric") |
| return problems |
|
|