Files
group_fqcd_jr/tests/unit/infrastructure/test_milvus_profile_projection.py
T
张胜宇 5d0becb67d 客服 Agent 重构收口:五出口决策链 + 知识库档位隔离 + 前端入参边界(答辩演示版本)
一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
  P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
  转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
  禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
  (输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)

二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
  knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数

三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
  ({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"

四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
  (端点与账号均已不存在,此前稳定报 3 条假红)

五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期

六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
  过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
  _chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
2026-09-20 14:33:30 +08:00

202 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""`MilvusProfileProjection` 的定向测试。
前 4 个用例移植自同事 `ZSY_develop` 的
`tests/unit/infrastructure/test_milvus_profile_projection.py`;
后 4 个覆盖本仓对其做的**两处契约放宽**(`customer_id` 兼容字符串、
不可投影键跳过而非整批失败)与脱敏,这些是移植时必须钉住的差异点。
"""
from uuid import uuid4
import pytest
from app.core.errors import RecoverableAgentError
from app.infrastructure.milvus_profile_projection import MilvusProfileProjection
class FakeMilvus:
def __init__(self, existing: list[dict[str, object]] | None = None) -> None:
self.existing = existing or []
self.queries: list[dict[str, object]] = []
self.upserts: list[dict[str, object]] = []
async def query(self, **kwargs: object) -> list[dict[str, object]]:
self.queries.append(kwargs)
return self.existing
async def upsert(self, **kwargs: object) -> None:
self.upserts.append(kwargs)
def payload() -> dict[str, object]:
return {
"customer_id": 7,
"profile_version": 1,
"memory_sources": [{
"memory_uuid": str(uuid4()),
"memory_key": "preference:risk_level",
"content": "稳健型",
"memory_type": "preference",
"confidence": 0.9,
"version": 2,
"valid_until": None,
}],
}
def _source(data: dict[str, object]) -> dict[str, object]:
sources = data["memory_sources"]
assert isinstance(sources, list)
source = sources[0]
assert isinstance(source, dict)
return source
@pytest.mark.asyncio
async def test_upsert_writes_schema_fields_and_vector() -> None:
client = FakeMilvus()
projection = MilvusProfileProjection(client, _embed)
await projection.upsert(payload())
assert len(client.upserts) == 1
row = client.upserts[0]["data"][0]
assert row["customer_id"] == 7
assert row["status"] == "active"
assert len(row["embedding"]) == 1024
@pytest.mark.asyncio
async def test_lower_memory_version_is_not_overwritten() -> None:
data = payload()
memory_uuid = _source(data)["memory_uuid"]
client = FakeMilvus(existing=[{
"memory_uuid": memory_uuid, "customer_id": 7, "version": 3,
}])
await MilvusProfileProjection(client, _embed).upsert(data)
assert client.upserts == []
@pytest.mark.asyncio
async def test_embedding_dimension_is_enforced() -> None:
with pytest.raises(RecoverableAgentError, match="维度"):
await MilvusProfileProjection(client=FakeMilvus(), embed=_embed_short).upsert(
payload()
)
@pytest.mark.asyncio
async def test_non_uuid_memory_id_is_rejected() -> None:
data = payload()
_source(data)["memory_uuid"] = "unsafe\" or true"
with pytest.raises(ValueError, match="memory_uuid"):
await MilvusProfileProjection(FakeMilvus(), _embed).upsert(data)
# --- 本仓放宽的契约(移植差异点) -------------------------------------------
@pytest.mark.asyncio
async def test_string_customer_id_is_accepted() -> None:
"""本仓三处生产者写的都是 `str(customer_id)`;不接受字符串则事件必然全部失败。"""
data = payload()
data["customer_id"] = "9102"
client = FakeMilvus()
await MilvusProfileProjection(client, _embed).upsert(data)
assert client.upserts[0]["data"][0]["customer_id"] == 9102
@pytest.mark.asyncio
async def test_non_numeric_customer_id_is_rejected() -> None:
"""放宽不等于不校验:uuid 之类的非数字串必须拒绝,不能当成客户号写进向量库。"""
data = payload()
data["customer_id"] = "957c0552-7fa2-4f2a-924d-d2d2e133b245"
with pytest.raises(ValueError, match="customer_id"):
await MilvusProfileProjection(FakeMilvus(), _embed).upsert(data)
@pytest.mark.asyncio
async def test_version_key_fallback_is_supported() -> None:
"""本仓生产端 payload 用 `version`;适配器契约用 `profile_version`。两个都要认。"""
data = payload()
del data["profile_version"]
data["version"] = 5
client = FakeMilvus()
await MilvusProfileProjection(client, _embed).upsert(data)
assert len(client.upserts) == 1
@pytest.mark.asyncio
async def test_non_projectable_memory_key_is_skipped_not_fatal() -> None:
"""`constraint:*` / `profile:*` 不在可投影前缀内。
关键:一条不可投影的键**不得**毒死同一客户其余可投影记忆。
"""
data = payload()
good = _source(data)
data["memory_sources"] = [
{
"memory_uuid": str(uuid4()),
"memory_key": "constraint:liquidity",
"content": "半年内需要流动性",
"memory_type": "constraint",
"confidence": 0.8,
"version": 1,
"valid_until": None,
},
good,
]
client = FakeMilvus()
await MilvusProfileProjection(client, _embed).upsert(data)
assert len(client.upserts) == 1
rows = client.upserts[0]["data"]
assert [row["memory_key"] for row in rows] == ["preference:risk_level"]
@pytest.mark.asyncio
async def test_all_keys_non_projectable_writes_nothing() -> None:
"""全部不可投影时不写 Milvus,但也不报错(不是失败,是无需投影)。"""
data = payload()
_source(data)["memory_key"] = "profile:occupation"
client = FakeMilvus()
await MilvusProfileProjection(client, _embed).upsert(data)
assert client.upserts == []
@pytest.mark.asyncio
async def test_sensitive_credentials_are_sanitized_before_write() -> None:
"""落外部存储前必须脱敏:手机号不得原样写进向量库。"""
data = payload()
_source(data)["content"] = "我的手机号是 13800138000,稳健型"
client = FakeMilvus()
await MilvusProfileProjection(client, _embed).upsert(data)
content = client.upserts[0]["data"][0]["content"]
assert "13800138000" not in content
assert "[手机号已隐藏]" in content
def _vector(size: int = 1024) -> list[float]:
return [0.0] * size
async def _embed(_: str) -> list[float]:
return _vector()
async def _embed_short(_: str) -> list[float]:
return _vector(3)