Files
group_fqcd_jr/app/service/profile_assembly_service.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +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