merge: T7 规则引擎·数据驱动决策表(T-P2-02,TD-10)

This commit is contained in:
xsl
2026-08-14 20:11:23 +08:00
11 changed files with 1984 additions and 9 deletions
+250
View File
@@ -0,0 +1,250 @@
"""规则引擎集成用例(真实 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()