wip: 客服Agent + RAG + 画像收尾(基于 6516ccb)

This commit is contained in:
2026-09-11 14:46:40 +08:00
parent 870fd0d44e
commit e4c4099aaa
82 changed files with 13901 additions and 2426 deletions
@@ -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(