Files
group_fqcd_jr/tests/unit/service/test_profile_generation_service.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

373 lines
15 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.
"""画像版本生成器单元测试(`docs/00` §6.4.6 的四条规则)。
覆盖四个方向,都要有牙:
1. **同事务两条事件**:一次生成必须同时投 `MILVUS` 与 `NEO4J` 两条,共用同一 `event_uuid`。
2. **幂等**:`snapshot_hash` 未变 → **不升版本、不投事件**(否则每次调用都灌 outbox)。
3. **旧版本置 0**:清旧当前标记必须发生在插新版本**之前**(唯一键约束)。
4. **失败关闭**:客户主表没有该行 → 抛错,不凭空造画像。
5. **字段对齐**:写入快照必须覆盖读取侧白名单所需字段(否则端点会返回空画像)。
"""
from __future__ import annotations
import json
from datetime import UTC, datetime, timedelta
from typing import Any
import pytest
from app.core.errors import ValidationAgentError
from app.core.profile_projection import project_profile
from app.service.profile_generation_service import (
REQUIRED_SNAPSHOT_FIELDS,
SYNC_OPERATION_UPSERT,
SYNC_STATUS_PENDING,
SYNC_TARGETS,
ProfileGenerationService,
build_snapshot,
compute_hash,
)
NOW = datetime(2026, 9, 11, 12, 0, tzinfo=UTC)
# ---------------------------------------------------------------------------
# 替身:只实现 ProfileRepository 用到的方法
# ---------------------------------------------------------------------------
class FakeRepository:
"""记录调用顺序与写入内容;`executed` 用来断言"先清旧、后插新"。"""
def __init__(
self,
*,
profile: dict[str, Any] | None = None,
assessment: dict[str, Any] | None = None,
current: dict[str, Any] | None = None,
next_version: int = 2,
memories: list[dict[str, Any]] | None = None,
) -> None:
self._profile = profile
self._assessment = assessment
self._current = current
self._next_version = next_version
self._memories = memories or []
self.executed: list[str] = []
self.inserted_snapshot: dict[str, Any] | None = None
self.sync_events: list[dict[str, Any]] = []
async def profile_row(self, _cid: int) -> dict[str, Any] | None:
self.executed.append("profile_row")
return self._profile
async def latest_assessment(self, _cid: int) -> dict[str, Any] | None:
self.executed.append("latest_assessment")
return self._assessment
async def current_snapshot(self, _cid: int) -> dict[str, Any] | None:
self.executed.append("current_snapshot")
return self._current
async def active_memories(self, _cid: int) -> list[dict[str, Any]]:
self.executed.append("active_memories")
return self._memories
async def next_version(self, _cid: int) -> int:
self.executed.append("next_version")
return self._next_version
async def clear_current(self, _cid: int, *, now: datetime) -> None:
self.executed.append("clear_current")
async def insert_snapshot(self, **kwargs: Any) -> None:
self.executed.append("insert_snapshot")
self.inserted_snapshot = kwargs
def add_sync_event(self, **kwargs: Any) -> None:
self.executed.append("add_sync_event")
self.sync_events.append(kwargs)
def service(monkeypatch: pytest.MonkeyPatch, repo: FakeRepository) -> ProfileGenerationService:
monkeypatch.setattr(
"app.service.profile_generation_service.ProfileRepository", lambda _session: repo
)
return ProfileGenerationService(session=object()) # type: ignore[arg-type]
def profile_row(**overrides: Any) -> dict[str, Any]:
row: dict[str, Any] = {
"customer_id": 9102,
"investor_type": "C3",
"investment_horizon": "medium_term",
"trading_frequency": "medium",
"preferred_asset_class": ["bond_fund"],
"risk_tags": ["balanced"],
"behavior_score": 61,
"customer_tier": "platinum",
"total_asset": "860000.00",
"last_active_at": NOW,
}
row.update(overrides)
return row
def assessment_row(valid_days: int = 200) -> dict[str, Any]:
return {
"id": 99102,
"questionnaire_version": "v2026.1",
"investor_type": "C3",
"assessed_at": NOW - timedelta(days=165),
"valid_until": (NOW + timedelta(days=valid_days)).replace(tzinfo=None),
}
# ---------------------------------------------------------------------------
# 1. 快照构建
# ---------------------------------------------------------------------------
def test_snapshot_covers_every_field_the_read_side_needs() -> None:
"""写侧必须覆盖读取侧白名单所需字段,否则端点会返回空画像。"""
snapshot = build_snapshot(profile_row(), assessment_row(), now=NOW)
missing = [f for f in REQUIRED_SNAPSHOT_FIELDS if f not in snapshot]
assert not missing, f"快照缺少读取侧需要的字段:{missing}"
def test_snapshot_is_projectable_by_the_read_path() -> None:
"""端到端对齐:生成出来的快照经 `project_profile` 能得到非空投影。"""
snapshot = build_snapshot(profile_row(), assessment_row(), now=NOW)
projected = project_profile(snapshot, now=NOW)
assert projected["investor_type"] == "C3"
assert projected["assessment_expired"] is False
def test_snapshot_carries_customer_tier_from_its_authority() -> None:
"""客户分层必须进快照,且取数自 `sys_user`(`D3.1` §3.5 / `DEC-19` 的受准只读字段)。
回归背景:`REQUIRED_SNAPSHOT_FIELDS` 曾把 `customer_tier` 排除在外,于是「白名单要它、
写侧没有它」长期分叉 —— 客服侧症状是「我够哪一档?」只能拿风险等级顶包。
"""
snapshot = build_snapshot(profile_row(), assessment_row(), now=NOW)
assert snapshot["customer_tier"] == "platinum"
assert project_profile(snapshot, now=NOW)["customer_tier"] == "platinum"
assert "customer_tier" in REQUIRED_SNAPSHOT_FIELDS
def test_json_null_literal_is_not_a_preference() -> None:
"""JSON 列里的字面量 "null" 是"没有值",不能被当成一个叫 null 的偏好。"""
snapshot = build_snapshot(
profile_row(preferred_asset_class="null"), assessment_row(), now=NOW
)
assert snapshot["preferred_asset_class"] == []
def test_json_string_value_loses_its_serialization_quotes() -> None:
"""JSON 列里的字符串值不能带着序列化外层引号进快照(实测会长成 `"\\"自述:...\\""`)。"""
snapshot = build_snapshot(
profile_row(risk_tags="\"自述:preference:communication=短信通知优先\""),
assessment_row(), now=NOW,
)
assert snapshot["risk_tags"] == ["自述:preference:communication=短信通知优先"]
def test_expired_assessment_is_marked_expired_in_snapshot() -> None:
"""过期测评必须在快照里标成过期(与读取侧"按当前时间重算"同向)。"""
snapshot = build_snapshot(profile_row(), assessment_row(valid_days=-45), now=NOW)
assert snapshot["assessment_expired"] is True
assert project_profile(snapshot, now=NOW)["assessment_expired"] is True
def test_no_assessment_means_no_fabricated_validity() -> None:
"""没有测评行时**不写**有效期/过期标记,而不是伪造一个。"""
snapshot = build_snapshot(profile_row(), None, now=NOW)
assert "assessment_valid_until" not in snapshot
assert "assessment_expired" not in snapshot
def test_hash_is_stable_against_key_order() -> None:
"""哈希是幂等判据,不能被字典插入顺序影响。"""
a = {"investor_type": "C3", "behavior_score": 61}
b = {"behavior_score": 61, "investor_type": "C3"}
assert compute_hash(a) == compute_hash(b)
assert compute_hash({"x": 1}) != compute_hash({"x": 2})
# ---------------------------------------------------------------------------
# 2. 首次生成:两条事件 + 顺序
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_first_generation_writes_two_events_and_clears_old_current(
monkeypatch: pytest.MonkeyPatch,
) -> None:
repo = FakeRepository(profile=profile_row(), assessment=assessment_row(), current=None)
result = await service(monkeypatch, repo).generate(9102, now=NOW)
assert result.changed is True
assert result.version == 2
assert result.sync_events == 2
# 两个目标存储各一条,且**共用同一个 event_uuid**(唯一键是 (event_uuid, target_store))
targets = [e["target_store"] for e in repo.sync_events]
assert targets == list(SYNC_TARGETS)
assert len({e["event_uuid"] for e in repo.sync_events}) == 1
# `aggregate_type='profile'` 由仓储层固定写入(不在 kwargs 里),此处断言服务传入的实体标识
assert len({e["aggregate_uuid"] for e in repo.sync_events}) == 1
assert all(e["aggregate_uuid"] == result.profile_uuid for e in repo.sync_events)
# 断言取值本身,且**与消费端领取条件对齐**:outbox worker 只领 `pending`/`failed`,
# 写成别的取值事件就永远没人消费。这里不再硬编码字面量(硬编码正是当初跑偏的原因)。
assert all(e["operation"] == SYNC_OPERATION_UPSERT for e in repo.sync_events)
assert all(e["status"] == SYNC_STATUS_PENDING for e in repo.sync_events)
assert SYNC_OPERATION_UPSERT == "upsert"
assert SYNC_STATUS_PENDING == "pending"
assert set(SYNC_TARGETS) == {"milvus", "neo4j"}
# 顺序:清旧当前标记必须在插新版本之前(否则撞唯一键)
assert repo.executed.index("clear_current") < repo.executed.index("insert_snapshot")
# 新版本标为当前
assert repo.inserted_snapshot is not None
assert repo.inserted_snapshot["version"] == 2
@pytest.mark.asyncio
async def test_payload_carries_memory_sources_for_projection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""payload 必须带 `memory_sources` 与 `profile_version`。
这是 Milvus 长期记忆投影的输入契约:适配器要 `memory_sources` 才知道往向量库
写什么,要 `profile_version`(或 `version`)才认得出这一批属于哪个画像版本。
缺了它,事件能被领取、却什么也投影不出来——属于"静默空转",必须由测试挡住。
"""
memories = [{
"memory_uuid": "11111111-2222-3333-4444-555555555555",
"memory_key": "preference:risk_level",
"content": "稳健型",
"memory_type": "preference",
"confidence": 0.9,
"version": 1,
"valid_until": None,
}]
repo = FakeRepository(
profile=profile_row(), assessment=assessment_row(), current=None, memories=memories
)
result = await service(monkeypatch, repo).generate(9102, now=NOW)
for sync_event in repo.sync_events:
payload = sync_event["payload"]
assert payload["profile_version"] == result.version
assert payload["version"] == result.version
sources = payload["memory_sources"]
assert len(sources) == 1
assert sources[0]["memory_uuid"] == memories[0]["memory_uuid"]
assert sources[0]["memory_key"] == "preference:risk_level"
@pytest.mark.asyncio
async def test_payload_memory_sources_is_empty_without_active_memories(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""没有有效记忆时给**空列表**(而不是省略该键)。
省略键会让适配器的 `memory_sources is invalid` 报错、事件反复重试直至死信;
空列表是"确实没有要投影的记忆",语义不同。这里把这个区别钉住。
"""
repo = FakeRepository(profile=profile_row(), assessment=assessment_row(), current=None)
await service(monkeypatch, repo).generate(9102, now=NOW)
for sync_event in repo.sync_events:
assert sync_event["payload"]["memory_sources"] == []
@pytest.mark.asyncio
async def test_each_version_gets_a_fresh_profile_uuid(monkeypatch: pytest.MonkeyPatch) -> None:
"""`profile_uuid` 有唯一键,**每个版本必须用新 uuid**(实测撞过 Duplicate entry)。"""
old = {"profile_uuid": "old-uuid", "version": 1, "snapshot_hash": "different"}
repo = FakeRepository(profile=profile_row(), assessment=assessment_row(), current=old)
result = await service(monkeypatch, repo).generate(9102, now=NOW)
assert result.profile_uuid != "old-uuid"
assert repo.inserted_snapshot is not None
assert repo.inserted_snapshot["profile_uuid"] == result.profile_uuid
# ---------------------------------------------------------------------------
# 3. 幂等
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unchanged_content_does_not_bump_version_or_emit_events(
monkeypatch: pytest.MonkeyPatch,
) -> None:
snapshot = build_snapshot(profile_row(), assessment_row(), now=NOW)
current = {
"profile_uuid": "u1",
"version": 7,
"snapshot_hash": compute_hash(snapshot),
}
repo = FakeRepository(profile=profile_row(), assessment=assessment_row(), current=current)
result = await service(monkeypatch, repo).generate(9102, now=NOW)
assert result.changed is False
assert result.version == 7 # 不升版本
assert result.sync_events == 0 # 不投事件
assert "insert_snapshot" not in repo.executed
assert repo.sync_events == []
@pytest.mark.asyncio
async def test_changed_content_bumps_version_and_reemits(
monkeypatch: pytest.MonkeyPatch,
) -> None:
current = {"profile_uuid": "u1", "version": 3, "snapshot_hash": "stale-hash"}
repo = FakeRepository(
profile=profile_row(total_asset="999999.00"),
assessment=assessment_row(),
current=current,
next_version=4,
)
result = await service(monkeypatch, repo).generate(9102, now=NOW)
assert result.changed is True
assert result.version == 4
assert result.sync_events == 2
# ---------------------------------------------------------------------------
# 4. 失败关闭
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_missing_customer_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
"""主表没有该客户 → 抛错,不凭空造画像。"""
repo = FakeRepository(profile=None)
with pytest.raises(ValidationAgentError, match="客户不存在"):
await service(monkeypatch, repo).generate(99999, now=NOW)
assert repo.inserted_snapshot is None
assert repo.sync_events == []
# ---------------------------------------------------------------------------
# 5. 事件载荷
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_event_payload_carries_version_and_hash(monkeypatch: pytest.MonkeyPatch) -> None:
repo = FakeRepository(profile=profile_row(), assessment=assessment_row(), current=None)
result = await service(monkeypatch, repo).generate(9102, now=NOW)
payload = repo.sync_events[0]["payload"]
assert payload["version"] == result.version
assert payload["snapshot_hash"] == result.snapshot_hash
assert payload["customer_id"] == "9102"
assert payload["snapshot"]["investor_type"] == "C3"
# 载荷必须可 JSON 序列化(outbox 是 JSON 列)
json.dumps(payload, ensure_ascii=False)