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 可撤稿)。
123 lines
4.5 KiB
Python
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
|