一、客服 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 生成的本地产物
202 lines
6.1 KiB
Python
202 lines
6.1 KiB
Python
"""`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)
|