Files
group_fqcd_jr/tools/seed_profile_demo.py
T

415 lines
23 KiB
Python
Raw Normal View History

"""画像模块演示/测试数据种子(6 条,三张表都写)。
用法:
.\\.venv\\Scripts\\python.exe tools\\seed_profile_demo.py
## 为什么是"6 条 + 边界覆盖"
用户 2026-09-10 决定:**画像虚拟数据先做 5 条**,用于画像读取、适当性判定与后续演示。
5 条不是随机凑数,而是覆盖**风险等级与测评有效期的边界**。
**2026-09-19 `W7` 增补第 6 条(9105)并把过期边界从 9001 挪走**,理由见下。
### 为什么过期边界必须离开 9001(`W7` 实测,HTTP 层才暴露)
`app/api/dependencies/auth.py` 的**开户测评前置**会对 `customer` 角色调用
`RiskQuestionnaireService.is_required()`,只要该客户**没有有效期内的测评**就整站 403
「请先完成开户风险测评问卷」。9001 是 `cust_t` 这个**演示账号**,评测前把它的测评设成过期
⇒ 客户登录后**任何**接口都 403,演示直接停摆(进程内探针绕过了这层 HTTP 依赖,所以此前没暴露)。
因此:**9001 恢复有效测评(演示可用)**,过期边界移到新客户 **9105**(仅数据层覆盖
「过期测评不得放行」的失败关闭)。
| customer_id | 画像 | 等级 | 测评有效期 | 覆盖目的 |
|---|---|---|---|---|
| 9101 | 企业主 / 高净值 | C5 | 有效 | 最高风险等级 |
| 9102 | 中学教师 / 中产 | C3 | 有效 | 中间等级(R3 产品可买、R5 不可) |
| 9103 | 退休职工 | C1 | 有效 | 最低风险承受能力 |
| 9104 | 互联网产品经理 / 年轻白领 | C2 | 有效 | 新手 + 低资产 |
| 9001 | **既有演示账号 `cust_t`** | C1 | 有效 | 演示主账号:画像/分层/适当性问答都要走得通 |
| 9105 | 过期测评客户(新增) | C2 | **已过期 45 天** | **失败关闭**:过期测评不得放行 |
## 分层档位与总资产必须同口径(2026-09-19 修正)
`total_asset`(画像侧:客户在**本公司体系内**持有的可投资金融资产)与 `sys_user.customer_tier`
(分层)**必须落在同一个区间**,否则演示被追问「才 6 万怎么会是金卡」时自相矛盾。
公开门槛与 `knowledge/product/高净值客户服务规范.md` §1.1、`knowledge/faq/高频问答对.txt`
的 FAQ-0014 一致(`customer_service.py` 的 `TIER_LABELS` 负责中文化):
| 分层 | 代码值 | 公开门槛 | 本种子的演示值 |
|---|---|---|---|
| 普通 | `normal` | 50 万元以下 | 9001 → 100,000.00 |
| 金卡 | `gold` | 50 万—200 万元 | 9103 → 620,000.00;9104 → 560,000.00 |
| 白金 | `platinum` | 200 万—600 万元 | 9102 → 2,600,000.00;9105 → 2,400,000.00 |
| 钻石 | `diamond` | 600 万—1,000 万元 | 9101 → 8,600,000.00 |
**9001 为什么是「普通」而不是「金卡」**:它是演示主账号,客户看板的资金来自场内模拟账户
(10 万初始资金,见 `tools/seed_sim_account_demo.py`),`docs/44-演示流程.md` 场景 2 的预期
也写着「总资产约 10 万」。10 万落在 50 万以下 ⇒ 只能标「普通」。要把演示账号包装成金卡,就得把
模拟账户抬到 50 万以上,那会一并改掉交易演示的资金口径 —— 本种子**不这样做**。
注意:**分层不由资产推导**(那是新增业务规则),这里只是把演示数据摆到自洽的位置。
## `risk_tags` 必须从「自述事实」长出(2026-09-19 修正)
`risk_tags` 不是自由展示位,而是 `ProfileAssemblyService` 的**自述类字段**:记忆重建时
`values["risk_tags"] = tags` **整体重算**,没有自述事实就清空。所以种子里直接写死一串英文标签
= 「种了也会被下一次重建抹掉」(客户 9001 实测由 `["conservative"]` 变空)。本种子改为两步:
① 给每个客户写一条 `user_facts` 自述事实 `preference:risk_level`;
② 列里写**重建后会得到的那一个值**:`自述:preference:risk_level=<客户自述>`。
两边一致 ⇒ 重建前后读到的标签相同,且标签**始终可追溯到一条事实**。
> ⚠️ 这不会动摇红线:`investor_type`(风险等级)**只来自问卷**(`fin_risk_assessment`),
> 自述只产出一条带 `自述:` 前缀的标签,与问卷等级分列。
## 采用"底座版"画像字段(不是老师需求文档的四维加权版)
用户已裁定:画像字段与采集规则**按底座做**。老师版要求**新增 7 个字段**,
而项目铁律禁止改已有字段定义 —— 故只能走底座既有的 16 字段。
来源:`docs/superpowers/analysis/2026-09-10-范围重定位与画像讨论记录.md`。
## 两个已实测的技术坑(实现时必须遵守)
1. **`profile_snapshots.current_customer_id` 不是生成列**,而是带唯一键
`uk_profile_snapshot_current` 的普通列 —— `is_current=1` 时**必须显式写入**且等于 `customer_id`,
否则同一个 NULL 会在第二条记录上撞唯一键。(与 `agent_reply_template.active_key` 那个**真生成列**不同。)
2. `uk_profile_snapshot_version (customer_id, version)`:版本号按客户唯一;脚本先清掉本脚本所造
客户的快照,再由 `ProfileGenerationService` 生成,因此每客户恰好 `version=1`。
3. 快照由生产实现生成(`ProfileAssemblyService.rebuild()` → `build_snapshot()`),
形状与读取侧白名单**等集**,不再是"只含本服务独占字段"的残片。
4. `investment_horizon` / `preferred_asset_class` 的权威来源是 **`user_facts`**,不是
`fin_customer_profile` 的列(那两个列由重建服务独占,无事实即清空)—— 所以脚本写事实表。
## 幂等性
重复执行先删除**本脚本所造客户**(9101-9105 + 9001)在这些表里的数据再重建。
**不删 `interaction_audit`**(审计留痕不可删),也**不动其它客户**。
另需清 9001 的**漂移复核链**(`advisor_profile_drift_review` + 挂其上的
`advisor_profile_tag`):它是派生复核产物、外键指向被测测评行,不清就删不掉
`fin_risk_assessment`。**历史 superseded 标签保留**(不挂复核,不受 FK 约束)。
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
from sqlalchemy import bindparam, text as sql
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.infrastructure.db import SessionFactory # noqa: E402
from app.service.profile_generation_service import ( # noqa: E402
ProfileGenerationService,
)
CUSTOMER_ROLE_ID = 9001
#: 5 条虚拟画像。`valid_days` 为负表示测评**已过期**(用于验证失败关闭)。
PROFILES: tuple[dict[str, object], ...] = (
{
"user_id": 9101, "user_no": "T-PRF-01", "username": "profile_c5",
"real_name": "陈宏远", "birth_year": 1972, "occupation": "企业主",
"investor_type": "C5", "valid_days": 300,
"horizon": "long_term", "assets": ["equity_fund", "private_equity", "structured"],
"frequency": "high", "total_asset": "8600000.00", "behavior": 88,
"self_reported_risk": "进取型,能承受较大回撤",
"tier": "diamond", "score": 92,
},
{
"user_id": 9102, "user_no": "T-PRF-02", "username": "profile_c3",
"real_name": "李静怡", "birth_year": 1985, "occupation": "中学教师",
"investor_type": "C3", "valid_days": 200,
"horizon": "medium_term", "assets": ["bond_fund", "money_fund", "bank_wm"],
"frequency": "medium", "total_asset": "2600000.00", "behavior": 61,
"self_reported_risk": "稳健型,能接受中等波动",
"tier": "platinum", "score": 63,
},
{
"user_id": 9103, "user_no": "T-PRF-03", "username": "profile_c1",
"real_name": "王秀兰", "birth_year": 1958, "occupation": "退休职工",
"investor_type": "C1", "valid_days": 120,
"horizon": "short_term", "assets": ["money_fund", "bank_wm"],
"frequency": "low", "total_asset": "620000.00", "behavior": 33,
"self_reported_risk": "保守型,要求本金安全",
"tier": "gold", "score": 31,
},
{
"user_id": 9104, "user_no": "T-PRF-04", "username": "profile_c2",
"real_name": "张一鸣", "birth_year": 1996, "occupation": "互联网产品经理",
"investor_type": "C2", "valid_days": 180,
"horizon": "medium_term", "assets": ["money_fund", "index_fund"],
"frequency": "medium", "total_asset": "560000.00", "behavior": 47,
"self_reported_risk": "稳健偏保守,刚开始学投资",
"tier": "gold", "score": 42,
},
{
# 演示主账号(`cust_t`):测评**必须有效**,否则 HTTP 开户测评前置会 403 掉
# 客户的所有接口(见模块 docstring「为什么过期边界必须离开 9001」)。
"user_id": 9001, "user_no": None, "username": None,
"real_name": "测试客户", "birth_year": 1990, "occupation": "软件工程师",
"investor_type": "C1", "valid_days": 300,
"horizon": "short_term", "assets": ["money_fund"],
"frequency": "low", "total_asset": "100000.00", "behavior": 22,
"self_reported_risk": "保守型,主要买货币基金",
"tier": "normal", "score": 35,
},
{
# 关键边界:测评**已过期**。用于验证画像读取与适当性判定的失败关闭。
# 为什么不用 9001:它是演示账号,过期会让 HTTP 门禁把客户整条链路 403 掉。
"user_id": 9105, "user_no": "T-PRF-05", "username": "profile_expired",
"real_name": "林嘉宁", "birth_year": 1988, "occupation": "市场营销总监",
"investor_type": "C2", "valid_days": -45,
"horizon": "medium_term", "assets": ["bond_fund", "index_fund"],
"frequency": "medium", "total_asset": "2400000.00", "behavior": 45,
"self_reported_risk": "稳健型,偏好债券类产品",
"tier": "platinum", "score": 47,
},
)
def _risk_tags(spec: dict[str, object]) -> list[str]:
"""`risk_tags` 的正确形状:**与 `rebuild_profile()` 重算出的那一份逐字相同**。
`risk_tags` 是自述类字段(记忆重建时整体重算),所以这里不能随手写一串英文标签
—— 下一次重建就会把它抹掉。必须写 `自述:<事实键>=<事实值>`,且 `user_facts` 里
真有这条自述(`_seed_facts` 的 `preference:risk_level`)。
"""
return [f"自述:preference:risk_level={spec['self_reported_risk']}"]
#: 分层公开门槛(下界含、上界不含),与 `knowledge/product/高净值客户服务规范.md` §1.1 一致。
#: **只用于种子的自洽自检** —— 分层不由资产推导,这张表是"演示数据必须落在同一档位"的守卫。
TIER_BANDS: dict[str, tuple[int, int | None]] = {
"normal": (0, 500_000),
"gold": (500_000, 2_000_000),
"platinum": (2_000_000, 6_000_000),
"diamond": (6_000_000, 10_000_000),
"exclusive": (10_000_000, None),
}
PROFILE_TABLE = "fin_customer_profile"
ASSESSMENT_TABLE = "fin_risk_assessment"
SNAPSHOT_TABLE = "profile_snapshots"
#: 删除顺序的真实依赖链(2026-09-19 连库实测撞出两层 1451,务必按此序):
#: `advisor_profile_tag.drift_review_id` → `advisor_profile_drift_review.id`
#: → `fin_risk_assessment.id`
DRIFT_TABLE = "advisor_profile_drift_review"
TAG_TABLE = "advisor_profile_tag"
USER_FACT_TABLE = "user_facts"
async def _ensure_customer(session: object, spec: dict[str, object], now: datetime) -> None:
"""为新造客户建 `sys_user` 行与 customer 角色(9001 是既有账号,跳过)。"""
if spec["user_no"] is None:
return
exists = await session.scalar( # type: ignore[attr-defined]
sql("SELECT id FROM sys_user WHERE id = :i"), {"i": spec["user_id"]}
)
if exists:
return
await session.execute( # type: ignore[attr-defined]
sql("""
INSERT INTO sys_user (id, user_no, username, password_hash, user_type,
professional_investor_status, fund_account_status, status, created_at, updated_at)
VALUES (:id, :no, :name, :pwd, 'customer', 'none', 'opened', '正常', :now, :now)
"""),
{"id": spec["user_id"], "no": spec["user_no"], "name": spec["username"],
"pwd": hashlib.sha256(f"profile-{spec['user_id']}".encode()).hexdigest(), "now": now},
)
await session.execute( # type: ignore[attr-defined]
# 列名是 `assigned_at`(不是 `created_at`)——按现库 DDL 口径写入。
sql("INSERT INTO sys_user_role (user_id, role_id, assigned_at) VALUES (:u, :r, :now)"),
{"u": spec["user_id"], "r": CUSTOMER_ROLE_ID, "now": now},
)
async def _seed_facts(session: object, spec: dict[str, object], now: datetime) -> None:
"""把三条**长期事实**写进 `user_facts`:投资期限偏好 / 偏好资产类别 / 自述风险偏好。
为什么必须写事实表而不是直接写 `fin_customer_profile` 的列:
`ProfileAssemblyService.rebuild_profile()` 把这两个字段列为**本服务独占**,
"本轮没有对应事实即清空"(防止记忆失效后画像仍留着作废偏好)。所以直接写主表列,
下一次重建就会被清成 NULL —— 实测客户 9001 正是这样丢掉投资期限与偏好资产类别的。
事实表是这两个字段的**权威来源**,写在这里才持久。
`id` 必须显式给(该列无 `auto_increment`):沿用服务层"微秒时间戳"的口径,
再按序号错开,保证同一批内不撞主键。
"""
# ⚠️ `now` 在整轮种子里是**同一个**值,只用它算 base 会让每个客户拿到相同主键
# (实测撞 `Duplicate entry ... for key 'user_facts.PRIMARY'`,第二个客户就炸)。
# 按客户号再错开一档,保证跨客户唯一。
base = int(now.timestamp() * 1_000_000) + (int(spec["user_id"]) % 1000) * 10
rows = (
("preference:horizon", spec["horizon"]),
("preference:asset_class", spec["assets"]),
# 自述风险偏好:唯一目的是让 `risk_tags` 有**事实来源**(见模块 docstring)。
# 它**不会**改 `investor_type`(红线:等级只来自问卷),只产出一条
# `自述:preference:risk_level=...` 标签;无这条事实时重建会把 `risk_tags` 清空。
("preference:risk_level", spec["self_reported_risk"]),
)
for offset, (key, value) in enumerate(rows, start=1):
await session.execute( # type: ignore[attr-defined]
sql("""
INSERT INTO user_facts (id, customer_id, fact_key, fact_value, source_portal,
source_episode_id, confidence, is_critical, created_at)
VALUES (:id, :cid, :key, :val, 'seed', NULL, 0.95, 1, :now)
"""),
{"id": base + offset, "cid": int(spec["user_id"]), "key": key,
"val": json.dumps(value, ensure_ascii=False), "now": now},
)
async def _fetch_count(session: object, table: str, ids: list[int]) -> int:
"""按 customer_id 集合计数(`IN` 的绑定值必须是**元组**,asyncmy 不接受 list)。"""
return int(await session.scalar( # type: ignore[attr-defined]
sql(f"SELECT COUNT(*) FROM {table} WHERE customer_id IN :ids").bindparams(
bindparam("ids", value=tuple(ids), expanding=True)
)
))
async def seed() -> list[int]:
"""幂等写入 6 条画像数据,返回涉及的 customer_id 列表。"""
now = datetime.now(UTC).replace(tzinfo=None)
ids = [int(spec["user_id"]) for spec in PROFILES]
id_tuple = tuple(ids)
async with SessionFactory() as session:
# 先断「漂移复核链」:只有挂着复核的标签才受 FK 约束(历史 superseded 标签不受)。
await session.execute(sql(
f"DELETE FROM {TAG_TABLE} WHERE customer_id IN :ids"
" AND drift_review_id IS NOT NULL"
).bindparams(bindparam("ids", value=id_tuple, expanding=True)))
# 顺序不可调:DRIFT 引用 ASSESSMENT,必须先删(否则 1451)。
for table in (DRIFT_TABLE, SNAPSHOT_TABLE, ASSESSMENT_TABLE, PROFILE_TABLE,
USER_FACT_TABLE):
await session.execute(
sql(f"DELETE FROM {table} WHERE customer_id IN :ids").bindparams(
bindparam("ids", value=id_tuple, expanding=True)
)
)
for spec in PROFILES:
cid = int(spec["user_id"])
await _ensure_customer(session, spec, now)
# 客户分层写进**权威列** `sys_user.customer_tier`(不是写进快照)。
# 快照里的分层由 `ProfileGenerationService` 从这一列 JOIN 出来 —— 数据只有一份来源。
await session.execute(sql(
"UPDATE sys_user SET customer_tier = :tier, updated_at = :now WHERE id = :cid"
), {"tier": spec["tier"], "cid": cid, "now": now})
valid_until = now + timedelta(days=int(spec["valid_days"]))
await session.execute(sql(f"""
INSERT INTO {PROFILE_TABLE} (customer_id, trade_account, real_name, birth_date,
occupation, mobile_masked, investor_type, investment_horizon,
preferred_asset_class, trading_frequency, last_active_at, total_asset,
behavior_score, risk_tags, opened_at, updated_at)
VALUES (:cid, :acct, :name, :birth, :occ, :mobile, :itype, :horizon,
:assets, :freq, :last_active, :total, :behavior, :tags, :opened, :now)
"""), {
"cid": cid, "acct": f"TA{cid}0001", "name": spec["real_name"],
"birth": f"{spec['birth_year']}-06-15", "occ": spec["occupation"],
"mobile": f"138****{cid % 10000:04d}", "itype": spec["investor_type"],
"horizon": spec["horizon"], "assets": json.dumps(spec["assets"]),
"freq": spec["frequency"],
"last_active": now - timedelta(days=3), "total": spec["total_asset"],
"behavior": spec["behavior"],
"tags": json.dumps(_risk_tags(spec), ensure_ascii=False),
"opened": now - timedelta(days=800), "now": now,
})
# `fin_risk_assessment.id` **没有 AUTO_INCREMENT**(实测 AUTO_INCREMENT=None),
# 必须显式给值;用 99xxx 段,避开任何自增序列。
await session.execute(sql(f"""
INSERT INTO {ASSESSMENT_TABLE} (id, customer_id, questionnaire_version, answers,
total_score, investor_type, assessed_at, valid_until, created_at)
VALUES (:aid, :cid, 'v2026.1', :answers, :score, :itype, :assessed, :valid, :now)
"""), {
"aid": 99000 + cid % 1000, "cid": cid,
"answers": json.dumps({"q1": "A", "q2": "B", "q3": "C"}, ensure_ascii=False),
"score": spec["score"], "itype": spec["investor_type"],
"assessed": valid_until - timedelta(days=365), "valid": valid_until, "now": now,
})
# ② 长期事实(投资期限 / 偏好资产)—— 这两个字段的权威来源
await _seed_facts(session, spec, now)
# ③ 快照**不在这里手拼**,交给 `ProfileGenerationService`(唯一的画像投影实现)。
# 手拼的后果实测过:种子里明明写了 `customer_tier: gold`,生产路径下一次重建就用
# 残缺形状把它覆盖掉(客户 9001 的当前快照被压到只剩 `investor_type`)。
# 种子的职责是造**权威事实**(`fin_customer_profile` / `fin_risk_assessment` /
# `user_facts` / `sys_user.customer_tier`);快照是**投影**,投影必须由投影器产出。
#
# 这里用 `generate()`(按权威表投影)而不是 `ProfileAssemblyService.rebuild()`:
# `rebuild()` 会把"没有长期事实支撑"的画像字段**清空**(含本脚本写的 `risk_tags`),
# 那是记忆变更后的收敛行为,不该在种子里发生。事实已在上一步写进 `user_facts`,
# 之后真发生记忆重建时这两个字段能自动收敛回来。
await ProfileGenerationService(session).generate(cid)
await session.commit()
return ids
async def verify(ids: list[int]) -> None:
"""自校验:三张表各有 6 条、每客户恰好一条 is_current=1、过期边界确实存在。"""
id_tuple = tuple(ids)
async with SessionFactory() as session:
for table in (PROFILE_TABLE, ASSESSMENT_TABLE, SNAPSHOT_TABLE):
n = await _fetch_count(session, table, ids)
print(f" {table:<24} {n} 行(期望 6)")
dup = (await session.execute(sql(f"""
SELECT customer_id, COUNT(*) AS n FROM {SNAPSHOT_TABLE}
WHERE customer_id IN :ids AND is_current = 1
GROUP BY customer_id HAVING n <> 1
""").bindparams(bindparam("ids", value=id_tuple, expanding=True)))).mappings().all()
print(f" is_current=1 非唯一的客户: {[dict(d) for d in dup] or '无'}")
expired = (await session.execute(sql(f"""
SELECT customer_id FROM {ASSESSMENT_TABLE}
WHERE customer_id IN :ids AND valid_until <= NOW()
""").bindparams(bindparam("ids", value=id_tuple, expanding=True)))).scalars().all()
print(f" 测评已过期的客户: {sorted(expired)}(期望 [9105])")
levels = (await session.execute(sql(f"""
SELECT investor_type, COUNT(*) AS n FROM {PROFILE_TABLE}
WHERE customer_id IN :ids GROUP BY investor_type ORDER BY investor_type
""").bindparams(bindparam("ids", value=id_tuple, expanding=True)))).mappings().all()
print(f" 风险等级分布: {[(r['investor_type'], r['n']) for r in levels]}")
# 分层自洽:`customer_tier` 必须与 `total_asset` 落在同一档位区间。
# 不这样做的话,演示会被一句「才 10 万怎么会是金卡」问穿(2026-09-19 实测 5/6 行不一致)。
band_rows = (await session.execute(sql(f"""
SELECT p.customer_id, p.total_asset, u.customer_tier
FROM {PROFILE_TABLE} p LEFT JOIN sys_user u ON u.id = p.customer_id
WHERE p.customer_id IN :ids ORDER BY p.customer_id
""").bindparams(bindparam("ids", value=id_tuple, expanding=True)))).mappings().all()
bad: list[str] = []
for row in band_rows:
tier = str(row["customer_tier"] or "")
lo, hi = TIER_BANDS.get(tier, (None, None))
amount = row["total_asset"]
ok = (
lo is not None and amount is not None
and int(amount) >= lo and (hi is None or int(amount) < hi)
)
print(f" 分层自洽检查 {row['customer_id']}: tier={tier} total_asset={amount} -> {'OK' if ok else '不一致'}")
if not ok:
bad.append(f"{row['customer_id']}(tier={tier}, total_asset={amount})")
assert not bad, (
"分层与总资产不在同一档位区间(演示会被追问):" + "、".join(bad)
+ f";公开门槛见 TIER_BANDS={TIER_BANDS}"
)
async def main() -> None:
ids = await seed()
print(f"已写入 {len(ids)} 条画像数据: {ids}")
await verify(ids)
if __name__ == "__main__":
asyncio.run(main())