一、客服 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 生成的本地产物
326 lines
16 KiB
Python
326 lines
16 KiB
Python
"""画像组装:中期记忆 → 长期事实 → 画像 + 版本快照。
|
||
|
||
这是"记忆系统为画像服务"的落地环节。三段职责:
|
||
|
||
1. **事实提升(中期 → 长期)**:把 `memory_unit` 里证据足够的记忆提炼进 `user_facts`。
|
||
门槛是 `evidence_count >= 2` **或** `confidence >= 0.90` —— 这条门槛就是
|
||
"客户随口一说不能变成画像结论"的落地方式。
|
||
2. **画像组装(长期 → 画像)**:把 `user_facts` 按**白名单**映射进 `fin_customer_profile`。
|
||
未列入白名单的事实只进 `user_facts`,不进画像,避免画像被噪声撑大。
|
||
3. **版本留痕**:每次重建写一条 `profile_snapshots`,并用 `generation_basis` 记录
|
||
**每个字段分别来自哪里**——风控与合规复盘时要能回答"当时凭什么这么判断"。
|
||
|
||
## 两条必须由代码保证的红线
|
||
|
||
- **`investor_type` 只来自问卷测评**(`fin_risk_assessment` 最新一条)。下面的实现里
|
||
它只从问卷查询取数,任何记忆路径都碰不到它。客户在对话里说"我是激进型"不会改变它
|
||
——这是合规底线,不能只靠约定。
|
||
- **按字段所有权写入**:交易侧的客观字段(`total_asset`/`trading_frequency`/`behavior_score`)
|
||
本服务**不写**,留给交易模块,避免两个模块抢写同一列。
|
||
"""
|
||
|
||
import json as _json
|
||
from datetime import UTC, datetime
|
||
from hashlib import sha256
|
||
from typing import Any
|
||
from uuid import uuid4
|
||
|
||
from sqlalchemy import or_, select, text
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.model.fund import FundCustomerProfile
|
||
from app.model.memory import MemoryUnit
|
||
from app.model.profile import ProfileSnapshot, UserFact
|
||
from app.repository.profile_repository import ProfileRepository
|
||
from app.service.profile_generation_service import build_snapshot
|
||
|
||
# 提升门槛
|
||
MIN_EVIDENCE = 2
|
||
HIGH_CONFIDENCE = 0.90
|
||
|
||
# 事实键 → 画像字段的**白名单**映射。没列在这里的事实(如 profile:family)只进 user_facts,
|
||
# 不进画像字段——画像要保持"能直接支撑决策"的信噪比。
|
||
FACT_TO_PROFILE_FIELD: dict[str, str] = {
|
||
"preference:asset_class": "preferred_asset_class",
|
||
"preference:horizon": "investment_horizon",
|
||
}
|
||
|
||
# 自述类事实(客户自己说的偏好)统一进 risk_tags,并标注来源为"自述"。
|
||
# 保留它们的价值在于:当出现「问卷 C4 / 自述稳健 / 行为买 R4」三方不一致时,
|
||
# 这种矛盾本身就是风控信号——但绝不能与问卷等级混进同一个字段。
|
||
SELF_REPORTED_PREFIXES = ("preference:risk_level", "preference:", "profile:")
|
||
|
||
# 关键事实:参与决策,标记出来便于下游优先读取
|
||
CRITICAL_FACTS = frozenset({
|
||
"preference:risk_level", "preference:horizon", "preference:asset_class",
|
||
})
|
||
|
||
# 画像中允许本服务写入的字段(其余字段归交易/注册侧所有)
|
||
PROFILE_OWNED_FIELDS = ("investor_type", "preferred_asset_class", "investment_horizon", "risk_tags")
|
||
|
||
#: `PROFILE_OWNED_FIELDS` 里在**画像表上是 JSON 列**的两个字段(见 `app/model/fund.py`)。
|
||
#:
|
||
#: 给它们套 `_as_text` 会把列表序列化成字符串,字符串再被 JSON 列二次编码 ——
|
||
#: 实测 `preferred_asset_class` 一度长成 `["[\\"money_fund\\"]"]`,而读取侧 `_localized`
|
||
#: 认不出这个值(既不在标签表里、又含非中文码),于是**整条"偏好资产类别"被静默丢弃**。
|
||
#: 同理 `None` 会被写成字面量 `"null"`(`app/service/profile_generation_service.py::_as_list`
|
||
#: 为此专门加了兜底)。JSON 列存原始值,文本列才做脱引号。
|
||
PROFILE_JSON_FIELDS = frozenset({"preferred_asset_class", "risk_tags"})
|
||
|
||
|
||
def _now() -> datetime:
|
||
return datetime.now(UTC).replace(tzinfo=None)
|
||
|
||
|
||
def _fact_id() -> int:
|
||
"""`user_facts.id` 没有 auto_increment,主键由应用生成。
|
||
|
||
用微秒时间戳:单调递增、无需额外序列、同客户同微秒重复在单进程写入下不可能发生。
|
||
"""
|
||
return int(datetime.now(UTC).timestamp() * 1_000_000)
|
||
|
||
|
||
class ProfileAssemblyService:
|
||
def __init__(self, session: AsyncSession) -> None:
|
||
self.session = session
|
||
|
||
# ---------- 中期 → 长期 ----------
|
||
|
||
async def promote_facts(self, customer_id: int) -> list[str]:
|
||
"""把证据足够的记忆提炼为长期事实;返回本次提升的事实键。"""
|
||
now = _now()
|
||
rows = list(await self.session.scalars(
|
||
select(MemoryUnit).where(
|
||
MemoryUnit.customer_id == customer_id,
|
||
MemoryUnit.status == "active",
|
||
or_(
|
||
MemoryUnit.evidence_count >= MIN_EVIDENCE,
|
||
MemoryUnit.confidence >= HIGH_CONFIDENCE,
|
||
),
|
||
or_(MemoryUnit.valid_until.is_(None), MemoryUnit.valid_until > now),
|
||
)
|
||
))
|
||
promoted: list[str] = []
|
||
for memory in rows:
|
||
key = str(memory.memory_key)
|
||
value = self._fact_value(memory)
|
||
existing = await self.session.scalar(
|
||
select(UserFact).where(
|
||
UserFact.customer_id == customer_id, UserFact.fact_key == key
|
||
)
|
||
)
|
||
if existing is None:
|
||
self.session.add(UserFact(
|
||
# 主键显式赋值:该表无 auto_increment
|
||
id=_fact_id(),
|
||
customer_id=customer_id,
|
||
fact_key=key,
|
||
fact_value=value,
|
||
source_portal=str(memory.source_type or "conversation"),
|
||
source_episode_id=None,
|
||
confidence=float(memory.confidence or 0.0),
|
||
is_critical=key in CRITICAL_FACTS,
|
||
created_at=now,
|
||
))
|
||
else:
|
||
existing.fact_value = value
|
||
existing.confidence = float(memory.confidence or 0.0)
|
||
existing.is_critical = key in CRITICAL_FACTS
|
||
promoted.append(key)
|
||
await self.session.flush()
|
||
return promoted
|
||
|
||
@staticmethod
|
||
def _fact_value(memory: MemoryUnit) -> Any:
|
||
"""事实值优先取结构化值,回退到正文;始终以 JSON 可存的形式返回。"""
|
||
structured = memory.structured_value
|
||
if isinstance(structured, dict) and "value" in structured:
|
||
return structured["value"]
|
||
if structured is not None:
|
||
return structured
|
||
return memory.content or ""
|
||
|
||
# ---------- 长期 → 画像 ----------
|
||
|
||
async def rebuild_profile(self, customer_id: int) -> dict[str, Any]:
|
||
"""用长期事实 + 问卷重建画像,并写一条版本快照。"""
|
||
now = _now()
|
||
facts = list(await self.session.scalars(
|
||
select(UserFact).where(UserFact.customer_id == customer_id)
|
||
))
|
||
assessment = (await self.session.execute(text(
|
||
"""
|
||
SELECT investor_type, questionnaire_version, assessed_at, valid_until
|
||
FROM fin_risk_assessment
|
||
WHERE customer_id = :customer_id
|
||
ORDER BY assessed_at DESC, id DESC
|
||
LIMIT 1
|
||
"""
|
||
), {"customer_id": customer_id})).first()
|
||
|
||
values: dict[str, Any] = {}
|
||
basis: dict[str, Any] = {}
|
||
|
||
# 红线:风险等级只从问卷取;记忆里哪怕有 preference:risk_level 也不写这个字段
|
||
if assessment is not None and assessment[0]:
|
||
values["investor_type"] = str(assessment[0])
|
||
basis["investor_type"] = {
|
||
"source": "fin_risk_assessment",
|
||
"questionnaire_version": assessment[1],
|
||
"assessed_at": str(assessment[2]),
|
||
"valid_until": str(assessment[3]),
|
||
}
|
||
|
||
tags: list[str] = []
|
||
for fact in facts:
|
||
key = str(fact.fact_key)
|
||
field = FACT_TO_PROFILE_FIELD.get(key)
|
||
if field is not None:
|
||
values[field] = (
|
||
fact.fact_value if field in PROFILE_JSON_FIELDS
|
||
else self._as_text(fact.fact_value)
|
||
)
|
||
basis[field] = {
|
||
"source": "user_facts", "fact_key": key,
|
||
"confidence": float(fact.confidence or 0.0),
|
||
}
|
||
elif key.startswith(SELF_REPORTED_PREFIXES):
|
||
# 自述信息进标签,并显式标注"自述",与问卷等级区分开
|
||
tags.append(f"自述:{key}={self._as_text(fact.fact_value)}")
|
||
basis.setdefault("risk_tags", {"source": "user_facts", "items": []})
|
||
basis["risk_tags"]["items"].append(key)
|
||
if tags:
|
||
# `risk_tags` 是 JSON 列(数组),不是"用分隔符拼起来的字符串":
|
||
# 拼字符串会被 JSON 列二次编码,读取侧再解析时多出一层引号。
|
||
# 下游消费方(`risk_repository`)本来就按列表用(`profile.risk_tags or []`)。
|
||
values["risk_tags"] = tags
|
||
|
||
profile = await self.session.get(FundCustomerProfile, customer_id)
|
||
if profile is None:
|
||
# 画像行由开户流程创建:`trade_account` 等身份字段在库里是 NOT NULL,属注册/账户侧
|
||
# 所有。本服务不代替开户去造这些数据——否则会写出一条**假的**开户记录,
|
||
# 而画像恰恰是风控要读的东西,假数据比没有数据更危险。未开户时如实报告。
|
||
return {
|
||
"profile": None,
|
||
"reason": "profile_row_not_opened",
|
||
"generation_basis": basis,
|
||
"promoted": len(facts),
|
||
}
|
||
for field in PROFILE_OWNED_FIELDS:
|
||
if field == "investor_type":
|
||
# 问卷是唯一权威:本轮没有问卷记录时**保持原值**(既不写入也不清空)。
|
||
# 否则重测前的空档会把开户时的等级抹掉,而该列是 NOT NULL。
|
||
if field in values:
|
||
setattr(profile, field, values[field])
|
||
continue
|
||
# 其余字段由本服务独占:本轮没有对应事实即清空。
|
||
# 这不只是洁癖——记忆失效后若不清理,画像会留着一个已经作废的投资期限,
|
||
# 投顾据此给建议,而客户从未授权这条信息继续生效(实测踩到过)。
|
||
setattr(profile, field, values.get(field))
|
||
profile.updated_at = now
|
||
await self.session.flush()
|
||
|
||
# ⚠️ 快照必须走 `build_snapshot()` 这**唯一**一个实现,不能就地拼一个"只含本服务
|
||
# 独占字段"的残片。原因:`_write_snapshot()` 会把新行置为 `is_current=1`,
|
||
# 所以这里产出的形状**就是**读取侧(`project_profile` → 客服画像工具)看到的全部内容。
|
||
#
|
||
# 2026-09-19 实测到的后果:这里原先只写
|
||
# `PROFILE_OWNED_FIELDS + generated_at` 四个字段,一写就把权威快照里的
|
||
# `total_asset` / `behavior_score` / `customer_tier` / `assessment_valid_until` /
|
||
# `assessment_expired` 全部抹掉 —— 客户 9001 的当前快照被覆盖到只剩
|
||
# `investor_type`,于是「我够哪一档?」只能拿风险等级顶包,而
|
||
# 「测评已过期」这条**失败关闭**依据在画像读取侧彻底消失(与 `SuitabilityService`
|
||
# 的 `ASSESSMENT_EXPIRED` 口径分叉)。两个写者各写一套形状,必然漂移。
|
||
#
|
||
# 字段所有权(`PROFILE_OWNED_FIELDS`)约束的是**写 `fin_customer_profile` 表**的归属,
|
||
# 不是快照的字段集合:本服务读交易侧字段来投影,不等于"抢写"。
|
||
profile_row = await ProfileRepository(self.session).profile_row(customer_id)
|
||
if profile_row is None:
|
||
# 上面已确认画像行存在,这里只是防御;真出现时如实报告而不是造快照。
|
||
return {
|
||
"profile": None,
|
||
"reason": "profile_row_missing_after_write",
|
||
"generation_basis": basis,
|
||
"promoted": len(facts),
|
||
}
|
||
assessment_row = await ProfileRepository(self.session).latest_assessment(customer_id)
|
||
snapshot = build_snapshot(
|
||
profile_row, assessment_row, now=now.replace(tzinfo=UTC)
|
||
)
|
||
await self._write_snapshot(customer_id, snapshot, basis, now)
|
||
return {"profile": snapshot, "generation_basis": basis, "promoted": len(facts)}
|
||
|
||
@staticmethod
|
||
def _as_text(value: Any) -> str:
|
||
"""把 JSON 列里取出的值渲染成可读字符串。
|
||
|
||
字符串类型的值可能带着 JSON 序列化时的外层引号(取决于驱动如何回读 JSON 列),
|
||
这里去掉它们——`risk_tags` 是给风控与投顾看的,多一对引号会让人以为值本身包含引号。
|
||
"""
|
||
if isinstance(value, str):
|
||
return value.strip().strip('"')
|
||
return _json.dumps(value, ensure_ascii=False)
|
||
|
||
async def _write_snapshot(
|
||
self, customer_id: int, snapshot: dict[str, Any], basis: dict[str, Any], now: datetime
|
||
) -> None:
|
||
"""写入新版本快照并把旧版本置为非当前。
|
||
|
||
唯一键 `uk_profile_snapshot_current` 建在列 `current_customer_id` 上(**不是**
|
||
`is_current`),保证「每个客户最多一条 current」;因此**换当前版本时必须
|
||
同时清掉旧行的那一列**,只改 `is_current` 是不够的。
|
||
|
||
⚠️ 2026-09-14 修:这里此前两件事都没做对 —— 旧行只置 `is_current=False`、
|
||
新行**不写** `current_customer_id`。后果是**这个唯一键从来没起作用**
|
||
(实测 13 行该列全 NULL),而且埋了一颗雷:候选批准路径(
|
||
`CustomerProfileCandidateService._write_profile_snapshot`)是**会写**这一列的,
|
||
它能正常工作的前提是"这一列当前没人占"。只要这个客户先被重建过一次
|
||
(旧 current 行仍占着 `current_customer_id=<客户号>`),下一次批准候选就会
|
||
撞 `Duplicate entry '<客户号>' for key 'uk_profile_snapshot_current'` → 整次批准 500。
|
||
演示链路"客户说完 → 管理员批准"会在这里断掉,所以必须按唯一键的真实语义写。
|
||
"""
|
||
previous = list(await self.session.scalars(
|
||
select(ProfileSnapshot).where(
|
||
ProfileSnapshot.customer_id == customer_id,
|
||
or_(
|
||
ProfileSnapshot.is_current.is_(True),
|
||
ProfileSnapshot.current_customer_id == customer_id,
|
||
),
|
||
)
|
||
))
|
||
for row in previous:
|
||
row.is_current = False
|
||
# 归还唯一键的占用(旧行变历史版本后该列必须为空)。
|
||
row.current_customer_id = None
|
||
row.updated_at = now
|
||
await self.session.flush()
|
||
|
||
latest = await self.session.scalar(text(
|
||
"SELECT COALESCE(MAX(version), 0) FROM profile_snapshots WHERE customer_id = :cid"
|
||
), {"cid": customer_id})
|
||
version = int(latest or 0) + 1
|
||
payload = _json.dumps(snapshot, ensure_ascii=False, sort_keys=True)
|
||
self.session.add(ProfileSnapshot(
|
||
profile_uuid=str(uuid4()),
|
||
customer_id=customer_id,
|
||
version=version,
|
||
snapshot=snapshot,
|
||
generation_basis=basis,
|
||
snapshot_hash=sha256(payload.encode("utf-8")).hexdigest(),
|
||
is_current=True,
|
||
# 当前版本必须显式写客户号(`app/model/profile.py` 的模块 docstring 第 2 条)。
|
||
current_customer_id=customer_id,
|
||
generated_at=now,
|
||
created_at=now,
|
||
updated_at=now,
|
||
))
|
||
await self.session.flush()
|
||
|
||
# ---------- 完整链路 ----------
|
||
|
||
async def rebuild(self, customer_id: int) -> dict[str, Any]:
|
||
promoted = await self.promote_facts(customer_id)
|
||
outcome = await self.rebuild_profile(customer_id)
|
||
outcome["promoted_keys"] = promoted
|
||
return outcome
|