Files
meida_backend/packages/rules/service.py
T
xsl e50a9424a1 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 可撤稿)。
2026-08-14 20:10:21 +08:00

241 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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