Files
meida_backend/tests/unit/test_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

123 lines
4.5 KiB
Python

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