Files
group_fqcd_jr/tools/seed_profile_demo.py
张胜宇 5d0becb67d 客服 Agent 重构收口:五出口决策链 + 知识库档位隔离 + 前端入参边界(答辩演示版本)
一、客服 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 生成的本地产物
2026-09-20 14:33:30 +08:00

415 lines
23 KiB
Python
Raw Permalink 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.
"""画像模块演示/测试数据种子(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())