"""画像模块演示/测试数据种子(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())