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:
2026-09-10 21:36:23 +08:00
parent 20a3a2f249
commit 962a0a116f
4 changed files with 509 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
"""画像与用户事实的 ORM 映射。
只映射既有表结构,**不改变任何字段**(`AGENTS.md` 第 3/4 条)。两处与常见表不同的设计在
这里显式标注,避免后续有人按直觉写入而踩坑:
1. `user_facts.id` 在库里**没有 auto_increment**,插入时必须由应用显式提供主键;
2. `profile_snapshots.current_customer_id` 是**生成列**(`IF(is_current=1, customer_id, NULL)`),
与唯一键 `uk_profile_snapshot_current` 共同保证「每个客户最多一条当前快照」。生成列由数据库
维护,因此这里**故意不映射**——映射了反而会在写入时与之冲突。
"""
from datetime import datetime
from typing import Any
from sqlalchemy import CHAR, JSON, BigInteger, Boolean, DateTime, Float, String
from sqlalchemy.orm import Mapped, mapped_column
from app.model.base import Base
class UserFact(Base):
"""用户事实(user_facts):由中期记忆提升而来的稳定事实,供画像组装读取。
表上**没有** (customer_id, fact_key) 唯一键(只有两个普通索引),因此"同一事实只保留一条"
必须由服务层用"先查后写"保证,不能依赖数据库约束。
"""
__tablename__ = "user_facts"
# 注意:库中该列无 auto_increment,主键由服务层显式赋值(单调递增)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
customer_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
fact_key: Mapped[str] = mapped_column(String(128), nullable=False)
fact_value: Mapped[Any] = mapped_column(JSON, nullable=False)
source_portal: Mapped[str] = mapped_column(String(16), nullable=False)
source_episode_id: Mapped[int | None] = mapped_column(BigInteger)
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
is_critical: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class ProfileSnapshot(Base):
"""画像快照(profile_snapshots):画像的版本化留痕。
存在意义是回答"当时依据的是什么"——风控复盘与合规检查都需要它,所以画像变更
**只新增版本、不原地覆盖**。
"""
__tablename__ = "profile_snapshots"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
profile_uuid: Mapped[str | None] = mapped_column(CHAR(36), unique=True)
customer_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
version: Mapped[int] = mapped_column(BigInteger, nullable=False)
snapshot: Mapped[Any] = mapped_column(JSON, nullable=False)
generation_basis: Mapped[Any | None] = mapped_column(JSON)
snapshot_hash: Mapped[str | None] = mapped_column(CHAR(64))
is_current: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
generated_at: Mapped[datetime | None] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
# current_customer_id 为生成列,由数据库维护,故意不映射(见模块 docstring)