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:
xsl
2026-08-14 20:10:21 +08:00
parent d5f366fa4c
commit e50a9424a1
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