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 可撤稿)。
251 lines
8.8 KiB
Python
251 lines
8.8 KiB
Python
"""规则引擎集成用例(真实 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()
|