wip: 客服Agent + RAG + 画像收尾(基于 6516ccb)
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
"""合规层种子数据的集成验证(真实 MySQL,不使用 mock)。
|
||||
|
||||
断言口径与 `app/service/agent/governance.py` L62-65 的取数条件**逐字一致**:
|
||||
`status='active' AND reviewer_id IS NOT NULL AND reviewed_at IS NOT NULL`。
|
||||
用别的口径断言会得出"种子已生效"的假结论——判据一改,governance 仍查不到规则。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.contracts import AgentDefinition, RequestContext
|
||||
from app.infrastructure.db import SessionFactory
|
||||
from app.service.agent.governance import PlatformGovernance
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
#: docs/02 §10.2 逐字要求的 7 个零容忍词(监管口径,不得增减)。
|
||||
NEGATIVE_WORDS = ("保本", "稳赚", "无风险", "保证收益", "预期收益率", "年化收益率", "安全")
|
||||
|
||||
#: `agent_reply_template.scene` 的 CHECK 取值集里,本次要覆盖的 6 类固定话术场景。
|
||||
EXPECTED_SCENES = {
|
||||
"disclaimer",
|
||||
"clarification",
|
||||
"low_confidence",
|
||||
"compliance_block",
|
||||
"transfer",
|
||||
"system_busy",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_seven_negative_words_are_active_and_reviewed() -> None:
|
||||
async with SessionFactory() as session:
|
||||
rows = (await session.execute(text(
|
||||
"SELECT word_pattern FROM agent_negative_word "
|
||||
"WHERE status='active' AND reviewer_id IS NOT NULL AND reviewed_at IS NOT NULL"
|
||||
))).scalars().all()
|
||||
patterns = " ".join(rows)
|
||||
for word in NEGATIVE_WORDS:
|
||||
assert word in patterns, f"负面词未生效:{word}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_templates_cover_all_six_scenes() -> None:
|
||||
async with SessionFactory() as session:
|
||||
rows = (await session.execute(text(
|
||||
"SELECT template_code, scene FROM agent_reply_template "
|
||||
"WHERE status='active' AND reviewer_id IS NOT NULL AND reviewed_at IS NOT NULL"
|
||||
))).all()
|
||||
scenes = {row.scene for row in rows}
|
||||
assert len(rows) >= 6, f"话术模板不足 6 条:{len(rows)}"
|
||||
assert len(scenes) >= 6, f"场景不足 6 类:{scenes}"
|
||||
# 比"数量够"更严的一步:6 类场景必须正好是 DDL CHECK 取值集里要覆盖的那 6 类。
|
||||
# 只数数量的话,把 6 条都塞进同一个 scene 也能过——那已经不是"6 类固定话术"了。
|
||||
assert scenes == EXPECTED_SCENES, f"话术场景与 DDL 取值不符:{scenes}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_negative_rules_are_gated_to_customer_service_only() -> None:
|
||||
"""规则必须按 `applicable_agents` 门控,且只对客服 Agent 生效。
|
||||
|
||||
为什么不能只查 SQL:光断言"规则在库里存在且 active"**抓不到门控被破坏**——门控只在
|
||||
`governance.py` L70-74 里生效,而那条判据是 `if agents and definition.agent_type not in
|
||||
agents: continue`,两种坏取值各有各的坏法(均为实测结论):
|
||||
- `applicable_agents` 指向别的 Agent(如 `["advisor"]`)→ 客服一条都加载不到(服务静默失效);
|
||||
- 置空 `[]` 或 `NULL` → 判据的 `if agents` 为假、`continue` 不执行 → 规则**溢出到所有
|
||||
Agent**(advisor 也加载到 11 条),是 fail-open 方向。
|
||||
所以正面断言(客服加载数 == 库里生效数)与反面断言(其他 Agent 必须为 0)缺一不可:
|
||||
只留正面断言会漏掉 `[]`/`NULL` 的溢出。三种坏取值下本测试都会失败(已逐一实测)。
|
||||
"""
|
||||
context = RequestContext(user_id="9001", trace_id="t", roles=("customer",))
|
||||
service = PlatformGovernance()
|
||||
|
||||
customer_service = await service.resolve(
|
||||
AgentDefinition(agent_type="customer_service", version="1"), context)
|
||||
other_agent = await service.resolve(
|
||||
AgentDefinition(agent_type="advisor", version="1"), context)
|
||||
|
||||
async with SessionFactory() as session:
|
||||
seeded = await session.scalar(text(
|
||||
"SELECT COUNT(*) FROM agent_negative_word "
|
||||
"WHERE status='active' AND reviewer_id IS NOT NULL AND reviewed_at IS NOT NULL"
|
||||
))
|
||||
assert seeded >= len(NEGATIVE_WORDS), f"生效规则少于 7 条硬要求:{seeded}"
|
||||
assert len(customer_service.negative_rules) == seeded, (
|
||||
f"客服 Agent 应加载全部 {seeded} 条规则,实际 {len(customer_service.negative_rules)} 条")
|
||||
assert customer_service.negative_rules, "客服 Agent 一条规则都没加载"
|
||||
assert other_agent.negative_rules == (), (
|
||||
f"其他 Agent 不应加载这些规则,实际加载了 {other_agent.negative_rules}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_own_reply_templates_do_not_trip_the_seeded_rules() -> None:
|
||||
"""话术不得被自己的规则拦截——否则 Task 2 接入话术时会形成自绊循环。
|
||||
|
||||
治理层对 `contains` 是朴素子串匹配(`governance.py` L127-128),**没有否定式豁免**:
|
||||
写过「非保本」的合规话术会命中「保本」规则。目前 `governance.py` L131 命中后替换的是
|
||||
写死的句子、并不读 `safe_reply_template_code`,所以自绊尚未暴露;等 Task 2 把话术接进
|
||||
治理层,"替换文本又被规则过滤"的循环就会出现。这条测试把该事实固定住。
|
||||
"""
|
||||
async with SessionFactory() as session:
|
||||
rules = (await session.execute(text(
|
||||
"SELECT word_pattern FROM agent_negative_word "
|
||||
"WHERE status='active' AND reviewer_id IS NOT NULL AND reviewed_at IS NOT NULL"
|
||||
))).scalars().all()
|
||||
templates = (await session.execute(text(
|
||||
"SELECT template_code, content_text FROM agent_reply_template "
|
||||
"WHERE status='active' AND reviewer_id IS NOT NULL AND reviewed_at IS NOT NULL"
|
||||
))).all()
|
||||
|
||||
assert rules, "没有任何生效规则,这条自绊检查会退化成永远通过"
|
||||
assert templates, "没有任何生效话术"
|
||||
trips = [
|
||||
(row.template_code, word)
|
||||
for row in templates
|
||||
for word in rules
|
||||
if word in (row.content_text or "")
|
||||
]
|
||||
assert trips == [], f"话术命中了自己的禁用词(自绊):{trips}"
|
||||
@@ -98,7 +98,13 @@ async def test_config_release_lifecycle_is_versioned_and_audited() -> None:
|
||||
)
|
||||
await session.execute(
|
||||
delete(DomainEventOutbox).where(
|
||||
DomainEventOutbox.aggregate_id.in_([str(release_id), str(rollback_id)])
|
||||
DomainEventOutbox.aggregate_id.in_([str(release_id), str(rollback_id)]),
|
||||
# 必须限定 event_type:`aggregate_id` 是字符串列,`release_id` 是自增小整数,
|
||||
# 与其它域用小整数做 `aggregate_id` 的记录(例如知识库的
|
||||
# `knowledge.vector_sync_requested`,aggregate_id = fin_knowledge_meta.id)
|
||||
# 数值上会碰撞。只按 aggregate_id 删会**误删其它域的 outbox 行**,
|
||||
# 表现为那些知识行永远拿不到向量同步事件、静默不被索引。
|
||||
DomainEventOutbox.event_type == "config.cache_invalidate_requested",
|
||||
)
|
||||
)
|
||||
await session.execute(
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.main import create_app
|
||||
from app.model.audit import InteractionAudit
|
||||
from app.model.conversation import ConversationMessage
|
||||
from app.model.platform import AgentRun, DomainEventOutbox, OutboxDelivery, RequestIdempotency
|
||||
from app.service.agent.governance import FALLBACK_DISCLAIMER
|
||||
from app.service.agent_persistence_service import AgentPersistenceService
|
||||
from app.service.agent_run_application_service import AgentRunApplicationService
|
||||
from app.worker.runtime import WorkerRuntime
|
||||
@@ -50,7 +51,10 @@ async def test_http_accept_worker_commit_query_and_repeat(acceptance_registry, r
|
||||
result = (await client.get(f"/api/v1/agent-runs/{run_id}")).json()["data"]
|
||||
assert result["status"] == ("failed" if revoked else "succeeded")
|
||||
if not revoked:
|
||||
assert result["result"]["content"] == "test result"
|
||||
# 治理层强制注入固定免责声明(门禁 F5:面向客户输出 100% 附固定话术),
|
||||
# 落库正文因此是「Agent 正文 + 免责声明」,不再是纯正文——断言改为包含。
|
||||
assert result["result"]["content"].startswith("test result")
|
||||
assert FALLBACK_DISCLAIMER in result["result"]["content"]
|
||||
assert not await runtime.execute(run_id)
|
||||
async with SessionFactory() as session:
|
||||
messages = list(await session.scalars(select(ConversationMessage).where(
|
||||
|
||||
Reference in New Issue
Block a user