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
+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