Files
meida_backend/tests/integration/test_rules_engine.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

251 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.
"""规则引擎集成用例(真实 PostgreSQL):published 载入、draft 不载入、硬约束拦截。
覆盖任务契约:插 3 条规则(含 1 条 is_hard)→ service 载入 → 求值命中与拦截
正确;draft 规则不被载入。规则键带随机后缀 + 前缀清理,可与任意环境的存量
数据共存。
"""
from __future__ import annotations
import os
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
import pytest
def _infra_available() -> bool:
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not _infra_available(),
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
),
]
_PREFIX = "R-IT7-"
def _key(suffix: str) -> str:
return f"{_PREFIX}{suffix}-{uuid.uuid4().hex[:6]}"
@asynccontextmanager
async def _session() -> AsyncIterator[Any]:
"""每个用例独立 engine:pytest-asyncio 每个用例一个事件循环,
共享全局连接池会把上一个 loop 的连接带进下一个用例(Future attached to
a different loop)。用完 dispose,进程内全局池保持零污染。
"""
from sqlalchemy.ext.asyncio import async_sessionmaker
from packages.shared.config import get_settings
from packages.shared.db import create_engine
engine = create_engine(get_settings())
factory = async_sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
try:
async with factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await engine.dispose()
async def _cleanup() -> None:
from sqlalchemy import delete
from packages.rules import service as rules_service
from packages.shared.models import KbRule
rules_service.invalidate_published_cache()
async with _session() as session:
await session.execute(delete(KbRule).where(KbRule.rule_key.like(f"{_PREFIX}%")))
def _body_payload(rule_key: str) -> dict[str, Any]:
return {
"rule_key": rule_key,
"category": "body_shape",
"priority": 90,
"confidence": 0.95,
"conditions": {"body_shape": "A型", "features": {"include": ["溜肩"]}},
"effects": {
"recommend": [{"slot": "top", "attrs": {"shoulder": ["IT7垫肩"]}}],
"avoid": [{"slot": "top", "attrs": {"shoulder": ["IT7落肩"]}}],
"explanation": "IT7 体型规则",
},
}
def _hard_payload(rule_key: str) -> dict[str, Any]:
return {
"rule_key": rule_key,
"category": "compliance",
"priority": 100,
"confidence": 1.0,
"is_hard": True,
"conditions": {"occasion": {"include": ["IT7晚宴"]}},
"effects": {
"avoid": [{"slot": "accessory", "attrs": {"material": ["IT7真兽皮"]}}],
"explanation": "IT7 硬约束",
},
}
def _draft_payload(rule_key: str) -> dict[str, Any]:
return {
"rule_key": rule_key,
"category": "body_shape",
"priority": 90,
"conditions": {"body_shape": "A型"},
"effects": {
"recommend": [{"slot": "dress", "attrs": {"silhouette": ["IT7草稿独有"]}}],
"explanation": "IT7 未发布规则",
},
}
async def test_published_loaded_draft_not_loaded_and_hard_blocks() -> None:
from packages.rules import dsl
from packages.rules import service as rules_service
await _cleanup()
body_key, hard_key, draft_key = _key("BODY"), _key("HARD"), _key("DRAFT")
try:
async with _session() as session:
svc = rules_service.RuleService(session)
await svc.create(_body_payload(body_key), author="it7")
await svc.create(_hard_payload(hard_key), author="it7")
await svc.create(_draft_payload(draft_key), author="it7") # 保持 draft
await svc.publish(body_key)
await svc.publish(hard_key)
ctx = dsl.RuleContext(body_shape="A型", features=("溜肩",), occasion="IT7晚宴")
# 求值:published 载入(两条命中),draft 不载入
async with _session() as session:
svc = rules_service.RuleService(session)
verdict = await svc.evaluate(ctx)
hit_keys = [h.rule_key for h in verdict.rule_hits]
assert body_key in hit_keys
assert hard_key in hit_keys
assert draft_key not in hit_keys
# 硬约束:is_hard 的 avoid 命中 → hard_blocks 带出可过滤的约束内容
assert verdict.blocked is True
block_keys = [b.rule_key for b in verdict.hard_blocks]
assert hard_key in block_keys
hard_block = next(b for b in verdict.hard_blocks if b.rule_key == hard_key)
assert hard_block.avoid[0].slot == "accessory"
assert hard_block.avoid[0].attrs["material"] == ("IT7真兽皮",)
# 命中的推荐来自 body 规则;draft 的效果从未出现
rec_values = {
value
for item in verdict.recommend
for values in item.attrs.values()
for value in values
}
assert "IT7垫肩" in rec_values
assert "IT7草稿独有" not in rec_values
# 下线 body 规则 → 缓存失效 → 再求值不再命中(保留 hard 规则互不干扰)
async with _session() as session:
svc = rules_service.RuleService(session)
await svc.archive(body_key)
async with _session() as session:
svc = rules_service.RuleService(session)
verdict_after = await svc.evaluate(ctx)
after_keys = [h.rule_key for h in verdict_after.rule_hits]
assert body_key not in after_keys
assert hard_key in after_keys
assert draft_key not in after_keys
finally:
await _cleanup()
async def test_create_rejects_invalid_dsl() -> None:
from packages.shared.errors import AppError, ErrorCode
await _cleanup()
try:
async with _session() as session:
from packages.rules.service import RuleService
svc = RuleService(session)
# 未识别的键(conclusions 是架构旧列名)必须被拒绝而不是静默忽略
with pytest.raises(AppError) as excinfo:
await svc.create({**_body_payload(_key("BAD")), "conclusions": ["垫肩"]})
assert excinfo.value.code == ErrorCode.VALIDATION_FAILED
finally:
await _cleanup()
async def test_state_machine_guards_on_real_rows() -> None:
from packages.shared.errors import AppError, ErrorCode
await _cleanup()
sm_key = _key("SM")
other_key = _key("SM2")
try:
async with _session() as session:
from packages.rules.service import RuleService
svc = RuleService(session)
row = await svc.create(_body_payload(sm_key), author="it7")
assert row.status == "draft"
assert row.version == 1
published = await svc.publish(sm_key)
assert published.status == "published"
assert published.published_at is not None
# published 不能再发布、不能直接改内容(要改必须先下线)
with pytest.raises(AppError) as republish:
await svc.publish(sm_key)
assert republish.value.code == ErrorCode.STATE_CONFLICT
with pytest.raises(AppError) as edit:
await svc.update(sm_key, _body_payload(sm_key))
assert edit.value.code == ErrorCode.STATE_CONFLICT
# draft 可以修改且 version+1;rule_key 不可改
draft_row = await svc.create(_body_payload(other_key), author="it7")
# identity map:update 返回的是同一 ORM 对象,先快照修改前的版本号
base_version = draft_row.version
updated = await svc.update(
other_key,
{
**_body_payload(other_key),
"priority": 85,
"effects": {
"recommend": [{"slot": "top", "attrs": {"shoulder": ["IT7改"]}}],
"explanation": "IT7 修改后",
},
},
)
assert updated.version == base_version + 1
assert updated.priority == 85
with pytest.raises(AppError) as rename:
await svc.update(other_key, _body_payload(_key("OTHER")))
assert rename.value.code == ErrorCode.BAD_REQUEST
# archived 是终态
await svc.archive(sm_key)
with pytest.raises(AppError) as rearchive:
await svc.archive(sm_key)
assert rearchive.value.code == ErrorCode.STATE_CONFLICT
finally:
await _cleanup()