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)
+259
View File
@@ -0,0 +1,259 @@
"""画像组装:中期记忆 → 长期事实 → 画像 + 版本快照。
这是"记忆系统为画像服务"的落地环节。三段职责:
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
# 提升门槛
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")
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] = 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:
values["risk_tags"] = ";".join(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 in values:
setattr(profile, field, values[field])
profile.updated_at = now
await self.session.flush()
snapshot = {
**{field: getattr(profile, field, None) for field in PROFILE_OWNED_FIELDS},
"generated_at": now.isoformat(),
}
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` 上,
保证「每个客户最多一条 current」;因此必须先清旧再写新,顺序不能反。
"""
previous = list(await self.session.scalars(
select(ProfileSnapshot).where(
ProfileSnapshot.customer_id == customer_id, ProfileSnapshot.is_current.is_(True)
)
))
for row in previous:
row.is_current = False
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,
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
+114
View File
@@ -0,0 +1,114 @@
# 记忆分层与画像设计
> 目的:讲清短期/中期/长期/画像四层**各自存在哪里、谁写、什么条件下向上提升、是否进画像**。
> 全部基于底座已有的真实表,不是另起一套;每节末尾标注实现现状。
## 0. 一句话
```
短期(会话上下文)→ 中期(候选事实)→ 长期(稳定事实)→ 画像(决策依据)
```
**逐层提高门槛**,是为了让"随口一说"永远变不成画像结论;
**三条路径(问卷/行为/对话)在画像层汇合**,但按字段划分所有权,不互相覆盖。
## 1. 总览
| 层 | 存在哪里 | 装什么 | 谁写 | 提升门槛 | 进画像 |
| --- | --- | --- | --- | --- | --- |
| **短期** | Redis 会话列表(设计);`conversation_message` 表(现状只有存档) | 当前会话最近的对话轮次 | 每次 run 追加 | — | ❌ 不进 |
| **中期** | `memory_unit` + `memory_evidence` | 从对话抽出的候选事实,带证据链 | Worker 记忆抽取 | `evidence_count ≥ 2` 或 `confidence ≥ 0.90` | ✅ 过门槛后提升 |
| **长期** | `user_facts`(`is_critical` 标关键事实) | 已确认的稳定事实 | 由中期提升 | 由 `user_facts` 判定 | ✅ 唯一用途就是喂画像 |
| **画像** | `fin_customer_profile`(当前)+ `profile_snapshots`(版本化,带 `generation_basis`) | 整合三路来源的客户视图 | 画像组装服务 | — | 它就是画像 |
## 2. 三条路径在画像汇合
这是整套设计的核心结构。**只有对话路径需要累积证据**,另外两条是权威/客观数据,直接写:
```
对话消息 ──→ [短期] 会话上下文 ──→ Agent 用它理解指代("它""那个")
│
└─(run 完成,发 memory.extraction_requested 事件)
↓
[中期] memory_unit + memory_evidence
│ 证据累积过门槛
↓
[长期] user_facts
│
│ ┌── 问卷测评(权威)────┐
└──组装──────┤ ├──→ [画像]
└── 行为/流水(客观)──┘
│
投顾 / 风控 / 客服
```
**为什么问卷和行为不需要走前三层**:它们是**权威**(问卷由客户答题、系统评分)与**客观**(交易记录无法自称)数据,不需要"多次出现才可信"这层保护;对话自述才有"记错、修饰、被引导"的风险。
## 3. 各层细节
### 3.1 短期:会话上下文
- **装什么**:当前会话最近若干轮对话,用于解析指代与保持连贯。
- **怎么工作**:run 开始时按 `session_id` 读出历史 → 与当前消息一起交给意图分类与回答生成;run 结束时把本轮追加进去。
- **生命周期**:会话结束或长时间无活动即失效(方案 §2.2 给的是 TTL 30min、最长 24h、超 4096 token 截断旧消息)。
- **为什么不进画像**:会话上下文是"他刚才说了什么",不是"他是什么样的人"。把临时对话当画像会造成画像抖动。
- **实现现状**:❌ **未实现**。`conversation_message` 表存了全部消息、`svc_conversation_session.message_count` 只在计数,但**没有任何"加载最近 N 条"的代码**,run 时只拿到当前这一条消息。后果是多轮指代无法解析(问"那它风险高吗"无法知道"它"指谁)。
### 3.2 中期:候选事实
- **装什么**:从对话里抽出的事实(风险偏好自述、投资期限、家庭情况、资产类别偏好等),每条都带证据。
- **怎么工作**:
- 写入:run 完成 → 发布 `memory.extraction_requested` → **Worker 消费事件** → 调模型抽取 → 写 `memory_unit`,同时写一条 `memory_evidence`(含原文摘录,可回溯"这句话从哪来")。
- 读取:run 开始时 `recall_memory` 自动召回,`recall_count` 累加。
- 演进:同一事实再次出现 → `evidence_count` 增加;出现相反证据 → `conflict_count` 增加(**不直接覆盖**,留冲突待判)。
- **生命周期**:`valid_from` / `valid_until`;过期或长期无新证据即降级/失效(`memory_lifecycle_service` 负责级联失效)。
- **进画像的条件**:`evidence_count ≥ 2` **或** `confidence ≥ 0.90` —— 这条门槛就是"一次性说法不该成为画像结论"的落地方式。
- **实现现状**:✅ **已实现并可验证**。实测:客户说"我的风险偏好是稳健型,平时只买债券基金" → 抽出 `preference:risk_level = 稳健型`(置信 0.95)+ 1 条证据。
- **⚠️ 运维前提**:抽取依赖**常驻 Worker** 消费 Outbox 事件。只起 API 服务不起 Worker,事件会一直堆在 `pending`,记忆永远不产生。
### 3.3 长期:稳定事实
- **装什么**:经过证据累积确认的稳定事实,`is_critical` 标记其中对决策关键的那些(如风险偏好、投资期限)。
- **怎么工作**:只由中期提升而来(不直接从对话写);`source_portal` 记录事实来自哪个入口,`source_episode_id` 记录来自哪个会话片段。
- **为什么不直接从对话写**:长期层是画像的输入,必须保证"这条结论经过验证",否则画像会被一次性说法污染。
- **进画像**:✅ 它的存在意义就是喂画像。
- **实现现状**:❌ **表在(9 列),无生成代码**,当前 0 行。
### 3.4 画像:决策依据
- **装什么**:整合三路来源后的客户视图,是投顾、风控、客服共同读取的**唯一**客户数据入口。
- **怎么工作**:画像组装服务按字段所有权写入 → `fin_customer_profile` 保存当前值 → 每次重建写一条 `profile_snapshots`(带 `version`、`generation_basis`、`snapshot_hash`、`is_current`),从而**每次变化都可追溯**。
- **字段所有权(关键:按字段切开,避免互相覆盖)**:
| 字段 | 唯一写入方 | 性质 |
| --- | --- | --- |
| `investor_type`(C1–C5) | **只有问卷测评** | 权威、合规硬约束 |
| `total_asset`、`trading_frequency`、`behavior_score` | 交易/流水侧 | 客观行为 |
| `preferred_asset_class`、`investment_horizon`、`risk_tags` | 记忆系统(经长期层) | 软信息、需累积 |
| `real_name`、`birth_date`、`occupation`、`mobile_masked`、`opened_at` | 注册/账户流程 | 身份 |
- **客户自述风险偏好放哪**:放 `risk_tags` 并标注"客户自述",**不得写入 `investor_type`**。
保留它的价值在于:当出现「问卷 C4 / 自述稳健 / 行为买 R4」三方不一致时,**这种矛盾本身就是风控信号**。
- **实现现状**:❌ **两张表都在,无组装代码**,当前 0 行。
## 4. 红线(必须由代码保证,不能只靠约定)
1. **适当性判定只认问卷**。`investor_type` 只能由问卷写入,记忆与对话在任何情况下都不得修改它。客户在对话里说"我是激进型"不能让他买到 R5。
2. **风控与投顾只读画像**,不直接读 `memory_unit`。否则同一条记忆会被多处按各自口径解释。
3. **每条画像字段都要能回答"凭什么"**。写入时通过 `generation_basis` 记录依据来源。
4. **画像变更留版本**,不原地覆盖 —— 风控复盘时需要"当时看到的是什么"。
5. **内部资料不进入面向客户的知识库**(已按此处理:反洗钱手册 `visibility=internal`)。
## 5. 现状与缺口一览
| 环节 | 状态 |
| --- | --- |
| 对话 → 中期记忆(抽取 + 证据 + 可回溯) | ✅ 已实现(依赖常驻 Worker) |
| 中期 → 长期(证据累积与门槛) | ❌ 待实现 |
| 长期 → 画像(组装 + 版本快照) | ❌ 待实现 |
| 问卷 → 画像 | ❌ 待实现(注册问卷流程未做) |
| 行为 → 画像 | ❌ 待实现(交易模块未做) |
| 短期会话上下文 | ❌ 待实现(影响多轮指代) |
| 画像 → 投顾 / 风控 | ❌ 待实现 |
**建议顺序**:先做「中期 → 长期 → 画像」这一段(现在就有记忆数据可验证),再接问卷与行为两路,最后接下游消费者。
+74
View File
@@ -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()))