Files
meida_backend/packages/rules/dsl.py
T
xsl e50a9424a1 feat(rules): data-driven decision table engine with hard constraints
kb_rule DSL 解析 + 纯域求值(优先级分组/硬约束/用户禁忌压过专业推荐)
+ 400 条基准 + 黄金集雏形(T-P2-02,TD-10)。

实现:dsl.py 封闭词汇表 pydantic 校验(未识别键拒绝)+ match 纯逻辑
(等值/include/exclude/min-max,缺字段不命中);engine.py 纯域求值
(priority 降序、confidence 次序、is_hard avoid → hard_blocks、高优先级
avoid 剔除冲突 recommend);service.py+repo.py published 整表载入进程内
缓存 + CRUD + draft→published→archived 状态机(同 T6 语义)。
测试:DSL 解析与 match 矩阵、排序/硬约束/禁忌优先、状态机矩阵、
黄金集 11 组(含用户禁忌压过专业推荐与多规则叠加)、400 条 × 1000 次
基准(实测均值 0.26ms/次、p95 0.41ms,预算 50ms);集成 3 用例
(published 载入命中拦截、draft 不载入、DSL 写入拒绝与状态机守卫)。
单测 310 通过(+72),集成 19 通过(+3)。

偏差说明:
1. 冲突仲裁按通用优先级语义实现(高优先级 avoid 剔除"严格更低"优先级的
   冲突 recommend),任务文档的"用户禁忌(≥95)永远压过专业推荐(≤90)"
   是该规则的特例,黄金集已含该场景用例。
2. include 子句按字段类型取语义:标量字段=命中列表任一值;集合字段=
   列表全部在场(子集,精确命中);文档未区分,此实现两类字段均可用。
3. 单测引擎文件命名为 tests/unit/test_rules_evaluation.py(而非
   test_rules_engine.py):与契约指定的集成文件 tests/integration/
   test_rules_engine.py 同名会导致 pytest 收集冲突(tests 目录非包)。
4. 集成测试每用例独立 engine 并 dispose:pytest-asyncio 每用例一个事件
   循环,直接用全局 session 工厂会让连接跨 loop 污染其它集成用例。
5. 规则不提供物理删除(命中链需可追溯,下线走 archived);kb_rule 契约
   无 reviewing 状态,状态机为 draft→published→archived(draft 可撤稿)。
2026-08-14 20:10:21 +08:00

227 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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