"""管理 API 六只读端点判别测试(批次B Task B4)。 覆盖: - 参数校验:422(缺 start / 跨度>366 / 日历非法 / drilldown 白名单外 / page_size>100)、404(employees/{ghost}、drilldown 员工不存在); - dashboard 各段齐全与关键数值(kpis/communication_trend/service_trend/ lifecycle/dimension_summary/anomalies/action_summary/data_as_of/ metric_version); - employees:默认分页 20/total/critical 优先默认排序/channel=wechat 时 whatsapp-only 员工消失/keyword/status/min_days 不足→insufficient_sample; - detail:账号清单+metrics 全字段+score 完整明细+anomalies+prev/baseline; - sections:五段各有 trend 数组与四对比; - drilldown:p90 分页与 fr_ms desc/unreplied 按 owner 过滤/cooling-dormant 生命周期判定(已删客户剔除)/call_followup 列表+期聚合摘要; - metric-definitions:trial 标记/口径说明/config_source 回退。 """ import datetime as dt from zoneinfo import ZoneInfo from app.db.models import ( Customer, EmployeeCallDaily, EmployeeMetricDaily, FirstReplyFact, Message, MsgDailyStats, PhoneCall, Staff, WechatAccount, WhatsappAccount, ) _SH = ZoneInfo("Asia/Shanghai") START, END = 20260901, 20260905 # 当前窗 5 天(min_days=5 恰好够) def _ts(*args) -> int: return int(dt.datetime(*args, tzinfo=_SH).timestamp() * 1000) def _mday(day, *, channel="wechat", user_id="u1", metric_version="v1", **kw): cols = { "msg_sent": 0, "msg_recv": 0, "active_friends": 0, "active_rooms": 0, "cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0, "new_customer_count": 0, "cooling_customer_count": 0, "dormant_customer_count": 0, "reactivated_customer_count": 0, "data_complete": True, } cols.update(kw) return EmployeeMetricDaily( day=day, channel=channel, user_id=user_id, metric_version=metric_version, **cols ) def _fact(fr_ms, *, channel="wechat", owner="wx1", peer="p1", day=20260901, i=0): end = _ts(day // 10000, day // 100 % 100, day % 100, 10, 0) + i * 60_000 return FirstReplyFact( channel=channel, owner_account_id=owner, peer_id=peer, thread_end_ts=end, reply_ts=end + fr_ms, reply_msg_id=f"{owner}-{peer}-m{i}", day=day, fr_ms=fr_ms, ) async def _seed_common(s): """三员工全量种子:u1(微信,2 warning)/u2(双渠道,1 critical+1 warning)/ u3(仅 WhatsApp,无异常)——支撑 dashboard/employees/detail/sections/ drilldown 全部判据。""" for uid, name, onjob in (("u1", "张三", True), ("u2", "李四", True), ("u3", "王五", False)): s.add(Staff(user_id=uid, name=name, is_on_job=onjob)) s.add(WechatAccount(wx_id="wx1", user_id="u1", nick_name="张三的微信", user_name="张三")) s.add(WechatAccount(wx_id="wx2", user_id="u2")) s.add(WhatsappAccount(uid="wa2", user_id="u2")) s.add(WhatsappAccount(uid="wa3", user_id="u3")) # ---- 当前窗宽表(0901-0905)---- for d in range(1, 6): s.add(_mday(20260900 + d, user_id="u1", msg_sent=1, cust_threads=2, threads_answered=2, fr_sum_ms=240_000, active_friends=3, active_rooms=1, cooling_customer_count=2, dormant_customer_count=1, first_reply_p50_ms=110_000, first_reply_p90_ms=220_000)) s.add(_mday(20260900 + d, user_id="u2", msg_sent=10, msg_recv=10, cust_threads=2, threads_answered=2, fr_sum_ms=1_200_000, active_friends=4, cooling_customer_count=1, reactivated_customer_count=1)) s.add(_mday(20260900 + d, channel="whatsapp", user_id="u3", msg_sent=5, msg_recv=5, cust_threads=1, active_friends=1)) # ---- 基线窗宽表(0827-0831,u1 高日均触发 activity_drop)---- for d in range(27, 32): s.add(_mday(20260800 + d, user_id="u1", msg_sent=14, msg_recv=14)) s.add(_mday(20260800 + d, user_id="u2", msg_sent=5, msg_recv=5)) # ---- fact:当前窗(u1 120s warning / u2 600s critical)+ 基线 60s ---- s.add(_fact(120_000, peer="pA", day=20260901, i=0)) s.add(_fact(120_000, peer="pB", day=20260902, i=1)) s.add(_fact(600_000, owner="wx2", peer="pC", day=20260901, i=2)) s.add(_fact(600_000, owner="wx2", peer="pD", day=20260902, i=3)) for owner, peer, day, i in (("wx1", "pA", 20260827, 10), ("wx1", "pB", 20260828, 11), ("wx2", "pC", 20260827, 12), ("wx2", "pD", 20260828, 13)): s.add(_fact(60_000, owner=owner, peer=peer, day=day, i=i)) # ---- msg_daily_stats:活跃客户 distinct + data_as_of + 未回候选 ---- s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="friend", peer_id="p1", day=20260901, send_cnt=0, recv_cnt=1, last_ts=_ts(2026, 9, 1, 10, 0), last_is_send=False)) s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="friend", peer_id="p2", day=20260902, send_cnt=1, recv_cnt=0, last_ts=_ts(2026, 9, 2, 10, 0), last_is_send=True)) s.add(MsgDailyStats(channel="wechat", owner_account_id="wx2", peer_kind="friend", peer_id="p3", day=20260901, send_cnt=2, recv_cnt=0, last_ts=_ts(2026, 9, 1, 11, 0), last_is_send=True)) s.add(MsgDailyStats(channel="whatsapp", owner_account_id="wa3", peer_kind="friend", peer_id="p4", day=20260903, send_cnt=1, recv_cnt=0, last_ts=_ts(2026, 9, 3, 9, 0), last_is_send=True)) # drilldown cooling/dormant 客户互动史(全历史,窗外): # f1 末次 0801(gap 35, hist 12)→ dormant;f2 末次 0825(gap 11)→ cooling for day, n in ((20260701, 6), (20260801, 6)): s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="friend", peer_id="f1", day=day, send_cnt=n, recv_cnt=0, last_ts=_ts(day // 10000, day // 100 % 100, day % 100, 10, 0), last_is_send=True)) for day in (20260801, 20260825): s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="friend", peer_id="f2", day=day, send_cnt=2, recv_cnt=2, last_ts=_ts(2026, 8, day % 100, 11, 0), last_is_send=True)) # ---- 客户档案(f9 已删,须被 drilldown 剔除)---- s.add(Customer(friend_id="f1", channel="wechat", owner_account_id="wx1", friend_nickname="客户一", user_id="u1", delete_flag=0)) s.add(Customer(friend_id="f2", channel="wechat", owner_account_id="wx1", friend_nickname="客户二", user_id="u1", delete_flag=0)) s.add(Customer(friend_id="f9", channel="wechat", owner_account_id="wx1", friend_nickname="已删客户", user_id="u1", delete_flag=1)) # ---- 未回候选 p1 的尾部消息(unreplied 决赛圈点查)---- s.add(Message(msg_id="m-p1", channel="wechat", owner_account_id="wx1", talker_id="p1", is_send=False, type="text", text="在吗", timestamp=_ts(2026, 9, 1, 10, 0))) # ---- 通话 ---- s.add(EmployeeCallDaily(day=20260901, user_id="u1", call_count=2, call_seconds=100, answered_call_count=1, call_followup_count=1)) s.add(EmployeeCallDaily(day=20260902, user_id="u2", call_count=1, call_seconds=60, answered_call_count=1, call_followup_count=0)) s.add(PhoneCall(call_id="c1", channel="voice_call", user_id="u1", start_call_time=_ts(2026, 9, 1, 15, 0), call_duration=120, call_number="13800000001")) s.add(PhoneCall(call_id="c2", channel="voice_call", user_id="u1", start_call_time=_ts(2026, 9, 2, 15, 0), call_duration=0, call_number="13800000002")) await s.commit() # ---------------------------------------------------------------- 参数与错误 async def test_dashboard_missing_start_422(make_client): async def seed(s): await _seed_common(s) client = await make_client(seed) r = await client.get("/api/management/dashboard", params={"end": END}) assert r.status_code == 422 async def test_dashboard_span_and_calendar_422(make_client): async def seed(s): await _seed_common(s) client = await make_client(seed) r = await client.get("/api/management/dashboard", params={"start": 20250101, "end": 20261231}) assert r.status_code == 422 r = await client.get("/api/management/dashboard", params={"start": 20260230, "end": 20260905}) assert r.status_code == 422 # 日历非法(2 月 30 日) async def test_drilldown_unknown_metric_422(make_client): async def seed(s): await _seed_common(s) client = await make_client(seed) r = await client.get("/api/management/employees/u1/drilldown", params={"metric_code": "bogus", "start": START, "end": END}) assert r.status_code == 422 assert "unknown metric_code" in r.json()["detail"] async def test_employees_page_size_over_100_422(make_client): async def seed(s): await _seed_common(s) client = await make_client(seed) r = await client.get("/api/management/employees", params={"start": START, "end": END, "page_size": 101}) assert r.status_code == 422 async def test_employee_detail_404(make_client): async def seed(s): await s.commit() client = await make_client(seed) r = await client.get("/api/management/employees/ghost", params={"start": START, "end": END}) assert r.status_code == 404 async def test_drilldown_employee_404(make_client): async def seed(s): await s.commit() client = await make_client(seed) r = await client.get("/api/management/employees/ghost/drilldown", params={"metric_code": "unreplied", "start": START, "end": END}) assert r.status_code == 404 # ---------------------------------------------------------------- dashboard async def test_dashboard_structure_and_kpis(make_client): async def seed(s): await _seed_common(s) client = await make_client(seed) r = await client.get("/api/management/dashboard", params={"channel": "all", "start": START, "end": END}) assert r.status_code == 200 body = r.json() for key in ("scope", "data_as_of", "metric_version", "kpis", "communication_trend", "service_trend", "lifecycle", "dimension_summary", "anomalies", "action_summary", "config_source", "caliber_notes"): assert key in body, key assert body["metric_version"] == "v1" assert body["config_source"] == "default" assert body["scope"] == {"channel": "all", "start": START, "end": END, "compare": "previous"} assert body["data_as_of"] == _ts(2026, 9, 3, 9, 0) k = body["kpis"] # 活跃客户=PM distinct 求和(u1:2 + u2:1 + u3:1) assert k["active_customers"] == 4 assert k["active_employees"] == 3 assert k["msg_total"] == 155 # u1 5 + u2 100 + u3 50 assert k["cust_threads"] == 25 # 10 + 10 + 5 assert k["threads_answered"] == 20 # 10 + 10 + 0 assert k["call_count"] == 3 assert k["call_seconds"] == 160 assert k["answered_call"] == 2 # 期末存量:u1 cooling2/dormant1 + u2 cooling1;reactivated 期内求和 u2 5 assert body["lifecycle"] == {"new": 0, "cooling": 3, "dormant": 1, "reactivated": 5} assert k["lifecycle"] == body["lifecycle"] # 趋势逐日 5 点;0901 = u1(1)+u2(20)+u3(10) 消息,宽表 active 合计 8,通话 2 trend = body["communication_trend"] assert [t["day"] for t in trend] == [20260901 + i for i in range(5)] assert trend[0]["msg_total"] == 31 assert trend[0]["active_customers"] == 8 assert trend[0]["call_cnt"] == 2 assert trend[1]["call_cnt"] == 1 assert trend[4] == {"day": 20260905, "msg_total": 31, "active_customers": 8, "call_cnt": 0} # 服务趋势:0901 fact [120k, 600k] → p50=120k/p90=600k; # reply_2h_rate = 2/5(两串均 ≤2h——600s=10min / 宽表当日串 5) st = {t["day"]: t for t in body["service_trend"]} assert st[20260901]["fr_p50_ms"] == 120_000 assert st[20260901]["fr_p90_ms"] == 600_000 assert st[20260901]["reply_2h_rate"] == 0.4 assert st[20260903]["fr_p50_ms"] is None # 无 fact 日 assert st[20260903]["reply_2h_rate"] == 0.0 # 分母有串、分子 0 # 维度摘要:5 维全员计数,总数=员工数 3 dims = body["dimension_summary"] assert set(dims) == {"customer", "service", "action", "workload", "risk"} for d in dims.values(): assert d["scored_count"] + d["insufficient_count"] + d["pending_count"] == 3 assert "avg_score" in d # 异常:critical 在前(u2),warning 其后按 affected_count desc anoms = body["anomalies"] assert anoms[0]["severity"] == "critical" assert anoms[0]["employee_user_id"] == "u2" assert all(a["severity"] == "critical" for a in anoms[:1]) assert all(a["severity"] == "warning" for a in anoms[1:]) assert body["action_summary"] == {"unreplied_2h": 1, "churn_real_30d": 0, "sensitive_pending": 0} # ---------------------------------------------------------------- employees async def test_employees_default_sort_and_paging(make_client): async def seed(s): await _seed_common(s) client = await make_client(seed) r = await client.get("/api/management/employees", params={"channel": "all", "start": START, "end": END}) assert r.status_code == 200 body = r.json() assert body["total"] == 3 assert body["page"] == 1 assert body["page_size"] == 20 assert body["metric_version"] == "v1" assert body["config_source"] == "default" assert len(body["rows"]) == 3 # 默认 anomaly_severity 排序:含 critical 的 u2 在最前 # (u2=1 critical——基线无串样本率不可算,rate 规则不触发;u1=2 warning) ids = [row["user_id"] for row in body["rows"]] assert ids == ["u2", "u1", "u3"] u2, u1, u3 = body["rows"] assert u2["anomaly_count"] == 1 and u2["anomaly_weight"] == 2 assert u1["anomaly_count"] == 2 and u1["anomaly_weight"] == 2 assert u3["anomaly_count"] == 0 and u3["anomaly_weight"] == 0 # 行结构:metrics 全字段 / comparisons 四值 / score 摘要 assert u1["name"] == "张三" and u1["is_on_job"] is True assert u1["metrics"]["cust_threads"] == 10 assert u1["metrics"]["end_active_friends"] == 3 assert set(u1["comparisons"]) >= { "active_share", "reply_2h_rate", "reply_24h_rate", "fr_p90_ms", "active_friends_band", "call_band"} for c in u1["comparisons"].values(): assert set(c) == {"current", "prev", "baseline", "median"} assert u1["comparisons"]["fr_p90_ms"]["current"] == 120_000 assert u1["comparisons"]["fr_p90_ms"]["baseline"] == 60_000 assert set(u1["score"]["dimensions"]) == { "customer", "service", "action", "workload", "risk"} for d in u1["score"]["dimensions"].values(): assert set(d) == {"score", "status"} assert u1["score"]["trial"] is True assert u1["score"]["score_status"] == "scored" # 5 天数据 ≥ min_days async def test_employees_channel_filter(make_client): async def seed(s): await _seed_common(s) client = await make_client(seed) r = await client.get("/api/management/employees", params={"channel": "wechat", "start": START, "end": END}) ids = [row["user_id"] for row in r.json()["rows"]] assert ids == ["u2", "u1"] # u3 仅 WhatsApp,wechat 视图消失 r = await client.get("/api/management/employees", params={"channel": "whatsapp", "start": START, "end": END}) ids = [row["user_id"] for row in r.json()["rows"]] assert ids == ["u2", "u3"] # u1 无 WA 账号 async def test_employees_keyword(make_client): async def seed(s): await _seed_common(s) client = await make_client(seed) r = await client.get("/api/management/employees", params={"start": START, "end": END, "keyword": "张"}) assert [row["user_id"] for row in r.json()["rows"]] == ["u1"] r = await client.get("/api/management/employees", params={"start": START, "end": END, "keyword": "U3"}) assert [row["user_id"] for row in r.json()["rows"]] == ["u3"] async def test_employees_status_filter(make_client): async def seed(s): await _seed_common(s) client = await make_client(seed) r = await client.get("/api/management/employees", params={"start": START, "end": END, "status": "on_job"}) assert [row["user_id"] for row in r.json()["rows"]] == ["u2", "u1"] r = await client.get("/api/management/employees", params={"start": START, "end": END, "status": "offline"}) assert [row["user_id"] for row in r.json()["rows"]] == ["u3"] async def test_employees_min_days_insufficient(make_client): """B2 评审遗留接线:days_with_data < min_days → score_status 覆盖 insufficient_sample,但可评明细(total_score)保留展示。""" async def seed(s): s.add(Staff(user_id="u9", name="赵九", is_on_job=True)) s.add(WechatAccount(wx_id="wx9", user_id="u9")) # 仅 2 天宽表数据(< min_days=5),但串样本 30 ≥ min_threads 可评分 for d in (1, 2): s.add(_mday(20260900 + d, user_id="u9", cust_threads=15, threads_answered=15, fr_sum_ms=900_000, active_friends=10)) s.add(_fact(60_000, owner="wx9", peer="q1", day=20260901, i=0)) s.add(_fact(60_000, owner="wx9", peer="q2", day=20260902, i=1)) for i in range(6): # 6 个 distinct peer → workload band 可评 s.add(MsgDailyStats(channel="wechat", owner_account_id="wx9", peer_kind="friend", peer_id=f"q{i}", day=20260901, send_cnt=1, recv_cnt=0, last_ts=_ts(2026, 9, 1, 12, 0), last_is_send=True)) await s.commit() client = await make_client(seed) r = await client.get("/api/management/employees", params={"start": START, "end": END}) assert r.status_code == 200 row = r.json()["rows"][0] assert row["user_id"] == "u9" assert row["metrics"]["days_with_data"] == 2 assert row["score"]["score_status"] == "insufficient_sample" assert row["score"]["total_score"] is not None # 明细保留展示 # ---------------------------------------------------------------- detail async def test_employee_detail_full(make_client): async def seed(s): await _seed_common(s) client = await make_client(seed) r = await client.get("/api/management/employees/u1", params={"channel": "all", "start": START, "end": END}) assert r.status_code == 200 body = r.json() assert body["user_id"] == "u1" assert body["name"] == "张三" assert body["accounts"] == [ {"channel": "wechat", "account_id": "wx1", "nick_name": "张三的微信", "user_name": "张三"}] # metrics 全字段(22 列 namedtuple) assert len(body["metrics"]) == 22 assert body["metrics"]["cust_threads"] == 10 # score 完整明细:dimensions 含逐指标 metrics;metrics_detail 扁平 assert body["score"]["dimensions"]["service"]["metrics"]["fr_p90_ms"][ "score"] == 100.0 assert body["score"]["metrics_detail"]["fr_p90_ms"]["value"] == 120_000 assert body["score"]["metrics_detail"]["reply_2h_rate"]["value"] == 0.2 assert body["score"]["metrics_detail"]["fr_p90_ms"]["baseline"] == 60_000 # anomalies + prev/baseline 原始值(prev 窗 0827-0831 与基线窗重叠: # 5 天 × msg_sent 14 = 70) assert len(body["anomalies"]) == 2 assert all(a["severity"] == "warning" for a in body["anomalies"]) assert body["prev"]["msg_sent"] == 70 assert body["baseline"]["msg_sent"] == 70 assert body["data_as_of"] == _ts(2026, 9, 3, 9, 0) # ---------------------------------------------------------------- sections async def test_employee_sections(make_client): async def seed(s): await _seed_common(s) client = await make_client(seed) base = "/api/management/employees/u1/metrics" for section in ("overview", "service", "customer", "workload", "calls"): r = await client.get(base, params={ "section": section, "channel": "wechat", "start": START, "end": END}) assert r.status_code == 200, section body = r.json() assert body["section"] == section assert len(body["trend"]) == 5 # 全窗口逐日(含零填充) assert all("day" in t for t in body["trend"]) assert body["comparisons"]["reply_2h_rate"]["current"] == 0.2 assert set(body["comparisons"]["reply_2h_rate"]) == { "current", "prev", "baseline", "median"} r = await client.get(base, params={"section": "service", "channel": "wechat", "start": START, "end": END}) body = r.json() assert body["metrics"]["cust_threads"] == 10 assert body["metrics"]["reply_2h_count"] == 2 t0 = body["trend"][0] assert t0 == {"day": 20260901, "cust_threads": 2, "threads_answered": 2, "fr_sum_ms": 240_000, "first_reply_p50_ms": 110_000, "first_reply_p90_ms": 220_000} r = await client.get(base, params={"section": "calls", "channel": "all", "start": START, "end": END}) calls = {t["day"]: t for t in r.json()["trend"]} assert calls[20260901]["call_count"] == 2 assert calls[20260901]["call_followup_count"] == 1 assert calls[20260905]["call_count"] == 0 # 零填充 assert r.json()["metrics"]["call_count"] == 2 # ---------------------------------------------------------------- drilldown async def test_drilldown_fr_p90(make_client): async def seed(s): await _seed_common(s) client = await make_client(seed) url = "/api/management/employees/u2/drilldown" r = await client.get(url, params={ "metric_code": "first_reply_p90", "channel": "all", "start": START, "end": END, "page": 1, "page_size": 1}) assert r.status_code == 200 body = r.json() assert body["total"] == 2 assert len(body["items"]) == 1 item = body["items"][0] # fr_ms desc + 同值按日 desc 决胜:0902 的 pD 在前 assert item["fr_ms"] == 600_000 assert item["peer_id"] == "pD" assert item["owner_account_id"] == "wx2" assert item["chat_hint"] == {"owner_account_id": "wx2", "talker_id": "pD"} r = await client.get(url, params={ "metric_code": "first_reply_p90", "channel": "all", "start": START, "end": END, "page": 2, "page_size": 1}) assert r.json()["items"][0]["peer_id"] == "pC" # u2 的 WA 账号无 fact 行 r = await client.get(url, params={ "metric_code": "first_reply_p50", "channel": "whatsapp", "start": START, "end": END}) assert r.json()["total"] == 0 async def test_drilldown_unreplied(make_client): async def seed(s): await _seed_common(s) client = await make_client(seed) r = await client.get("/api/management/employees/u1/drilldown", params={"metric_code": "unreplied", "channel": "wechat", "start": START, "end": END}) assert r.status_code == 200 body = r.json() assert body["total"] == 1 # p1(wx1)进入,u1 过滤后 assert body["items"][0]["peer_id"] == "p1" assert body["items"][0]["owner_account_id"] == "wx1" # u2 名下无未回候选(p3 尾部为员工消息) r = await client.get("/api/management/employees/u2/drilldown", params={"metric_code": "unreplied", "channel": "wechat", "start": START, "end": END}) assert r.json()["total"] == 0 async def test_drilldown_lifecycle_customers(make_client): async def seed(s): await _seed_common(s) client = await make_client(seed) r = await client.get("/api/management/employees/u1/drilldown", params={"metric_code": "cooling_customers", "channel": "all", "start": START, "end": END}) assert r.status_code == 200 body = r.json() assert body["total"] == 1 # f2 降温;f1 沉睡、f9 已删 assert body["items"][0] == {"friend_id": "f2", "nickname": "客户二", "last_day": 20260825, "state": "cooling"} r = await client.get("/api/management/employees/u1/drilldown", params={"metric_code": "dormant_customers", "channel": "all", "start": START, "end": END}) body = r.json() assert body["total"] == 1 assert body["items"][0]["friend_id"] == "f1" assert body["items"][0]["state"] == "dormant" async def test_drilldown_call_followup(make_client): async def seed(s): await _seed_common(s) client = await make_client(seed) r = await client.get("/api/management/employees/u1/drilldown", params={"metric_code": "call_followup", "channel": "all", "start": START, "end": END}) assert r.status_code == 200 body = r.json() assert body["total"] == 2 # start_call_time desc:c2(0902)在前 assert [i["call_id"] for i in body["items"]] == ["c2", "c1"] assert set(body["items"][0]) == { "call_id", "channel", "call_number", "start_call_time", "call_duration"} assert body["summary"] == {"call_count": 2, "call_seconds": 100, "answered_call_count": 1, "call_followup_count": 1} # ------------------------------------------------------- metric-definitions async def test_metric_definitions(make_client): async def seed(s): await _seed_common(s) client = await make_client(seed) r = await client.get("/api/management/metric-definitions") assert r.status_code == 200 body = r.json() assert body["definitions"]["cust_threads"]["unit"] == "串" assert body["score_config"]["trial"] is True assert body["score_config"]["dimensions"]["risk"]["status"] == "pending_data" assert body["config_source"] == "default" # 未种子配置→回退默认 assert "1:1" in body["caliber_notes"]