Files
group_fqcd_jr/app/service/profile_assembly_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

326 lines
16 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.
"""画像组装:中期记忆 → 长期事实 → 画像 + 版本快照。
这是"记忆系统为画像服务"的落地环节。三段职责:
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