"""kb_rule DSL 的解析校验与纯匹配(TD-10 决策表,知识库文档 §3.2⑩)。 规则是数据不是代码(NFR-MAINT-05):本模块只回答两个问题——"一条规则是否 合法"与"一条规则是否命中画像",不碰任何 I/O。 **封闭词汇表**:未识别的键一律拒绝而非忽略。规则里写错一个键名却被静默忽略, 意味着它悄悄不生效——给用户的建议变味且无人察觉,比写入时报错危险得多。 这是"专业可信"红线在数据侧的落地(FR-KB-02)。 条件子句语义(子句间全部 AND): - 等值(str):画像字段等于该值;画像字段是集合时退化为"包含该值"。 - ``include``:标量字段=命中列表任一值;集合字段=列表全部在场(子集语义)。 - ``exclude``:画像取值与列表没有任何交集。 - ``min``/``max``:数值闭区间,仅对数值字段(temperature)有意义。 - 画像缺字段(None):该子句不匹配 → 整条规则不命中。 """ from __future__ import annotations from dataclasses import dataclass, fields from typing import Any, Self from pydantic import BaseModel, ConfigDict, Field, model_validator # ── 优先级分组(知识库文档 §3.2⑩:数字越大越优先)────────────────────────── PRIORITY_COMPLIANCE = 100 # 合规硬约束(发布红线) PRIORITY_USER_TABOO = 95 # 用户禁忌:永远压过专业推荐 PRIORITY_BODY_SHAPE = 90 # 体型 PRIORITY_COLOR = 85 # 色彩 PRIORITY_OCCASION = 80 # 场景 PRIORITY_TEMPERATURE = 75 # 温度 PRIORITY_STYLE = 70 # 风格 PRIORITY_PREFERENCE = 60 # 偏好 PRIORITY_TREND = 50 # 趋势 @dataclass(frozen=True, slots=True) class RuleContext: """求值输入:一份用户画像。字段缺失(None/空集合)= 相关子句不匹配。""" body_shape: str | None = None season: str | None = None face_shape: str | None = None temperature: float | None = None features: tuple[str, ...] = () goals: tuple[str, ...] = () occasion: str | None = None style: str | None = None user_taboos: tuple[str, ...] = () #: 条件字段只能引用 RuleContext 上真实存在的字段——typo 在解析时就暴露, #: 而不是等到线上作为"永不命中的死规则"被发现。 CONTEXT_FIELDS: frozenset[str] = frozenset(f.name for f in fields(RuleContext)) class ConditionClause(BaseModel): """集合/区间子句:min/max=闭区间;include/exclude 按字段类型取语义。 - 标量字段(body_shape/season/…):``include`` = 字段值命中列表任一值; - 集合字段(features/goals/user_taboos):``include`` = 列表全部在场 (子集语义——决策表要精确命中,宁可少命中也不误命中,想要"任一满足" 就拆成多条规则); - ``exclude`` 两种类型对称:与列表有任何交集即不匹配。 """ model_config = ConfigDict(extra="forbid") include: tuple[str, ...] | None = None exclude: tuple[str, ...] | None = None min: float | None = None max: float | None = None @model_validator(mode="after") def _must_constrain_something(self) -> Self: if self.include is None and self.exclude is None and self.min is None and self.max is None: raise ValueError("条件子句至少要包含 include/exclude/min/max 之一") if self.min is not None and self.max is not None and self.min > self.max: raise ValueError("min 不能大于 max") return self #: 子句取值:等值匹配(str)或集合/区间结构(ConditionClause)。 ClauseValue = str | ConditionClause class RuleCondition(BaseModel): """条件子句集合(子句间 AND)。 对外序列化形状是决策表里的扁平 dict(如 ``{"body_shape": "A型"}``), 内部挂在 ``clauses`` 下是为了复用 pydantic 的键值校验。 空 conditions 合法:表示无条件命中,供合规类兜底规则使用(例如 "全场禁真兽皮");effects 仍必须至少写一项,规则不会变成空操作。 """ model_config = ConfigDict(extra="forbid") clauses: dict[str, ClauseValue] = Field(default_factory=dict) @model_validator(mode="before") @classmethod def _wrap_flat_dict(cls, data: Any) -> Any: # 允许直接传扁平 dict(DB JSONB / 后台表单的自然形状)。 if isinstance(data, dict) and "clauses" not in data: return {"clauses": data} return data @model_validator(mode="after") def _fields_must_exist_on_context(self) -> Self: unknown = sorted(set(self.clauses) - CONTEXT_FIELDS) if unknown: raise ValueError(f"未识别的条件字段 {unknown},可用字段:{sorted(CONTEXT_FIELDS)}") return self class SlotAttrs(BaseModel): """单个品类的属性约束:slot + 每个属性维度上的取值集合。 ``attrs`` 为空表示对整个 slot 推荐/回避(如"回避泳装品类")。维度名开放 (shoulder/sleeve/silhouette/…由衣橱属性体系决定,不能收口),但每个维度 的取值列表必须非空——空列表约束不了任何东西,多半是漏填。 """ model_config = ConfigDict(extra="forbid") slot: str = Field(min_length=1, max_length=32) attrs: dict[str, tuple[str, ...]] = Field(default_factory=dict) @model_validator(mode="after") def _values_must_not_be_empty(self) -> Self: for dim, values in self.attrs.items(): if not dim: raise ValueError("属性维度名不能为空") if not values: raise ValueError(f"属性 {dim} 的取值列表不能为空") return self class RuleEffect(BaseModel): """规则效果:推荐什么、回避什么、怎么向用户解释。""" model_config = ConfigDict(extra="forbid") recommend: tuple[SlotAttrs, ...] = () avoid: tuple[SlotAttrs, ...] = () explanation: str | None = Field(default=None, max_length=500) @model_validator(mode="after") def _at_least_one_effect(self) -> Self: if not (self.recommend or self.avoid or self.explanation): raise ValueError("effects 至少要包含 recommend/avoid/explanation 之一") return self class Rule(BaseModel): """一条已通过 DSL 校验的决策表规则(求值引擎的唯一输入类型)。""" model_config = ConfigDict(extra="forbid") rule_key: str = Field(min_length=1, max_length=64) category: str = Field(min_length=1, max_length=32) priority: int = Field(ge=0, le=100) conditions: RuleCondition = Field(default_factory=RuleCondition) effects: RuleEffect is_hard: bool = False confidence: float = Field(default=0.9, ge=0.0, le=1.0) # ── 匹配(纯逻辑,重点单测对象)──────────────────────────────────────────── def match(condition: RuleCondition, ctx: RuleContext) -> bool: """所有子句 AND;画像缺字段(None)= 该子句不匹配 → 整条规则不命中。""" for name, clause in condition.clauses.items(): if not _clause_matches(clause, getattr(ctx, name, None)): return False return True def _as_options(value: Any) -> tuple[str, ...] | None: """把画像字段值规整成可做集合判断的取值元组;不适用的类型返回 None。""" if isinstance(value, str): return (value,) if isinstance(value, (tuple, list, frozenset, set)): return tuple(value) return None def _is_number(value: Any) -> bool: # bool 是 int 的子类,但 True 不是任何字段的合法温度值。 return isinstance(value, (int, float)) and not isinstance(value, bool) def _clause_matches(clause: ClauseValue, value: Any) -> bool: if value is None: return False # 缺字段 = 不匹配该子句 if isinstance(clause, str): options = _as_options(value) return options is not None and clause in options if clause.include is not None or clause.exclude is not None: if isinstance(value, str): # 标量字段只能取一个值:include=命中列表任一值。 if clause.include is not None and value not in clause.include: return False if clause.exclude is not None and value in clause.exclude: return False else: options = _as_options(value) if options is None: return False present = set(options) # 集合字段:include=列表全部在场(精确命中)。 if clause.include is not None and not set(clause.include) <= present: return False if clause.exclude is not None and set(clause.exclude) & present: return False if clause.min is not None or clause.max is not None: if not _is_number(value): return False if clause.min is not None and value < clause.min: return False if clause.max is not None and value > clause.max: return False return True