merge: T7 规则引擎·数据驱动决策表(T-P2-02,TD-10)
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user