merge: T7 规则引擎·数据驱动决策表(T-P2-02,TD-10)

This commit is contained in:
xsl
2026-08-14 20:11:23 +08:00
11 changed files with 1984 additions and 9 deletions
+19 -8
View File
@@ -1,17 +1,28 @@
# `packages/rules/` — 规则引擎(占位)
# `packages/rules` — 规则引擎(TD-10 自研数据驱动决策表)
架构 §8.1 / ADR-05:自研数据驱动决策表(TD-10)。
架构 §8.1 / ADR-05:**结论由规则引擎产出,LLM 只负责表达**(防幻觉红线)。
## 待实现
## 模块
- 从 `kb_rule` 读已发布规则,求值并输出**命中链**
- 硬约束(`is_hard`)校验:可拦截违规输出
- p95 < 50ms(FR-KB-05)
- `dsl.py` —— kb_rule DSL 的 pydantic 解析校验 + 画像匹配(纯逻辑)。封闭词汇表:
未识别的键一律拒绝而非忽略,规则不会静默失效。
- `engine.py` —— 纯域求值:priority 降序(同序按 confidence)、`is_hard` 的 avoid
命中进 `hard_blocks`(调用方必须据此做硬过滤)、高优先级 avoid 剔除严格低优先级
的冲突 recommend(用户禁忌 95 压过专业推荐 ≤90)。
- `repo.py` —— kb_rule 表的唯一读写点(经共享 ORM 访问)。
- `service.py` —— CRUD、draft→published→archived 状态机(同 T6 语义)、published
规则进程内缓存(量级 ~400,整表载入,管理动作后 `invalidate()`)。
## 设计约束
- **规则是数据不是代码**。求值器是代码,规则本身在 `kb_rule` 表里,
顾问在后台维护,改规则不发版(NFR-MAINT-05)。
- 每次求值必须输出**命中了哪几条规则**,这是报告与 Agent 可解释性的来源,
- 每次求值必须输出**命中链**(`rule_hits`:rule_key/priority/category/is_hard/
confidence/explanation),这是报告与 Agent 可解释性的来源,
也是 `pf_diagnosis.rule_hits` 与 `ag_message.rule_hits` 的数据来源。
- 已发布规则集缓存在进程内 + Redis,TTL 5 分钟,发布时广播失效(架构 §10)。
- p95 < 50ms(FR-KB-05):求值是纯内存操作,400 条规则量级实测远低于预算
(见 `tests/unit/test_rules_engine.py` 的基准用例)。
- 黄金集雏形:`tests/unit/test_rules_golden.py`(固定画像 → 期望输出,含硬禁忌
与多规则叠加场景),改引擎行为必须先过黄金集。
- 已发布规则集缓存在进程内;Redis TTL + 发布广播失效(架构 §10)为后续接入项,
失效入口已收敛到 `service.invalidate_published_cache()`。
+7 -1
View File
@@ -1 +1,7 @@
"""规则引擎:决策表求值、硬约束校验、命中链输出。占位。"""
"""规则引擎(TD-10 自研数据驱动决策表):DSL 校验、纯域求值、硬约束拦截、命中链输出。
- :mod:`packages.rules.dsl` —— kb_rule DSL 的 pydantic 解析与画像匹配(纯逻辑)
- :mod:`packages.rules.engine` —— 优先级排序 / 硬约束 / 用户禁忌压过专业推荐
- :mod:`packages.rules.repo` —— kb_rule 表数据访问(唯一读写点)
- :mod:`packages.rules.service` —— CRUD、draft→published 状态机、已发布规则缓存
"""
+226
View File
@@ -0,0 +1,226 @@
"""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
+165
View File
@@ -0,0 +1,165 @@
"""纯域求值引擎(架构 §8.2 rules-engine,TD-10):无 I/O。
防幻觉红线:**结论由规则引擎产出,LLM 只负责表达**。因此本模块是"钱与
正确性"代码——输出必须完全确定、可解释、可回放。
求值流程:
1. ``dsl.match`` 过滤命中规则(子句 AND,缺字段不命中);
2. 按 priority 降序、confidence 降序排序(再按 rule_key 兜底,输出完全确定);
3. 汇总 recommend/avoid(保序去重)与解释;
4. 硬约束:``is_hard`` 规则的 avoid 命中 → ``hard_blocks`` 非空。调用方必须
用其中的 avoid 约束对候选做硬过滤——禁忌违反 = 0 是发布红线;
5. 冲突仲裁:recommend 与 avoid 同 slot 且共享维度上取值相交视为冲突,
**高优先级 avoid 剔除严格低优先级 recommend**。由此:用户禁忌(95)压过
体型(90)及以下的专业推荐,合规(100)压过一切——"数字越大越优先"在
规则之间同样成立,而不是只写死 95 这一个档位。
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from packages.rules import dsl
from packages.rules.dsl import SlotAttrs
#: recommend/avoid 条目的去重键:(slot, 排序后的 (维度, 排序后取值) 序列)。
ItemKey = tuple[str, tuple[tuple[str, tuple[str, ...]], ...]]
@dataclass(frozen=True, slots=True)
class RuleHit:
"""命中链的一环:报告与 Agent 可解释性的数据来源(架构 §8.1)。"""
rule_key: str
priority: int
category: str
is_hard: bool
confidence: float
explanation: str | None
@dataclass(frozen=True, slots=True)
class HardBlock:
"""一条必须执行的硬约束:命中规则元数据 + 待过滤的 avoid 约束。
调用方拿 ``avoid`` 里的 slot/attrs 对候选单品做过滤(同 slot 且任一
维度取值落在列表内即剔除),而不是只拿 rule_key 记日志。
"""
rule_key: str
priority: int
category: str
confidence: float
explanation: str | None
avoid: tuple[SlotAttrs, ...]
@dataclass(frozen=True, slots=True)
class RuleVerdict:
"""一次求值的完整结论。"""
recommend: tuple[SlotAttrs, ...]
avoid: tuple[SlotAttrs, ...]
explanations: tuple[str, ...]
rule_hits: tuple[RuleHit, ...]
hard_blocks: tuple[HardBlock, ...]
@property
def explanation(self) -> str:
"""命中规则的解释按优先级序拼接,供 LLM 直接表达。"""
return ";".join(self.explanations)
@property
def blocked(self) -> bool:
"""是否触发硬拦截。"""
return bool(self.hard_blocks)
def _item_key(item: SlotAttrs) -> ItemKey:
return (
item.slot,
tuple(sorted((dim, tuple(sorted(vals))) for dim, vals in item.attrs.items())),
)
def _conflicts(rec: SlotAttrs, avoid: SlotAttrs) -> bool:
"""同 slot 且至少一个共享维度上取值相交,才视为推荐与回避冲突。
不同维度的 avoid(如"回避修身"之于"推荐垫肩")是互补信号而非冲突:
只有当推荐恰好落进回避圈定的事实集合时才需要仲裁。
"""
if rec.slot != avoid.slot:
return False
return any(
dim in avoid.attrs and set(vals) & set(avoid.attrs[dim]) for dim, vals in rec.attrs.items()
)
def evaluate(rules: Sequence[dsl.Rule], ctx: dsl.RuleContext) -> RuleVerdict:
"""对一份画像求值全部规则,产出推荐/回避/命中链/硬拦截。"""
fired = [r for r in rules if dsl.match(r.conditions, ctx)]
fired.sort(key=lambda r: (-r.priority, -r.confidence, r.rule_key))
rule_hits = tuple(
RuleHit(
rule_key=r.rule_key,
priority=r.priority,
category=r.category,
is_hard=r.is_hard,
confidence=r.confidence,
explanation=r.effects.explanation,
)
for r in fired
)
hard_blocks = tuple(
HardBlock(
rule_key=r.rule_key,
priority=r.priority,
category=r.category,
confidence=r.confidence,
explanation=r.effects.explanation,
avoid=r.effects.avoid,
)
for r in fired
# 硬约束的拦截语义只由 avoid 表达:is_hard 规则没写 avoid 就无物可拦。
if r.is_hard and r.effects.avoid
)
explanations = tuple(r.effects.explanation for r in fired if r.effects.explanation)
# recommend 保序去重,并记住来源优先级供仲裁。
recommend: list[tuple[int, SlotAttrs]] = []
seen_rec: set[ItemKey] = set()
# avoid 保序去重;同一约束来自多条规则时取最高来源优先级参与仲裁。
avoid: list[SlotAttrs] = []
avoid_priority: dict[ItemKey, int] = {}
for r in fired:
for item in r.effects.recommend:
key = _item_key(item)
if key not in seen_rec:
seen_rec.add(key)
recommend.append((r.priority, item))
for item in r.effects.avoid:
key = _item_key(item)
if key in avoid_priority:
avoid_priority[key] = max(avoid_priority[key], r.priority)
else:
avoid_priority[key] = r.priority
avoid.append(item)
kept_recommend = tuple(
item
for prio, item in recommend
if not any(
avoid_priority[_item_key(blocker)] > prio and _conflicts(item, blocker)
for blocker in avoid
)
)
return RuleVerdict(
recommend=kept_recommend,
avoid=tuple(avoid),
explanations=explanations,
rule_hits=rule_hits,
hard_blocks=hard_blocks,
)
+43
View File
@@ -0,0 +1,43 @@
"""kb_rule 表数据访问。**只有本模块读写 kb_rule**(表与 packages/kb 共享,
但规则包只经 ORM 访问,不碰 T6 的文件)。"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from packages.shared.models import KbRule
STATUS_PUBLISHED = "published"
async def list_published(session: AsyncSession) -> list[KbRule]:
"""全量已发布规则。量级 ~400,直接整表载入;序即求值序(priority 降序)。"""
result = await session.execute(
select(KbRule)
.where(KbRule.status == STATUS_PUBLISHED)
.order_by(KbRule.priority.desc(), KbRule.rule_key)
)
return list(result.scalars().all())
async def get_by_key(session: AsyncSession, rule_key: str) -> KbRule | None:
result = await session.execute(select(KbRule).where(KbRule.rule_key == rule_key))
return result.scalar_one_or_none()
async def list_by_status(session: AsyncSession, status: str) -> list[KbRule]:
result = await session.execute(
select(KbRule).where(KbRule.status == status).order_by(KbRule.rule_key)
)
return list(result.scalars().all())
async def list_all(session: AsyncSession) -> list[KbRule]:
result = await session.execute(select(KbRule).order_by(KbRule.rule_key))
return list(result.scalars().all())
def add_rule(session: AsyncSession, rule: KbRule) -> KbRule:
session.add(rule)
return rule
+240
View File
@@ -0,0 +1,240 @@
"""kb_rule 的 CRUD、draft→published 状态机与已发布规则缓存。
状态机(同 T6 语义):draft → published → archived;archived 终态;draft 可
直接 archived(撤稿)。只有 draft 可修改内容且每次修改 version+1——直接改
published 规则等于线上建议瞬间变味,必须重新走发布动作。
规则不做物理删除:命中链(rule_hits)是报告与 Agent 可解释性的数据来源,
历史规则要可追溯,下线一律走 archived。
"""
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from pydantic import ValidationError
from sqlalchemy.ext.asyncio import AsyncSession
from packages.domain.ids import next_id
from packages.rules import dsl, engine, repo
from packages.shared.errors import AppError, ErrorCode
from packages.shared.logging import get_logger
from packages.shared.models import KbRule
logger = get_logger(__name__)
STATUS_DRAFT = "draft"
STATUS_PUBLISHED = "published"
STATUS_ARCHIVED = "archived"
#: 合法状态跃迁全集(自跃迁也非法:状态机不接受无操作变更)。
LEGAL_TRANSITIONS: frozenset[tuple[str, str]] = frozenset(
{
(STATUS_DRAFT, STATUS_PUBLISHED),
(STATUS_PUBLISHED, STATUS_ARCHIVED),
(STATUS_DRAFT, STATUS_ARCHIVED),
}
)
def ensure_transition(current: str, target: str) -> None:
"""非法跃迁抛 STATE_CONFLICT——状态错误必须响亮失败,不能悄悄吞掉。"""
if (current, target) not in LEGAL_TRANSITIONS:
raise AppError(
ErrorCode.STATE_CONFLICT,
message=f"规则状态不能从 {current} 变更为 {target}",
data={"current": current, "target": target},
)
def validate_rule_payload(payload: dict[str, Any]) -> dsl.Rule:
"""DSL 写入前校验:非法结构 100% 拒绝(未识别键不静默忽略,见 dsl 模块)。"""
try:
return dsl.Rule.model_validate(payload)
except ValidationError as exc:
raise AppError(
ErrorCode.VALIDATION_FAILED,
message="规则 DSL 不合法",
data={
"errors": [
f"{'.'.join(str(loc) for loc in err['loc'])}: {err['msg']}"
for err in exc.errors()
]
},
) from exc
def row_to_rule(row: KbRule) -> dsl.Rule:
"""ORM 行 → 域规则。
已入库数据不合法属于知识库数据错误(5xxxx),必须立刻暴露——静默跳过
一条 published 规则等于线上建议悄悄缺了一块,没人会发现。
"""
try:
return dsl.Rule.model_validate(
{
"rule_key": row.rule_key,
"category": row.category,
"priority": row.priority,
"conditions": row.conditions,
"effects": row.effects,
"is_hard": row.is_hard,
"confidence": float(row.confidence),
}
)
except ValidationError as exc:
logger.error("kb_rule_dsl_invalid", rule_key=row.rule_key, errors=exc.errors())
raise AppError(
ErrorCode.CONFIG_ERROR,
message="知识库中存在不合法的规则数据",
data={"rule_key": row.rule_key},
) from exc
class PublishedRules:
"""进程内已发布规则缓存。
量级 ~400、读极多写极少:整表载入 + 管理动作后失效即可满足 p95 < 50ms
(求值本身是纯内存操作)。跨进程失效广播(Redis,架构 §10)属后续接入
项,先留 ``invalidate()`` 单一入口。并发首载最多重复读一次库,无害。
"""
def __init__(self) -> None:
self._rules: tuple[dsl.Rule, ...] | None = None
async def load(self, session: AsyncSession) -> tuple[dsl.Rule, ...]:
if self._rules is None:
rows = await repo.list_published(session)
self._rules = tuple(row_to_rule(row) for row in rows)
logger.info("rules_cache_loaded", count=len(self._rules))
return self._rules
def invalidate(self) -> None:
self._rules = None
_published_rules = PublishedRules()
def invalidate_published_cache() -> None:
"""发布/下线/修改规则后调用,进程内缓存立刻重建。"""
_published_rules.invalidate()
class RuleService:
"""kb_rule 的读写门面:CRUD + 状态机 + 缓存求值入口。"""
def __init__(self, session: AsyncSession) -> None:
self._session = session
# -- 读 -------------------------------------------------------------------
async def get(self, rule_key: str) -> KbRule:
return await self._require(rule_key)
async def list(self, status: str | None = None) -> list[KbRule]:
if status is None:
return await repo.list_all(self._session)
return await repo.list_by_status(self._session, status)
async def evaluate(self, ctx: dsl.RuleContext) -> engine.RuleVerdict:
"""用进程内已发布规则缓存求值(命中链 + 硬拦截 + 冲突仲裁)。"""
rules = await _published_rules.load(self._session)
return engine.evaluate(rules, ctx)
# -- 写 -------------------------------------------------------------------
async def create(
self,
payload: dict[str, Any],
*,
author: str | None = None,
evidence: str | None = None,
) -> KbRule:
"""新建 draft 规则。DSL 先校验再落库;rule_key 全局唯一。"""
rule = validate_rule_payload(payload)
if await repo.get_by_key(self._session, rule.rule_key) is not None:
raise AppError(
ErrorCode.BAD_REQUEST,
message="规则键已存在",
data={"rule_key": rule.rule_key},
)
row = repo.add_rule(
self._session,
KbRule(
rule_id=next_id(),
rule_key=rule.rule_key,
category=rule.category,
version=1,
status=STATUS_DRAFT,
priority=rule.priority,
conditions=rule.conditions.model_dump()["clauses"],
effects=rule.effects.model_dump(),
is_hard=rule.is_hard,
confidence=Decimal(str(rule.confidence)),
author=author,
evidence=evidence,
),
)
await self._session.flush()
logger.info("rule_created", rule_key=rule.rule_key, author=author)
return row
async def update(self, rule_key: str, payload: dict[str, Any]) -> KbRule:
"""修改规则内容(仅 draft):version+1;rule_key 是命中链锚点,不可改。"""
rule = validate_rule_payload(payload)
if rule.rule_key != rule_key:
raise AppError(
ErrorCode.BAD_REQUEST,
message="rule_key 不可修改",
data={"rule_key": rule_key},
)
row = await self._require(rule_key)
if row.status != STATUS_DRAFT:
raise AppError(
ErrorCode.STATE_CONFLICT,
message="仅 draft 规则可修改,published 规则请先下线重发",
data={"rule_key": rule_key, "status": row.status},
)
row.category = rule.category
row.priority = rule.priority
row.conditions = rule.conditions.model_dump()["clauses"]
row.effects = rule.effects.model_dump()
row.is_hard = rule.is_hard
row.confidence = Decimal(str(rule.confidence))
row.version += 1
await self._session.flush()
logger.info("rule_updated", rule_key=rule_key, version=row.version)
return row
async def publish(self, rule_key: str) -> KbRule:
row = await self._require(rule_key)
ensure_transition(row.status, STATUS_PUBLISHED)
row.status = STATUS_PUBLISHED
row.published_at = datetime.now(UTC)
await self._session.flush()
invalidate_published_cache()
logger.info("rule_published", rule_key=rule_key, version=row.version)
return row
async def archive(self, rule_key: str) -> KbRule:
"""下线/撤稿(draft 与 published 均可)。规则不物理删除,保留可追溯性。"""
row = await self._require(rule_key)
ensure_transition(row.status, STATUS_ARCHIVED)
row.status = STATUS_ARCHIVED
await self._session.flush()
invalidate_published_cache()
logger.info("rule_archived", rule_key=rule_key)
return row
async def _require(self, rule_key: str) -> KbRule:
row = await repo.get_by_key(self._session, rule_key)
if row is None:
raise AppError(
ErrorCode.NOT_FOUND,
message="规则不存在",
data={"rule_key": rule_key},
)
return row
+250
View File
@@ -0,0 +1,250 @@
"""规则引擎集成用例(真实 PostgreSQL):published 载入、draft 不载入、硬约束拦截。
覆盖任务契约:插 3 条规则(含 1 条 is_hard)→ service 载入 → 求值命中与拦截
正确;draft 规则不被载入。规则键带随机后缀 + 前缀清理,可与任意环境的存量
数据共存。
"""
from __future__ import annotations
import os
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
import pytest
def _infra_available() -> bool:
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not _infra_available(),
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
),
]
_PREFIX = "R-IT7-"
def _key(suffix: str) -> str:
return f"{_PREFIX}{suffix}-{uuid.uuid4().hex[:6]}"
@asynccontextmanager
async def _session() -> AsyncIterator[Any]:
"""每个用例独立 engine:pytest-asyncio 每个用例一个事件循环,
共享全局连接池会把上一个 loop 的连接带进下一个用例(Future attached to
a different loop)。用完 dispose,进程内全局池保持零污染。
"""
from sqlalchemy.ext.asyncio import async_sessionmaker
from packages.shared.config import get_settings
from packages.shared.db import create_engine
engine = create_engine(get_settings())
factory = async_sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
try:
async with factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await engine.dispose()
async def _cleanup() -> None:
from sqlalchemy import delete
from packages.rules import service as rules_service
from packages.shared.models import KbRule
rules_service.invalidate_published_cache()
async with _session() as session:
await session.execute(delete(KbRule).where(KbRule.rule_key.like(f"{_PREFIX}%")))
def _body_payload(rule_key: str) -> dict[str, Any]:
return {
"rule_key": rule_key,
"category": "body_shape",
"priority": 90,
"confidence": 0.95,
"conditions": {"body_shape": "A型", "features": {"include": ["溜肩"]}},
"effects": {
"recommend": [{"slot": "top", "attrs": {"shoulder": ["IT7垫肩"]}}],
"avoid": [{"slot": "top", "attrs": {"shoulder": ["IT7落肩"]}}],
"explanation": "IT7 体型规则",
},
}
def _hard_payload(rule_key: str) -> dict[str, Any]:
return {
"rule_key": rule_key,
"category": "compliance",
"priority": 100,
"confidence": 1.0,
"is_hard": True,
"conditions": {"occasion": {"include": ["IT7晚宴"]}},
"effects": {
"avoid": [{"slot": "accessory", "attrs": {"material": ["IT7真兽皮"]}}],
"explanation": "IT7 硬约束",
},
}
def _draft_payload(rule_key: str) -> dict[str, Any]:
return {
"rule_key": rule_key,
"category": "body_shape",
"priority": 90,
"conditions": {"body_shape": "A型"},
"effects": {
"recommend": [{"slot": "dress", "attrs": {"silhouette": ["IT7草稿独有"]}}],
"explanation": "IT7 未发布规则",
},
}
async def test_published_loaded_draft_not_loaded_and_hard_blocks() -> None:
from packages.rules import dsl
from packages.rules import service as rules_service
await _cleanup()
body_key, hard_key, draft_key = _key("BODY"), _key("HARD"), _key("DRAFT")
try:
async with _session() as session:
svc = rules_service.RuleService(session)
await svc.create(_body_payload(body_key), author="it7")
await svc.create(_hard_payload(hard_key), author="it7")
await svc.create(_draft_payload(draft_key), author="it7") # 保持 draft
await svc.publish(body_key)
await svc.publish(hard_key)
ctx = dsl.RuleContext(body_shape="A型", features=("溜肩",), occasion="IT7晚宴")
# 求值:published 载入(两条命中),draft 不载入
async with _session() as session:
svc = rules_service.RuleService(session)
verdict = await svc.evaluate(ctx)
hit_keys = [h.rule_key for h in verdict.rule_hits]
assert body_key in hit_keys
assert hard_key in hit_keys
assert draft_key not in hit_keys
# 硬约束:is_hard 的 avoid 命中 → hard_blocks 带出可过滤的约束内容
assert verdict.blocked is True
block_keys = [b.rule_key for b in verdict.hard_blocks]
assert hard_key in block_keys
hard_block = next(b for b in verdict.hard_blocks if b.rule_key == hard_key)
assert hard_block.avoid[0].slot == "accessory"
assert hard_block.avoid[0].attrs["material"] == ("IT7真兽皮",)
# 命中的推荐来自 body 规则;draft 的效果从未出现
rec_values = {
value
for item in verdict.recommend
for values in item.attrs.values()
for value in values
}
assert "IT7垫肩" in rec_values
assert "IT7草稿独有" not in rec_values
# 下线 body 规则 → 缓存失效 → 再求值不再命中(保留 hard 规则互不干扰)
async with _session() as session:
svc = rules_service.RuleService(session)
await svc.archive(body_key)
async with _session() as session:
svc = rules_service.RuleService(session)
verdict_after = await svc.evaluate(ctx)
after_keys = [h.rule_key for h in verdict_after.rule_hits]
assert body_key not in after_keys
assert hard_key in after_keys
assert draft_key not in after_keys
finally:
await _cleanup()
async def test_create_rejects_invalid_dsl() -> None:
from packages.shared.errors import AppError, ErrorCode
await _cleanup()
try:
async with _session() as session:
from packages.rules.service import RuleService
svc = RuleService(session)
# 未识别的键(conclusions 是架构旧列名)必须被拒绝而不是静默忽略
with pytest.raises(AppError) as excinfo:
await svc.create({**_body_payload(_key("BAD")), "conclusions": ["垫肩"]})
assert excinfo.value.code == ErrorCode.VALIDATION_FAILED
finally:
await _cleanup()
async def test_state_machine_guards_on_real_rows() -> None:
from packages.shared.errors import AppError, ErrorCode
await _cleanup()
sm_key = _key("SM")
other_key = _key("SM2")
try:
async with _session() as session:
from packages.rules.service import RuleService
svc = RuleService(session)
row = await svc.create(_body_payload(sm_key), author="it7")
assert row.status == "draft"
assert row.version == 1
published = await svc.publish(sm_key)
assert published.status == "published"
assert published.published_at is not None
# published 不能再发布、不能直接改内容(要改必须先下线)
with pytest.raises(AppError) as republish:
await svc.publish(sm_key)
assert republish.value.code == ErrorCode.STATE_CONFLICT
with pytest.raises(AppError) as edit:
await svc.update(sm_key, _body_payload(sm_key))
assert edit.value.code == ErrorCode.STATE_CONFLICT
# draft 可以修改且 version+1;rule_key 不可改
draft_row = await svc.create(_body_payload(other_key), author="it7")
# identity map:update 返回的是同一 ORM 对象,先快照修改前的版本号
base_version = draft_row.version
updated = await svc.update(
other_key,
{
**_body_payload(other_key),
"priority": 85,
"effects": {
"recommend": [{"slot": "top", "attrs": {"shoulder": ["IT7改"]}}],
"explanation": "IT7 修改后",
},
},
)
assert updated.version == base_version + 1
assert updated.priority == 85
with pytest.raises(AppError) as rename:
await svc.update(other_key, _body_payload(_key("OTHER")))
assert rename.value.code == ErrorCode.BAD_REQUEST
# archived 是终态
await svc.archive(sm_key)
with pytest.raises(AppError) as rearchive:
await svc.archive(sm_key)
assert rearchive.value.code == ErrorCode.STATE_CONFLICT
finally:
await _cleanup()
+260
View File
@@ -0,0 +1,260 @@
"""kb_rule DSL 解析校验与 match 矩阵(TD-10,纯逻辑)。
核心验收点:**非法结构 100% 拒绝**——未识别的键(顶层/子句/条件字段)全部
在解析时抛错,而不是被静默忽略后变成一条永不生效的死规则。
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from packages.rules import dsl
VALID_RULE = {
"rule_key": "R-BODY-A-002",
"category": "body_shape",
"priority": 90,
"conditions": {
"body_shape": "A型",
"features": {"include": ["溜肩"]},
"goals": {"include": ["修饰溜肩"]},
},
"effects": {
"recommend": [
{"slot": "top", "attrs": {"shoulder": ["垫肩", "自然肩"], "sleeve": ["泡泡袖"]}}
],
"avoid": [{"slot": "top", "attrs": {"shoulder": ["落肩"], "silhouette": ["修身"]}}],
"explanation": "溜肩需要在肩部建立结构感",
},
"is_hard": False,
"confidence": 0.95,
}
# ── 解析:合法结构 ─────────────────────────────────────────────────────────
def test_parse_doc_example() -> None:
rule = dsl.Rule.model_validate(VALID_RULE)
assert rule.rule_key == "R-BODY-A-002"
assert rule.priority == 90
assert rule.is_hard is False
assert rule.confidence == pytest.approx(0.95)
# 等值子句解析为 str,集合子句解析为 ConditionClause
assert rule.conditions.clauses["body_shape"] == "A型"
features = rule.conditions.clauses["features"]
assert isinstance(features, dsl.ConditionClause)
assert features.include == ("溜肩",)
assert rule.effects.recommend[0].slot == "top"
assert rule.effects.avoid[0].attrs["shoulder"] == ("落肩",)
def test_parse_min_max_and_exclude_clause() -> None:
rule = dsl.Rule.model_validate(
{
**VALID_RULE,
"conditions": {
"temperature": {"min": 18, "max": 26},
"season": {"exclude": ["夏"]},
},
}
)
temp = rule.conditions.clauses["temperature"]
assert isinstance(temp, dsl.ConditionClause)
assert temp.min == 18.0
assert temp.max == 26.0
season = rule.conditions.clauses["season"]
assert isinstance(season, dsl.ConditionClause)
assert season.exclude == ("夏",)
def test_empty_conditions_means_match_all() -> None:
# 合规类兜底规则("全场禁真兽皮")无条件命中是合法语义。
rule = dsl.Rule.model_validate({**VALID_RULE, "conditions": {}})
assert rule.conditions.clauses == {}
assert dsl.match(rule.conditions, dsl.RuleContext()) is True
def test_explanation_only_effect_is_valid() -> None:
rule = dsl.Rule.model_validate(
{**VALID_RULE, "effects": {"explanation": "整体偏保守的通勤思路"}}
)
assert rule.effects.recommend == ()
assert rule.effects.avoid == ()
def test_attrs_empty_means_whole_slot() -> None:
rule = dsl.Rule.model_validate(
{**VALID_RULE, "effects": {"avoid": [{"slot": "swimsuit"}], "explanation": "回避品类"}}
)
assert rule.effects.avoid[0].attrs == {}
# ── 解析:非法结构 100% 拒绝 ───────────────────────────────────────────────
@pytest.mark.parametrize(
"mutation",
[
{"rule_key": 12345}, # 非 str
{"rule_key": "R-" + "x" * 63}, # 超出 64 字符(列宽)
{"category": "c" * 33}, # 超出 32 字符(列宽)
{"priority": 101}, # 超出分组上限 100
{"priority": -1},
{"confidence": 1.5},
{"confidence": -0.1},
],
)
def test_invalid_top_level_values_rejected(mutation: dict) -> None:
with pytest.raises(ValidationError):
dsl.Rule.model_validate({**VALID_RULE, **mutation})
def test_unknown_top_level_key_rejected() -> None:
with pytest.raises(ValidationError, match="unexpected"):
dsl.Rule.model_validate({**VALID_RULE, "unexpected": 1})
def test_unknown_condition_field_rejected() -> None:
# 条件字段名 typo:静默忽略会让这条规则永不命中
with pytest.raises(ValidationError, match="未识别的条件字段"):
dsl.Rule.model_validate({**VALID_RULE, "conditions": {"body_shap": "A型"}})
def test_unknown_clause_key_rejected() -> None:
with pytest.raises(ValidationError):
dsl.Rule.model_validate({**VALID_RULE, "conditions": {"features": {"includes": ["溜肩"]}}})
def test_empty_clause_rejected() -> None:
with pytest.raises(ValidationError, match="至少要包含"):
dsl.Rule.model_validate({**VALID_RULE, "conditions": {"features": {}}})
def test_min_greater_than_max_rejected() -> None:
with pytest.raises(ValidationError, match="min 不能大于 max"):
dsl.Rule.model_validate(
{**VALID_RULE, "conditions": {"temperature": {"min": 26, "max": 18}}}
)
def test_empty_attr_values_rejected() -> None:
with pytest.raises(ValidationError, match="取值列表不能为空"):
dsl.Rule.model_validate(
{
**VALID_RULE,
"effects": {
"recommend": [{"slot": "top", "attrs": {"shoulder": []}}],
"explanation": "x",
},
}
)
def test_empty_attr_dim_rejected() -> None:
with pytest.raises(ValidationError, match="维度名不能为空"):
dsl.Rule.model_validate(
{
**VALID_RULE,
"effects": {
"recommend": [{"slot": "top", "attrs": {"": ["垫肩"]}}],
"explanation": "x",
},
}
)
def test_empty_effects_rejected() -> None:
with pytest.raises(ValidationError, match="至少要包含"):
dsl.Rule.model_validate({**VALID_RULE, "effects": {}})
def test_blank_explanation_alone_rejected() -> None:
# explanation="" 视同未填,效果为空的规则没有存在意义
with pytest.raises(ValidationError, match="至少要包含"):
dsl.Rule.model_validate({**VALID_RULE, "effects": {"explanation": ""}})
def test_non_dict_conditions_rejected() -> None:
with pytest.raises(ValidationError):
dsl.Rule.model_validate({**VALID_RULE, "conditions": ["body_shape"]})
def test_effects_missing_rejected() -> None:
payload = dict(VALID_RULE)
del payload["effects"]
with pytest.raises(ValidationError):
dsl.Rule.model_validate(payload)
# ── match 矩阵 ─────────────────────────────────────────────────────────────
def _cond(**clauses: object) -> dsl.RuleCondition:
return dsl.RuleCondition.model_validate(clauses)
def test_match_equality_hit_and_miss() -> None:
cond = _cond(body_shape="A型")
assert dsl.match(cond, dsl.RuleContext(body_shape="A型")) is True
assert dsl.match(cond, dsl.RuleContext(body_shape="H型")) is False
assert dsl.match(cond, dsl.RuleContext(body_shape=None)) is False # 缺字段
def test_match_equality_on_collection_field_is_membership() -> None:
cond = _cond(user_taboos="露脐")
assert dsl.match(cond, dsl.RuleContext(user_taboos=("露脐", "豹纹"))) is True
assert dsl.match(cond, dsl.RuleContext(user_taboos=())) is False
def test_match_include_requires_all_values() -> None:
cond = _cond(features={"include": ["溜肩", "宽胯"]})
assert dsl.match(cond, dsl.RuleContext(features=("溜肩", "宽胯", "小腿粗"))) is True
# 子集语义:只命中一半不算命中
assert dsl.match(cond, dsl.RuleContext(features=("溜肩",))) is False
assert dsl.match(cond, dsl.RuleContext(features=())) is False
def test_match_include_on_scalar_field() -> None:
cond = _cond(occasion={"include": ["面试", "通勤"]})
assert dsl.match(cond, dsl.RuleContext(occasion="面试")) is True
assert dsl.match(cond, dsl.RuleContext(occasion="约会")) is False
def test_match_exclude() -> None:
cond = _cond(features={"exclude": ["溜肩"]})
assert dsl.match(cond, dsl.RuleContext(features=("宽胯",))) is True
assert dsl.match(cond, dsl.RuleContext(features=("宽胯", "溜肩"))) is False
def test_match_min_max_closed_interval() -> None:
cond = _cond(temperature={"min": 18, "max": 26})
assert dsl.match(cond, dsl.RuleContext(temperature=18.0)) is True # 闭区间含端点
assert dsl.match(cond, dsl.RuleContext(temperature=26.0)) is True
assert dsl.match(cond, dsl.RuleContext(temperature=21.5)) is True
assert dsl.match(cond, dsl.RuleContext(temperature=17.9)) is False
assert dsl.match(cond, dsl.RuleContext(temperature=26.1)) is False
assert dsl.match(cond, dsl.RuleContext(temperature=None)) is False # 缺字段
def test_match_min_only_and_max_only() -> None:
assert dsl.match(_cond(temperature={"min": 10}), dsl.RuleContext(temperature=10.0))
assert not dsl.match(_cond(temperature={"min": 10}), dsl.RuleContext(temperature=9.9))
assert dsl.match(_cond(temperature={"max": 10}), dsl.RuleContext(temperature=10.0))
assert not dsl.match(_cond(temperature={"max": 10}), dsl.RuleContext(temperature=10.1))
def test_match_clauses_are_anded() -> None:
cond = _cond(body_shape="A型", features={"include": ["溜肩"]})
assert dsl.match(cond, dsl.RuleContext(body_shape="A型", features=("溜肩",))) is True
assert dsl.match(cond, dsl.RuleContext(body_shape="A型", features=())) is False
assert dsl.match(cond, dsl.RuleContext(body_shape="H型", features=("溜肩",))) is False
def test_match_include_and_min_max_combined_in_one_clause() -> None:
# 同一子句内 include 与区间也按 AND 处理
cond = _cond(goals={"include": ["显瘦"], "exclude": ["显壮"]})
assert dsl.match(cond, dsl.RuleContext(goals=("显瘦",))) is True
assert dsl.match(cond, dsl.RuleContext(goals=("显瘦", "显壮"))) is False
assert dsl.match(cond, dsl.RuleContext(goals=("显高",))) is False
+322
View File
@@ -0,0 +1,322 @@
"""纯域求值引擎:排序、硬约束拦截、用户禁忌压过专业推荐、性能基准(T-P2-02)。"""
from __future__ import annotations
import time
from typing import Any
from packages.rules import dsl, engine
def _rule(
rule_key: str,
priority: int,
*,
category: str = "test",
confidence: float = 0.9,
is_hard: bool = False,
conditions: dict[str, Any] | None = None,
recommend: tuple[dict[str, Any], ...] = (),
avoid: tuple[dict[str, Any], ...] = (),
explanation: str | None = None,
) -> dsl.Rule:
effects: dict[str, Any] = {}
if recommend:
effects["recommend"] = list(recommend)
if avoid:
effects["avoid"] = list(avoid)
if explanation:
effects["explanation"] = explanation
if not effects:
effects["explanation"] = "测试规则" # 空效果本身非法,测试助手兜底
return dsl.Rule.model_validate(
{
"rule_key": rule_key,
"category": category,
"priority": priority,
"conditions": conditions or {},
"effects": effects,
"is_hard": is_hard,
"confidence": confidence,
}
)
# ── 排序与命中链 ───────────────────────────────────────────────────────────
def test_hits_sorted_by_priority_then_confidence_then_key() -> None:
rules = [
_rule("R-LOW", 50),
_rule("R-TIE-B", 90, confidence=0.9),
_rule("R-TOP", 95, confidence=0.5),
_rule("R-TIE-A", 90, confidence=0.9),
_rule("R-TIE-C", 90, confidence=0.8),
]
verdict = engine.evaluate(rules, dsl.RuleContext())
assert [h.rule_key for h in verdict.rule_hits] == [
"R-TOP",
"R-TIE-A",
"R-TIE-B",
"R-TIE-C",
"R-LOW",
]
assert [h.priority for h in verdict.rule_hits] == [95, 90, 90, 90, 50]
def test_rule_hit_carries_metadata_and_explanations_in_priority_order() -> None:
rules = [
_rule("R-LOW", 50, explanation="趋势解释", category="trend", confidence=0.7),
_rule("R-HIGH", 90, explanation="体型解释", category="body_shape", confidence=0.95),
]
verdict = engine.evaluate(rules, dsl.RuleContext())
top = verdict.rule_hits[0]
assert (top.rule_key, top.priority, top.category, top.confidence) == (
"R-HIGH",
90,
"body_shape",
0.95,
)
assert top.is_hard is False
assert verdict.explanations == ("体型解释", "趋势解释")
assert verdict.explanation == "体型解释;趋势解释"
def test_no_rules_or_no_hits_yield_empty_verdict() -> None:
assert engine.evaluate([], dsl.RuleContext()).recommend == ()
verdict = engine.evaluate(
[_rule("R-X", 90, conditions={"body_shape": "A型"})],
dsl.RuleContext(body_shape="H型"),
)
assert verdict.rule_hits == ()
assert verdict.blocked is False
# ── 硬约束 ─────────────────────────────────────────────────────────────────
def test_hard_rule_with_avoid_populates_hard_blocks() -> None:
avoid = {"slot": "shoes", "attrs": {"material": ["真皮"]}}
rules = [_rule("R-HARD", 100, is_hard=True, avoid=(avoid,), explanation="合规红线")]
verdict = engine.evaluate(rules, dsl.RuleContext())
assert verdict.blocked is True
assert len(verdict.hard_blocks) == 1
block = verdict.hard_blocks[0]
assert block.rule_key == "R-HARD"
assert block.avoid[0].slot == "shoes"
assert block.avoid[0].attrs["material"] == ("真皮",)
assert block.explanation == "合规红线"
def test_hard_rule_without_avoid_does_not_block() -> None:
# 拦截语义只由 avoid 表达:is_hard 规则只写 recommend 就无物可拦
rules = [
_rule(
"R-HARD-REC",
100,
is_hard=True,
recommend=({"slot": "top", "attrs": {"collar": ["衬衫领"]}},),
)
]
verdict = engine.evaluate(rules, dsl.RuleContext())
assert verdict.hard_blocks == ()
assert verdict.blocked is False
def test_hard_rule_not_fired_does_not_block() -> None:
rules = [
_rule(
"R-HARD",
100,
is_hard=True,
conditions={"occasion": "面试"},
avoid=({"slot": "top", "attrs": {"neckline": ["深V"]}},),
)
]
assert engine.evaluate(rules, dsl.RuleContext(occasion="通勤")).blocked is False
assert engine.evaluate(rules, dsl.RuleContext(occasion="面试")).blocked is True
def test_soft_rule_with_avoid_never_blocks() -> None:
rules = [_rule("R-SOFT", 90, avoid=({"slot": "top", "attrs": {"silhouette": ["修身"]}},))]
assert engine.evaluate(rules, dsl.RuleContext()).hard_blocks == ()
# ── 冲突仲裁:高优先级 avoid 剔除严格低优先级的冲突 recommend ────────────────
def test_user_taboo_overrides_professional_recommend() -> None:
# 用户禁忌(95)与专业推荐(90)同 slot 同维度取值相交 → 推荐被剔除
rules = [
_rule(
"R-PRO",
dsl.PRIORITY_BODY_SHAPE,
recommend=({"slot": "top", "attrs": {"shoulder": ["落肩"]}},),
),
_rule(
"R-TABOO",
dsl.PRIORITY_USER_TABOO,
conditions={"user_taboos": {"include": ["oversize"]}},
avoid=({"slot": "top", "attrs": {"shoulder": ["落肩"]}},),
),
]
verdict = engine.evaluate(rules, dsl.RuleContext(user_taboos=("oversize",)))
assert verdict.recommend == ()
assert verdict.avoid != () # 禁忌约束保留在 avoid 里供下游过滤
assert [h.rule_key for h in verdict.rule_hits] == ["R-TABOO", "R-PRO"]
def test_taboo_keeps_non_conflicting_recommend() -> None:
# 不同维度(length vs shoulder)不构成冲突:专业推荐保留
rules = [
_rule(
"R-PRO",
90,
recommend=({"slot": "top", "attrs": {"shoulder": ["垫肩"]}},),
),
_rule(
"R-TABOO",
95,
avoid=({"slot": "top", "attrs": {"length": ["短款"]}},),
),
]
verdict = engine.evaluate(rules, dsl.RuleContext())
assert [item.slot for item in verdict.recommend] == ["top"]
assert verdict.recommend[0].attrs["shoulder"] == ("垫肩",)
def test_taboo_does_not_override_higher_priority_recommend() -> None:
# 合规(100)> 用户禁忌(95):只有"严格更低"优先级的推荐才会被剔除
rules = [
_rule(
"R-COMPLIANCE",
dsl.PRIORITY_COMPLIANCE,
recommend=({"slot": "top", "attrs": {"shoulder": ["落肩"]}},),
),
_rule(
"R-TABOO",
dsl.PRIORITY_USER_TABOO,
avoid=({"slot": "top", "attrs": {"shoulder": ["落肩"]}},),
),
]
verdict = engine.evaluate(rules, dsl.RuleContext())
assert len(verdict.recommend) == 1
def test_lower_priority_recommend_vetoed_by_higher_soft_avoid() -> None:
# 通用优先级语义:体型 avoid(90)同样压过趋势 recommend(50)
rules = [
_rule(
"R-TREND",
dsl.PRIORITY_TREND,
recommend=({"slot": "top", "attrs": {"silhouette": ["修身"]}},),
),
_rule(
"R-BODY",
dsl.PRIORITY_BODY_SHAPE,
avoid=({"slot": "top", "attrs": {"silhouette": ["修身"]}},),
),
]
verdict = engine.evaluate(rules, dsl.RuleContext())
assert verdict.recommend == ()
def test_same_priority_avoid_does_not_veto_recommend() -> None:
# 同优先级不仲裁:推荐与回避并存,由下游按场景取舍
rules = [
_rule(
"R-A",
90,
recommend=({"slot": "top", "attrs": {"sleeve": ["泡泡袖"]}},),
),
_rule(
"R-B",
90,
avoid=({"slot": "top", "attrs": {"sleeve": ["泡泡袖"]}},),
),
]
verdict = engine.evaluate(rules, dsl.RuleContext())
assert len(verdict.recommend) == 1
def test_different_slot_never_conflicts() -> None:
rules = [
_rule(
"R-REC",
50,
recommend=({"slot": "bottom", "attrs": {"silhouette": ["紧身"]}},),
),
_rule(
"R-AVOID",
100,
is_hard=True,
avoid=({"slot": "top", "attrs": {"silhouette": ["紧身"]}},),
),
]
verdict = engine.evaluate(rules, dsl.RuleContext())
assert len(verdict.recommend) == 1
assert verdict.blocked is True # 拦截归拦截,推荐归推荐
# ── 汇总与去重 ─────────────────────────────────────────────────────────────
def test_identical_recommend_deduped_and_order_follows_priority() -> None:
item = {"slot": "top", "attrs": {"collar": ["衬衫领"]}}
rules = [
_rule("R-STYLE", 70, recommend=(item,)),
_rule("R-OCCASION", 80, recommend=(item,)),
]
verdict = engine.evaluate(rules, dsl.RuleContext())
assert len(verdict.recommend) == 1
assert len(verdict.avoid) == 0
def test_identical_avoid_deduped() -> None:
avoid = {"slot": "top", "attrs": {"silhouette": ["修身"]}}
rules = [
_rule("R-A", 90, avoid=(avoid,)),
_rule("R-B", 60, avoid=(avoid,)),
]
verdict = engine.evaluate(rules, dsl.RuleContext())
assert len(verdict.avoid) == 1
# ── 性能基准(T-P2-02:p95 < 50ms @ 400 条规则)─────────────────────────────
def test_benchmark_400_rules_well_under_50ms_budget() -> None:
"""400 条规则 × 1000 次求值。
断言均值 < 5ms/次(对 50ms 预算留 10 倍裕量,CI 机器差异不至于 flaky),
并直接断言 p95 < 50ms 对应验收指标。求值是纯内存操作,规则从缓存来,
不含任何 I/O。
"""
shapes = ("A型", "H型", "X型", "O型")
rules = [
_rule(
f"R-BENCH-{i:03d}",
50 + (i % 6) * 10,
category="bench",
confidence=0.5 + (i % 10) / 20,
conditions={"body_shape": shapes[i % 4], "season": {"exclude": ["夏"]}},
recommend=({"slot": "top", "attrs": {"collar": [f"领型{i % 5}"]}},),
avoid=({"slot": "bottom", "attrs": {"silhouette": ["紧身"]}},),
explanation=f"基准规则 {i}",
)
for i in range(400)
]
assert len(rules) == 400
ctx = dsl.RuleContext(body_shape="A型", season="冬", temperature=18.0)
engine.evaluate(rules, ctx) # 预热:首次调用的函数定位等开销不摊给被测对象
timings: list[float] = []
for _ in range(1000):
start = time.perf_counter()
engine.evaluate(rules, ctx)
timings.append(time.perf_counter() - start)
mean = sum(timings) / len(timings)
p95 = sorted(timings)[int(len(timings) * 0.95) - 1]
assert mean < 0.005, f"平均单次求值 {mean * 1000:.2f}ms,超出 5ms 基线"
assert p95 < 0.05, f"p95 单次求值 {p95 * 1000:.2f}ms,超出 50ms 预算"
+330
View File
@@ -0,0 +1,330 @@
"""规则引擎黄金集雏形(架构 §1081):固定画像 → 期望输出。
改引擎行为必须先过黄金集——这是"专业可信"的回归防线。必含两类场景:
硬禁忌(用户禁忌压过专业推荐)与多规则叠加。规则库是知识库文档 §3.2⑩ 的
迷你缩影(体型/场景/温度/风格/趋势/用户禁忌/合规硬约束各至少一条)。
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from packages.rules import dsl, engine
# ── 固定规则库(8 条)───────────────────────────────────────────────────────
_GOLDEN_RULES = [
# 体型(90):文档 §3.2⑩ 原始示例规则
{
"rule_key": "R-BODY-A-002",
"category": "body_shape",
"priority": 90,
"confidence": 0.95,
"conditions": {
"body_shape": "A型",
"features": {"include": ["溜肩"]},
"goals": {"include": ["修饰溜肩"]},
},
"effects": {
"recommend": [
{"slot": "top", "attrs": {"shoulder": ["垫肩", "自然肩"], "sleeve": ["泡泡袖"]}}
],
"avoid": [{"slot": "top", "attrs": {"shoulder": ["落肩"], "silhouette": ["修身"]}}],
"explanation": "溜肩需要用垫肩与泡泡袖在肩部建立结构感。",
},
},
# 体型(90,同优先级低置信度:验证二级排序)
{
"rule_key": "R-BODY-A-010",
"category": "body_shape",
"priority": 90,
"confidence": 0.90,
"conditions": {"body_shape": "A型"},
"effects": {
"recommend": [{"slot": "bottom", "attrs": {"silhouette": ["直筒", "阔腿"]}}],
"avoid": [{"slot": "bottom", "attrs": {"silhouette": ["紧身"]}}],
"explanation": "A型下半身宜直线轮廓,避免紧身放大胯部。",
},
},
# 场景(80)
{
"rule_key": "R-OCCASION-INTERVIEW-001",
"category": "occasion",
"priority": 80,
"confidence": 0.90,
"conditions": {"occasion": "面试"},
"effects": {
"recommend": [
{"slot": "top", "attrs": {"collar": ["衬衫领"]}},
{"slot": "bottom", "attrs": {"silhouette": ["直筒"]}},
],
"avoid": [{"slot": "top", "attrs": {"neckline": ["深V"]}}],
"explanation": "面试场合需要干练可信的轮廓。",
},
},
# 温度(75):闭区间
{
"rule_key": "R-TEMP-COMFORT-18-26",
"category": "temperature",
"priority": 75,
"confidence": 0.85,
"conditions": {"temperature": {"min": 18, "max": 26}},
"effects": {
"recommend": [{"slot": "outer", "attrs": {"thickness": ["薄款"]}}],
"avoid": [{"slot": "outer", "attrs": {"thickness": ["厚款"]}}],
"explanation": "18-26℃ 单层薄外套最舒适。",
},
},
# 风格(70):与场景规则的推荐完全相同 → 验证去重
{
"rule_key": "R-STYLE-OL-001",
"category": "style",
"priority": 70,
"confidence": 0.80,
"conditions": {"style": "通勤"},
"effects": {
"recommend": [{"slot": "top", "attrs": {"collar": ["衬衫领"]}}],
"explanation": "通勤风格以衬衫领立住精神气。",
},
},
# 趋势(50):推荐恰与用户禁忌冲突 → 必须被压过
{
"rule_key": "R-TREND-SWEET-001",
"category": "trend",
"priority": 50,
"confidence": 0.70,
"conditions": {"style": "甜酷"},
"effects": {
"recommend": [{"slot": "top", "attrs": {"length": ["短款"]}}],
"explanation": "短款上衣是当季甜酷风的核心单品。",
},
},
# 用户禁忌(95):avoid 永远压过专业推荐
{
"rule_key": "R-TABOO-NAVEL-001",
"category": "user_taboo",
"priority": 95,
"confidence": 1.0,
"conditions": {"user_taboos": {"include": ["露脐"]}},
"effects": {
"avoid": [{"slot": "top", "attrs": {"length": ["短款", "露脐"]}}],
"explanation": "用户不接受露脐,已剔除相关单品。",
},
},
# 合规(100):is_hard,avoid 命中即硬拦截
{
"rule_key": "R-HARD-EXOTIC-001",
"category": "compliance",
"priority": 100,
"confidence": 1.0,
"is_hard": True,
"conditions": {"occasion": {"include": ["红毯", "晚宴"]}},
"effects": {
"avoid": [
{"slot": "accessory", "attrs": {"material": ["真兽皮"]}},
{"slot": "shoes", "attrs": {"material": ["真皮"]}},
],
"explanation": "合规红线:不推荐真兽皮材质。",
},
},
]
_RULES = [dsl.Rule.model_validate(r) for r in _GOLDEN_RULES]
# ── 断言辅助:把 SlotAttrs 规整成可比较的集合 ────────────────────────────────
#: 单个 recommend/avoid 条目规整后的形状。
NormItem = tuple[str, tuple[tuple[str, tuple[str, ...]], ...]]
def _item(slot: str, **dims: tuple[str, ...]) -> NormItem:
# 与 _norm 相同的规整规则(维度与取值都排序),期望值不必手写排序
return (slot, tuple(sorted((d, tuple(sorted(v))) for d, v in dims.items())))
def _norm(items: Iterable[dsl.SlotAttrs]) -> frozenset[NormItem]:
return frozenset(
(i.slot, tuple(sorted((d, tuple(sorted(v))) for d, v in i.attrs.items()))) for i in items
)
def _evaluate(**ctx: Any) -> engine.RuleVerdict:
return engine.evaluate(_RULES, dsl.RuleContext(**ctx))
def _hit_keys(verdict: engine.RuleVerdict) -> list[str]:
return [h.rule_key for h in verdict.rule_hits]
# ── 1. 基础命中 ─────────────────────────────────────────────────────────────
def test_a_shape_with_sloped_shoulders() -> None:
verdict = _evaluate(body_shape="A型", features=("溜肩",), goals=("修饰溜肩",))
assert _hit_keys(verdict) == ["R-BODY-A-002", "R-BODY-A-010"]
assert _norm(verdict.recommend) == {
_item("top", shoulder=("垫肩", "自然肩"), sleeve=("泡泡袖",)),
_item("bottom", silhouette=("阔腿", "直筒")),
}
assert _norm(verdict.avoid) == {
_item("top", shoulder=("落肩",), silhouette=("修身",)),
_item("bottom", silhouette=("紧身",)),
}
assert verdict.hard_blocks == ()
assert verdict.blocked is False
def test_missing_body_shape_does_not_fire_shape_rules() -> None:
# 缺字段 = 不匹配该子句:只有 features/goals 时体型规则不命中
verdict = _evaluate(features=("溜肩",), goals=("修饰溜肩",))
assert _hit_keys(verdict) == []
assert verdict.recommend == ()
assert verdict.avoid == ()
assert verdict.explanation == ""
def test_include_is_subset_semantics() -> None:
# features 是 include 列表的超集 → 命中
hit = _evaluate(body_shape="A型", features=("溜肩", "宽胯"), goals=("修饰溜肩",))
assert "R-BODY-A-002" in _hit_keys(hit)
# goals 缺一个 → 不命中
miss = _evaluate(body_shape="A型", features=("溜肩",), goals=("显瘦",))
assert "R-BODY-A-002" not in _hit_keys(miss)
assert _hit_keys(miss) == ["R-BODY-A-010"]
# ── 2. 多规则叠加 ──────────────────────────────────────────────────────────
def test_body_plus_temperature_plus_occasion_stack() -> None:
verdict = _evaluate(
body_shape="A型",
features=("溜肩",),
goals=("修饰溜肩",),
temperature=20.0,
occasion="面试",
)
assert _hit_keys(verdict) == [
"R-BODY-A-002",
"R-BODY-A-010",
"R-OCCASION-INTERVIEW-001",
"R-TEMP-COMFORT-18-26",
]
# 四条规则的推荐全部叠加,互不冲突(不同维度/不同 slot;两条 bottom 推荐
# 的属性集不同,各自保留)
assert _norm(verdict.recommend) == {
_item("top", shoulder=("垫肩", "自然肩"), sleeve=("泡泡袖",)),
_item("bottom", silhouette=("阔腿", "直筒")),
_item("top", collar=("衬衫领",)),
_item("bottom", silhouette=("直筒",)),
_item("outer", thickness=("薄款",)),
}
assert _norm(verdict.avoid) == {
_item("top", shoulder=("落肩",), silhouette=("修身",)),
_item("bottom", silhouette=("紧身",)),
_item("top", neckline=("深V",)),
_item("outer", thickness=("厚款",)),
}
assert verdict.blocked is False
def test_same_priority_sorted_by_confidence_and_dedupe_collapsed() -> None:
# A-002 不命中(缺 features/goals),A-010 与面试/通勤规则叠加;
# 场景(80)与风格(70)推荐同一件衬衫领 → 去重为一条
verdict = _evaluate(body_shape="A型", occasion="面试", style="通勤")
assert _hit_keys(verdict) == [
"R-BODY-A-010",
"R-OCCASION-INTERVIEW-001",
"R-STYLE-OL-001",
]
assert _norm(verdict.recommend) == {
_item("bottom", silhouette=("阔腿", "直筒")),
_item("bottom", silhouette=("直筒",)),
_item("top", collar=("衬衫领",)),
}
assert verdict.explanations == (
"A型下半身宜直线轮廓,避免紧身放大胯部。",
"面试场合需要干练可信的轮廓。",
"通勤风格以衬衫领立住精神气。",
)
def test_temperature_closed_interval_boundaries() -> None:
assert _hit_keys(_evaluate(temperature=18.0)) == ["R-TEMP-COMFORT-18-26"]
assert _hit_keys(_evaluate(temperature=26.0)) == ["R-TEMP-COMFORT-18-26"]
assert _hit_keys(_evaluate(temperature=17.9)) == []
assert _hit_keys(_evaluate(temperature=26.1)) == []
# ── 3. 用户禁忌压过专业推荐(硬性场景)──────────────────────────────────────
def test_user_taboo_overrides_trend_recommendation() -> None:
# 趋势(50)推荐短款上衣,用户禁忌(95)回避短款/露脐 → 推荐被剔除
verdict = _evaluate(style="甜酷", user_taboos=("露脐",))
assert _hit_keys(verdict) == ["R-TABOO-NAVEL-001", "R-TREND-SWEET-001"]
assert verdict.recommend == ()
assert _norm(verdict.avoid) == {_item("top", length=("露脐", "短款"))}
# 禁忌不是 is_hard:不构成硬拦截,但推荐侧已经干净
assert verdict.hard_blocks == ()
assert verdict.explanation.startswith("用户不接受露脐")
def test_user_taboo_keeps_unrelated_slot_recommendations() -> None:
# 禁忌只作用于 top.length:体型对 bottom 的推荐不受影响
verdict = _evaluate(body_shape="A型", user_taboos=("露脐",))
assert _hit_keys(verdict) == ["R-TABOO-NAVEL-001", "R-BODY-A-010"]
assert _norm(verdict.recommend) == {_item("bottom", silhouette=("阔腿", "直筒"))}
# ── 4. 硬约束拦截(is_hard)─────────────────────────────────────────────────
def test_hard_compliance_rule_blocks_gala_occasion() -> None:
verdict = _evaluate(occasion="红毯")
assert _hit_keys(verdict) == ["R-HARD-EXOTIC-001"]
assert verdict.blocked is True
assert [b.rule_key for b in verdict.hard_blocks] == ["R-HARD-EXOTIC-001"]
# 调用方用 hard_blocks.avoid 对候选做硬过滤:约束内容完整带出
assert _norm(verdict.hard_blocks[0].avoid) == {
_item("accessory", material=("真兽皮",)),
_item("shoes", material=("真皮",)),
}
assert verdict.hard_blocks[0].explanation == "合规红线:不推荐真兽皮材质。"
def test_hard_rule_not_fired_on_other_occasions() -> None:
assert _evaluate(occasion="通勤").hard_blocks == ()
assert _evaluate(occasion="通勤").blocked is False
# ── 5. 综合叠加:优先级链 + 禁忌压制 + 硬拦截同时发生 ────────────────────────
def test_composite_profile_full_priority_chain() -> None:
verdict = _evaluate(
body_shape="A型",
features=("溜肩",),
goals=("修饰溜肩",),
style="甜酷",
user_taboos=("露脐",),
occasion="红毯",
)
# 命中链严格按 priority 降序、同优先级按 confidence 降序
assert _hit_keys(verdict) == [
"R-HARD-EXOTIC-001", # 100
"R-TABOO-NAVEL-001", # 95
"R-BODY-A-002", # 90 / 0.95
"R-BODY-A-010", # 90 / 0.90
"R-TREND-SWEET-001", # 50
]
# 体型推荐保留(与禁忌不同维度);趋势推荐短款被禁忌压掉
assert _norm(verdict.recommend) == {
_item("top", shoulder=("垫肩", "自然肩"), sleeve=("泡泡袖",)),
_item("bottom", silhouette=("阔腿", "直筒")),
}
assert verdict.blocked is True
assert [b.rule_key for b in verdict.hard_blocks] == ["R-HARD-EXOTIC-001"]
+122
View File
@@ -0,0 +1,122 @@
"""service 层纯逻辑:状态机矩阵、DSL 写入校验、ORM 行 → 域规则转换。
不连库——DB 路径由 tests/integration/test_rules_engine.py 覆盖。
"""
from __future__ import annotations
from decimal import Decimal
import pytest
from packages.rules import service
from packages.shared.errors import AppError, ErrorCode
from packages.shared.models import KbRule
VALID_PAYLOAD = {
"rule_key": "R-TEST-001",
"category": "body_shape",
"priority": 90,
"conditions": {"body_shape": "A型"},
"effects": {
"recommend": [{"slot": "top", "attrs": {"shoulder": ["垫肩"]}}],
"avoid": [{"slot": "top", "attrs": {"shoulder": ["落肩"]}}],
"explanation": "测试规则",
},
"is_hard": False,
"confidence": 0.95,
}
ALL_STATUSES = ("draft", "published", "archived")
# ── 状态机全跃迁矩阵 ───────────────────────────────────────────────────────
def test_legal_transitions_accepted() -> None:
for current, target in (
("draft", "published"), # 发布
("published", "archived"), # 下线
("draft", "archived"), # 撤稿
):
service.ensure_transition(current, target) # 不抛即通过
@pytest.mark.parametrize("current", ALL_STATUSES)
@pytest.mark.parametrize("target", ALL_STATUSES)
def test_illegal_transitions_rejected(current: str, target: str) -> None:
if (current, target) in service.LEGAL_TRANSITIONS:
pytest.skip("合法跃迁另测")
with pytest.raises(AppError) as excinfo:
service.ensure_transition(current, target)
assert excinfo.value.code == ErrorCode.STATE_CONFLICT
assert excinfo.value.data == {"current": current, "target": target}
# ── DSL 写入校验 ───────────────────────────────────────────────────────────
def test_validate_rule_payload_accepts_valid_dsl() -> None:
rule = service.validate_rule_payload(VALID_PAYLOAD)
assert rule.rule_key == "R-TEST-001"
def test_validate_rule_payload_rejects_unknown_keys_with_details() -> None:
with pytest.raises(AppError) as excinfo:
service.validate_rule_payload({**VALID_PAYLOAD, "conclusions": []})
assert excinfo.value.code == ErrorCode.VALIDATION_FAILED
assert excinfo.value.data["errors"] # 拒绝原因必须可读,不能只说"不合法"
assert any("conclusions" in err for err in excinfo.value.data["errors"])
def test_validate_rule_payload_rejects_unknown_condition_field() -> None:
with pytest.raises(AppError) as excinfo:
service.validate_rule_payload({**VALID_PAYLOAD, "conditions": {"body_shap": "A型"}})
assert excinfo.value.code == ErrorCode.VALIDATION_FAILED
# ── ORM 行 → 域规则 ────────────────────────────────────────────────────────
def _row(**overrides: object) -> KbRule:
values: dict[str, object] = {
"rule_id": 1,
"rule_key": "R-ROW-001",
"category": "body_shape",
"version": 1,
"status": "published",
"priority": 90,
"conditions": {"body_shape": "A型"},
"effects": {
"recommend": [{"slot": "top", "attrs": {"shoulder": ["垫肩"]}}],
"explanation": "行转换",
},
"is_hard": False,
"confidence": Decimal("0.950"),
}
values.update(overrides)
return KbRule(**values) # type: ignore[arg-type]
def test_row_to_rule_parses_stored_jsonb() -> None:
rule = service.row_to_rule(_row())
assert rule.rule_key == "R-ROW-001"
assert rule.confidence == pytest.approx(0.95) # Numeric(4,3) → float
assert rule.conditions.clauses["body_shape"] == "A型"
assert rule.effects.recommend[0].attrs["shoulder"] == ("垫肩",)
def test_row_to_rule_rejects_corrupted_stored_dsl() -> None:
# 库里数据坏了是 5xxxx 知识库数据错误,必须响亮失败而不是静默跳过
with pytest.raises(AppError) as excinfo:
service.row_to_rule(_row(conditions={"mood": "开心"}))
assert excinfo.value.code == ErrorCode.CONFIG_ERROR
assert excinfo.value.data == {"rule_key": "R-ROW-001"}
def test_published_rules_cache_invalidate_resets() -> None:
cache = service.PublishedRules()
assert cache._rules is None
cache._rules = ()
cache.invalidate()
assert cache._rules is None