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 可撤稿)。
227 lines
9.1 KiB
Python
227 lines
9.1 KiB
Python
"""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
|