一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
(输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)
二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数
三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"
四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
(端点与账号均已不存在,此前稳定报 3 条假红)
五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期
六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
_chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
415 lines
23 KiB
Python
415 lines
23 KiB
Python
"""画像模块演示/测试数据种子(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())
|