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 可撤稿)。
This commit is contained in:
@@ -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()`。
|
||||
|
||||
@@ -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 状态机、已发布规则缓存
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user