feat: 记忆→画像打通(事实提升 + 画像组装 + 版本快照)
补齐"记忆系统为画像服务"的断链,按 docs/23 的分层设计实现后三层。
1. 新增 app/model/profile.py:user_facts 与 profile_snapshots 的 ORM 映射。此前这两张表
只有结构、没有 Model,实际没有任何代码在用。两处表结构特例在 docstring 里显式标注,
避免后续有人按直觉写入踩坑:
· user_facts.id 无 auto_increment,主键必须由应用提供(本实现用微秒时间戳,单调递增);
· profile_snapshots.current_customer_id 是生成列(IF(is_current=1, customer_id, NULL)),
故意不映射——映射了反而会在写入时与之冲突。
2. 新增 app/service/profile_assembly_service.py,三段职责:
· 事实提升(中期→长期):evidence_count ≥ 2 或 confidence ≥ 0.90 才从 memory_unit
提炼进 user_facts —— 这条门槛就是"客户随口一说不能变成画像结论"的落地方式;
· 画像组装(长期→画像):按白名单映射进 fin_customer_profile,未列入白名单的事实
(如 profile:family)只进 user_facts,保证画像的信噪比;
· 版本留痕:每次重建写一条 profile_snapshots,generation_basis 逐字段记录来源,
用于回答"当时凭什么这么判断"。
3. 新增 tools/rebuild_profile.py:手工触发入口(单客户或 --all)。画像暂无自动触发,
这是目前唯一的重建方式,也便于排查"画像为什么没更新"。
红线由代码保证而非约定:investor_type 只从 fin_risk_assessment 最新一条读取,实现中
不存在任何记忆路径能写它。实测——客户 9001 问卷为 C2、对话自述"稳健型",重建后
investor_type 仍为 C2,自述信息进入 risk_tags 并标注"自述:"前缀。三方不一致保持可见,
但等级判定只认问卷,客户无法靠对话改变自己的可购范围。
另一处由实测修正的设计:fin_customer_profile 的 trade_account/real_name/total_asset/
behavior_score 均为 NOT NULL,说明画像行由开户流程创建(也印证了"注册时填问卷"是开户
前置条件)。原先"首次重建时创建画像行"的做法是错的——会写出一条假的开户记录,而画像
恰恰是风控要读的数据。已改为只更新已存在的画像,未开户时返回 reason=profile_row_not_opened
并如实报告,而不是静默成功。
同时新增 docs/23-记忆分层与画像设计.md:短期/中期/长期/画像四层各自存在哪里、谁写、
提升门槛、是否进画像,以及三条路径(问卷/行为/对话)在画像层汇合的设计。
验证:ruff 通过、mypy 109 文件无错;tools/rebuild_profile.py 对客户 9001 连续两次重建
产生 version=1/2 两条快照且 is_current 正确轮转(旧版本置 0)。
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
"""手工重建客户画像:中期记忆 → 长期事实 → 画像 + 版本快照。
|
||||
|
||||
用法:
|
||||
|
||||
```powershell
|
||||
# 重建一个客户
|
||||
python tools/rebuild_profile.py 9001
|
||||
|
||||
# 重建全部有记忆的客户
|
||||
python tools/rebuild_profile.py --all
|
||||
```
|
||||
|
||||
为什么需要这个入口:画像组装目前没有自动触发(记忆写入后不会立刻重建画像),
|
||||
在把它接上 Worker 事件之前,这是唯一的触发方式,也便于运维排查"画像为什么没更新"。
|
||||
|
||||
关于输出里的 `reason=profile_row_not_opened`:客户尚未开户时不会创建画像行
|
||||
(`trade_account` 等身份字段是 NOT NULL,属注册/开户流程所有),这是**正确行为**而非失败。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.infrastructure.db import SessionFactory
|
||||
from app.model.memory import MemoryUnit
|
||||
from app.service.profile_assembly_service import ProfileAssemblyService
|
||||
|
||||
sys.stdout.reconfigure(errors="replace")
|
||||
|
||||
|
||||
async def rebuild_one(customer_id: int) -> None:
|
||||
async with SessionFactory() as session:
|
||||
async with session.begin():
|
||||
outcome = await ProfileAssemblyService(session).rebuild(customer_id)
|
||||
print(f"\n== 客户 {customer_id} ==")
|
||||
if outcome.get("profile") is None:
|
||||
print(f" 未重建画像:{outcome.get('reason')}"
|
||||
f"(尚未开户;已提升事实 {len(outcome.get('promoted_keys') or [])} 条)")
|
||||
else:
|
||||
print(f" 提升事实:{outcome.get('promoted_keys')}")
|
||||
print(f" 画像内容:{outcome.get('profile')}")
|
||||
print(f" 生成依据:{outcome.get('generation_basis')}")
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="重建客户画像")
|
||||
parser.add_argument("customer_id", nargs="?", type=int, help="客户 id")
|
||||
parser.add_argument("--all", action="store_true", help="重建全部有记忆的客户")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.all:
|
||||
async with SessionFactory() as session:
|
||||
rows = list(await session.scalars(
|
||||
select(MemoryUnit.customer_id).distinct()
|
||||
))
|
||||
targets = [int(row) for row in rows]
|
||||
if not targets:
|
||||
print("没有任何客户有记忆数据,无需重建")
|
||||
return 0
|
||||
print(f"将重建 {len(targets)} 个客户:{targets}")
|
||||
for customer_id in targets:
|
||||
await rebuild_one(customer_id)
|
||||
return 0
|
||||
|
||||
if args.customer_id is None:
|
||||
parser.print_help()
|
||||
return 2
|
||||
await rebuild_one(args.customer_id)
|
||||
return 0
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
Reference in New Issue
Block a user