一、客服 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 生成的本地产物
183 lines
7.5 KiB
Python
183 lines
7.5 KiB
Python
"""画像生成的数据访问层(Repository)。
|
||
|
||
按 MVC+S 边界:**Service 不得直接建 Session / 直接查 Model**,所有库交互收口在这里。
|
||
|
||
只做两件事:
|
||
1. **只读**组装画像所需的权威事实(`docs/00` §6.4.6:画像生成只允许读取**已提交的**业务事实
|
||
和状态为**有效**的中期记忆);
|
||
2. **写入**画像版本与两条跨存储同步事件(同事务,由调用方管理事务)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.model.memory import MemorySyncOutbox
|
||
|
||
#: 画像快照:查询客户主表 + 客户分层。
|
||
#:
|
||
#: **为什么必须 JOIN `sys_user`**:`customer_tier`(客户分层:金卡 / 白金 / 钻石 / 尊享)的
|
||
#: 权威列在 `sys_user` 上,`fin_customer_profile` **没有**这一列。而 `D3.1` §3.5 与 `DEC-19`
|
||
#: 裁定 (a) 都把「读 `customer_level`」列为客服侧的**受准只读字段**(供确定性规则与画像问答),
|
||
#: `PROFILE_FIELD_POLICY` / `customer_profile_service.ALLOWED_FIELDS` 也早已把它写进白名单 ——
|
||
#: 唯独没有取数来源,于是它在"白名单里躺了很久却永远是空"。这里补上取数,字段才真正可达。
|
||
#:
|
||
#: 用 `LEFT JOIN`(不是 `INNER JOIN`):客户可能没有 `sys_user` 行(历史数据),
|
||
#: 此时画像仍应生成,只是分层为空 —— 不能因为缺一行账号把整张画像判成"不存在"。
|
||
_SQL_PROFILE = text("""
|
||
SELECT p.customer_id, p.investor_type, p.investment_horizon, p.preferred_asset_class,
|
||
p.trading_frequency, p.total_asset, p.behavior_score, p.risk_tags, p.last_active_at,
|
||
u.customer_tier
|
||
FROM fin_customer_profile AS p
|
||
LEFT JOIN sys_user AS u ON u.id = p.customer_id
|
||
WHERE p.customer_id = :customer_id
|
||
""")
|
||
|
||
#: 风险测评:取**最新一条**,含有效期(用于判定是否过期)。
|
||
_SQL_ASSESSMENT = text("""
|
||
SELECT id, questionnaire_version, investor_type, total_score, assessed_at, valid_until
|
||
FROM fin_risk_assessment
|
||
WHERE customer_id = :customer_id
|
||
ORDER BY assessed_at DESC, id DESC
|
||
LIMIT 1
|
||
""")
|
||
|
||
#: 当前画像版本(取 `is_current=1` 那一条)。
|
||
_SQL_CURRENT_SNAPSHOT = text("""
|
||
SELECT id, profile_uuid, version, snapshot, snapshot_hash, generation_basis
|
||
FROM profile_snapshots
|
||
WHERE customer_id = :customer_id AND is_current = 1
|
||
LIMIT 1
|
||
""")
|
||
|
||
#: 下一版本号 = 当前最大版本 + 1
|
||
#: (`uk_profile_snapshot_version (customer_id, version)` 要求版本号按客户唯一)。
|
||
_SQL_NEXT_VERSION = text("""
|
||
SELECT COALESCE(MAX(version), 0) + 1
|
||
FROM profile_snapshots WHERE customer_id = :customer_id
|
||
""")
|
||
|
||
#: 该客户**有效**的长期记忆(投影到 Milvus 长期记忆向量集合的数据源)。
|
||
#:
|
||
#: 只取 `status='active'`:失效/被取代的记忆不应再进入向量召回,否则投顾会召回
|
||
#: 已过期偏好。字段与 `MilvusProfileProjection` 的 `memory_sources` 契约一一对应。
|
||
_SQL_ACTIVE_MEMORIES = text("""
|
||
SELECT memory_uuid, memory_key, content, memory_type, confidence, version, valid_until
|
||
FROM memory_unit
|
||
WHERE customer_id = :customer_id AND status = 'active'
|
||
ORDER BY id
|
||
""")
|
||
|
||
#: 旧画像置非当前(`current_customer_id` 有唯一键,必须先置 0 才能插新的当前版本)。
|
||
_SQL_CLEAR_CURRENT = text("""
|
||
UPDATE profile_snapshots
|
||
SET is_current = 0, current_customer_id = NULL, updated_at = :now
|
||
WHERE customer_id = :customer_id AND is_current = 1
|
||
""")
|
||
|
||
_SQL_INSERT_SNAPSHOT = text("""
|
||
INSERT INTO profile_snapshots
|
||
(profile_uuid, customer_id, version, snapshot, generation_basis, snapshot_hash,
|
||
is_current, current_customer_id, generated_at, created_at, updated_at)
|
||
VALUES
|
||
(:profile_uuid, :customer_id, :version, :snapshot, :generation_basis, :snapshot_hash,
|
||
1, :customer_id, :now, :now, :now)
|
||
""")
|
||
|
||
|
||
class ProfileRepository:
|
||
def __init__(self, session: AsyncSession) -> None:
|
||
self._session = session
|
||
|
||
# --- 只读事实 ---------------------------------------------------------
|
||
|
||
async def profile_row(self, customer_id: int) -> dict[str, Any] | None:
|
||
result = await self._session.execute(_SQL_PROFILE, {"customer_id": customer_id})
|
||
row = result.mappings().first()
|
||
return dict(row) if row is not None else None
|
||
|
||
async def latest_assessment(self, customer_id: int) -> dict[str, Any] | None:
|
||
result = await self._session.execute(_SQL_ASSESSMENT, {"customer_id": customer_id})
|
||
row = result.mappings().first()
|
||
return dict(row) if row is not None else None
|
||
|
||
async def current_snapshot(self, customer_id: int) -> dict[str, Any] | None:
|
||
result = await self._session.execute(_SQL_CURRENT_SNAPSHOT, {"customer_id": customer_id})
|
||
row = result.mappings().first()
|
||
return dict(row) if row is not None else None
|
||
|
||
async def active_memories(self, customer_id: int) -> list[dict[str, Any]]:
|
||
"""该客户所有 `status='active'` 的长期记忆,按 id 稳定排序。"""
|
||
result = await self._session.execute(
|
||
_SQL_ACTIVE_MEMORIES, {"customer_id": customer_id}
|
||
)
|
||
return [dict(row) for row in result.mappings().all()]
|
||
|
||
async def next_version(self, customer_id: int) -> int:
|
||
return int(await self._session.scalar(_SQL_NEXT_VERSION, {"customer_id": customer_id}))
|
||
|
||
# --- 写入(调用方管事务) ---------------------------------------------
|
||
|
||
async def clear_current(self, customer_id: int, *, now: datetime) -> None:
|
||
"""把旧画像的 `is_current` 置 0 —— 必须在插入新版本**之前**调用。
|
||
|
||
`uk_profile_snapshot_current` 建在 `current_customer_id` 上(唯一),
|
||
所以同一客户同时只能有一条 `is_current=1`;顺序颠倒会撞唯一键。
|
||
"""
|
||
await self._session.execute(_SQL_CLEAR_CURRENT, {"customer_id": customer_id, "now": now})
|
||
|
||
async def insert_snapshot(
|
||
self,
|
||
*,
|
||
customer_id: int,
|
||
version: int,
|
||
profile_uuid: str,
|
||
snapshot_json: str,
|
||
generation_basis_json: str,
|
||
snapshot_hash: str,
|
||
now: datetime,
|
||
) -> None:
|
||
await self._session.execute(_SQL_INSERT_SNAPSHOT, {
|
||
"profile_uuid": profile_uuid,
|
||
"customer_id": customer_id,
|
||
"version": version,
|
||
"snapshot": snapshot_json,
|
||
"generation_basis": generation_basis_json,
|
||
"snapshot_hash": snapshot_hash,
|
||
"now": now,
|
||
})
|
||
|
||
def add_sync_event(
|
||
self,
|
||
*,
|
||
event_uuid: str,
|
||
target_store: str,
|
||
aggregate_uuid: str,
|
||
aggregate_version: int,
|
||
payload: dict[str, Any],
|
||
now: datetime,
|
||
status: str,
|
||
operation: str,
|
||
) -> None:
|
||
"""追加一条跨存储同步事件。
|
||
|
||
唯一键 `uk_memory_sync_event (event_uuid, target_store)`:**同一 `event_uuid` 允许
|
||
对两个目标库各写一条**,因此 Milvus 与 Neo4j 共用同一个 `event_uuid`。
|
||
"""
|
||
self._session.add(MemorySyncOutbox(
|
||
event_uuid=event_uuid,
|
||
aggregate_type="profile",
|
||
aggregate_uuid=aggregate_uuid,
|
||
aggregate_version=aggregate_version,
|
||
target_store=target_store,
|
||
operation=operation,
|
||
payload=payload,
|
||
status=status,
|
||
retry_count=0,
|
||
created_at=now,
|
||
))
|