Files
group_fqcd_jr/app/service/profile_assembly_service.py
T
lzf_0626 9490e5043b 记忆系统演示文档 + 修掉两处会让演示断链的真问题
## 新增:docs/演示用/记忆系统演示文档-2026-09-14.md
按"操作 → 看到什么 → 这体现什么"写,五个场景(探针看存储 / 记住一件事 / 谁能读谁读不到 /
画像版本与投影 / 停 Worker),每个场景配可复现命令与**实测输出**,另附建议顺序与时长、
六条问答话术、演示前自检清单、受控信号词表摘录。

配套两个新工具(都已实跑):
- `tools/memory_demo_chain.py`:一键走完"客户说 → 候选 → 客户确认 → 管理员批准 →
  记忆与画像自动收敛",打印演示前后对比(实测 2 秒收敛)。
- `tools/memory_recall_demo.py`:同一客户换六个身份召回,当场看出"客户只读自己 /
  员工要权限码+归属 / 运营读不到 / 管理员有权限码但没归属也读不到"。

## 修掉两处会让演示当场断链的问题(都是真机复现的)

1. **候选批准后画像字段不收敛**
   `CustomerProfileCandidateService._promote` 只写画像快照、**不投**
   `profile.rebuild_requested`,而 `user_facts` 与画像字段是 `ProfileAssemblyService`
   的重建链路写的。后果:批准后记忆变 active、快照版本 +1,但**画像字段停在旧值**
   (客户说"三年以内",画像里还是"十年以上"),要等一次无关的重建才收敛 ——
   而"客户说完 → 批准 → 画像变了"正是演示主线。
   现补一条重建事件(走事件而不是就地重建:本方法所在事务还没提交,
   另开 session 看不到刚写入的记忆)。

2. **画像快照的唯一键从来没起作用,而且埋雷**
   `uk_profile_snapshot_current` 建在列 `current_customer_id` 上(不是 `is_current`),
   但 `ProfileAssemblyService._write_snapshot` 旧行只置 `is_current=False`(不清该列)、
   新行**不写**该列(实测 13 行该列全 NULL)。
   后果:只要客户**先被重建过一次**,旧 current 行仍占着 `current_customer_id=9001`,
   下一次"批准画像候选"就会撞 `Duplicate entry '9001' for key
   uk_profile_snapshot_current` → **整次批准 500**(本次实测踩到)。
   现按唯一键的真实语义写:清旧行的该列、新行显式写客户号。
   (`ProfileGenerationService` / 候选路径本来就是这么写的,只有这一处没对齐。)

## 守卫
新增 `tests/integration/test_profile_snapshot_current_invariant_mysql.py`:
按真实顺序"先重建再批准候选",断言 ① 只有一条 current 且它占着唯一键、
② 历史版本已归还该列、③ 批准不再 500、④ 批准投出了重建事件。
修复前这条用例会在第 ② 步失败。

## 验证
- `pytest tests/unit tests/contract` → 1490 passed, 2 skipped, 0 failed
- 新增集成用例通过;真机实测演示主线:候选 → verified → active → **2 秒内**
  `user_facts` 与 `fin_customer_profile.investment_horizon` 都变成新值
- `mypy tools/memory_demo_chain.py tools/memory_recall_demo.py` → 0 错;ruff 全绿

## 文档
`docs/44-演示流程.md`:配套文档清单与"记忆链路"备选场景都指向新演示文档。
2026-09-15 00:55:44 +08:00

286 lines
13 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
# 提升门槛
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 == "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()
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` 上(**不是**
`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