一、客服 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 生成的本地产物
77 lines
3.7 KiB
Python
77 lines
3.7 KiB
Python
"""画像快照**字段完整性**的真机回归(守 `ProfileAssemblyService` 不得写残片快照)。
|
||
|
||
## 守的是什么
|
||
|
||
`profile_snapshots` 的当前版本有多个写入方,其中两个会把它置为 `is_current=1`:
|
||
|
||
1. `ProfileGenerationService` —— 走 `build_snapshot()`,覆盖读取侧白名单全部字段;
|
||
2. `ProfileAssemblyService.rebuild_profile()` —— 记忆 → 画像重建。
|
||
|
||
第 2 个此前**就地拼一个只含 `PROFILE_OWNED_FIELDS + generated_at` 的四字段残片**。
|
||
后果(2026-09-19 连库实测,客户 9001 的当前快照停在 `{investor_type, risk_tags,
|
||
generated_at, ...}`):
|
||
|
||
- 种子里明明写了 `customer_tier: gold`,被下一次重建覆盖后**分层整个消失**
|
||
⇒ 客服「我够哪一档?」只能拿风险等级顶包;
|
||
- `assessment_valid_until` / `assessment_expired` 一起消失 ⇒ 画像读取侧再也判断不出
|
||
「测评是否过期」,与 `SuitabilityService` 的 `ASSESSMENT_EXPIRED` 失败关闭口径**分叉**;
|
||
- `total_asset` / `behavior_score` 消失,画像与「账户看板」无法对照。
|
||
|
||
所以用例按真实路径重建一次,断言当前快照覆盖 `REQUIRED_SNAPSHOT_FIELDS` 全量。
|
||
`9001` 有有效期内的测评行,因此「测评有效期 / 是否过期」两个字段也应齐全。
|
||
"""
|
||
|
||
import asyncio
|
||
|
||
import pytest
|
||
from sqlalchemy import select
|
||
|
||
from app.core.profile_projection import project_profile
|
||
from app.infrastructure.db import SessionFactory
|
||
from app.model.profile import ProfileSnapshot
|
||
from app.service.profile_assembly_service import ProfileAssemblyService
|
||
from app.service.profile_generation_service import REQUIRED_SNAPSHOT_FIELDS
|
||
|
||
CUSTOMER_ID = 9001
|
||
|
||
|
||
async def _rebuild_and_read_current() -> dict[str, object]:
|
||
async with SessionFactory() as session, session.begin():
|
||
await ProfileAssemblyService(session).rebuild(CUSTOMER_ID)
|
||
async with SessionFactory() as session:
|
||
row = await session.scalar(
|
||
select(ProfileSnapshot).where(
|
||
ProfileSnapshot.customer_id == CUSTOMER_ID,
|
||
ProfileSnapshot.is_current.is_(True),
|
||
)
|
||
)
|
||
assert row is not None, "重建后必须存在一条当前版本快照"
|
||
snapshot = row.snapshot
|
||
if isinstance(snapshot, (bytes, bytearray)):
|
||
snapshot = snapshot.decode("utf-8")
|
||
return snapshot if isinstance(snapshot, dict) else {}
|
||
|
||
|
||
@pytest.mark.integration
|
||
def test_rebuild_writes_a_complete_snapshot_not_a_partial_fragment() -> None:
|
||
"""重建画像后,当前快照必须覆盖读取侧白名单的全部字段(不得是残片)。"""
|
||
snapshot = asyncio.run(_rebuild_and_read_current())
|
||
|
||
missing = [field for field in REQUIRED_SNAPSHOT_FIELDS if field not in snapshot]
|
||
assert not missing, (
|
||
f"重建写出的快照缺少读取侧字段:{missing}(实际快照={snapshot})"
|
||
)
|
||
|
||
projected = project_profile(snapshot)
|
||
assert projected.get("investor_type"), "重建后画像必须仍可投影出风险等级"
|
||
assert "total_asset" in projected, "交易侧客观字段不能被记忆重建抹掉"
|
||
assert "assessment_expired" in projected, "测评有效期判据必须保留在画像里"
|
||
|
||
# JSON 列字段不得被二次编码:正确形状是 `["money_fund"]`,不是 `['"[\\"money_fund\\"]"']`。
|
||
# 后者会被读取侧 `_localized` 当成未知值静默丢弃,等价于「偏好资产类别」整条消失。
|
||
for field in ("preferred_asset_class", "risk_tags"):
|
||
value = snapshot.get(field)
|
||
assert isinstance(value, list), f"{field} 必须是数组,实得 {value!r}"
|
||
nested = [item for item in value if isinstance(item, str) and item.strip().startswith("[")]
|
||
assert not nested, f"{field} 出现二次编码的元素:{nested}"
|