diff --git a/.harness/project/replay_cases.py b/.harness/project/replay_cases.py index 182762f..b46a77c 100644 --- a/.harness/project/replay_cases.py +++ b/.harness/project/replay_cases.py @@ -27,16 +27,16 @@ "V080-R2", "只在现有报告中的任务不再恢复", "scripts/daily_report.py", - " for key, old in base_map.items():\n if key in rescan_map:", - " for key, old in []:\n if key in rescan_map:", + " for key, old in base_map.items():\n if key in rescan_map or key in excluded_sessions:", + " for key, old in []:\n if key in rescan_map or key in excluded_sessions:", ("test_properties.MergeNoDowngradeProperties", "test_daily_report"), ), Case( "V080-R3", "判定为 agent 的会话被恢复", "scripts/daily_report.py", - " if key in excluded_sessions:\n continue # 判定为 agent:正当排除,不恢复。", - " pass", + " if key in rescan_map or key in excluded_sessions:\n continue # 判定为 agent:正当排除,不恢复。", + " if False:\n continue # 判定为 agent:正当排除,不恢复。", ("test_properties.MergeNoDowngradeProperties", "test_daily_report"), ), Case( @@ -139,9 +139,15 @@ "H0925-4", "合并不降级时三类合计沿用现有报告,某类上升时与 model 明细不一致", "scripts/daily_report.py", - ' merged_record["input_tokens"] = int(record.get("input_tokens") or 0) + diff["fresh_input"]\n' - ' merged_record["cache_tokens"] = int(record.get("cache_tokens") or 0) + diff["cache_read"]\n' - ' merged_record["output_tokens"] = int(record.get("output_tokens") or 0) + diff["output"]\n' + ' merged_record["input_tokens"] = (\n' + ' int(record.get("input_tokens") or 0) + diff["fresh_input"]\n' + ' )\n' + ' merged_record["cache_tokens"] = (\n' + ' int(record.get("cache_tokens") or 0) + diff["cache_read"]\n' + ' )\n' + ' merged_record["output_tokens"] = (\n' + ' int(record.get("output_tokens") or 0) + diff["output"]\n' + ' )\n' ' merged_record["total_tokens"] = _task_total(merged_record)', ' merged_record["input_tokens"] = int(old.get("input_tokens") or 0)\n' ' merged_record["cache_tokens"] = int(old.get("cache_tokens") or 0)\n' @@ -354,6 +360,14 @@ " _ = url", ("swift-policy",), ), + Case( + "H1016-1", + "claude 转写同 message.id 重复行恢复逐行累加(转写重复计数)", + "scripts/daily_report.py", + " if message_id in seen_messages:\n continue", + " if False:\n continue", + ("test_daily_report",), + ), ] # 过程类失败:由守卫测试覆盖(测试必须存在,回放时逐个运行并要求通过)。 diff --git a/docs/specs/daily-report.md b/docs/specs/daily-report.md index 1874d78..085d259 100644 --- a/docs/specs/daily-report.md +++ b/docs/specs/daily-report.md @@ -1,6 +1,6 @@ -# 工作日报 v3(token 三类口径,报告 schema v8) +# 工作日报 v3(token 三类口径,报告 schema v9) -状态:**报告 schema 已升到 v8**(2026-09-23,逐 model 用量,见[用量金额规范](usage-cost.md)):六来源产出 model_raw 与四项原始 token,`tasks[].models` 与 `totals.models` 入报告;金额不写入报告,由 `inbox usage` / 日报界面在读取时按价格表派生。v7 → v8 迁移自动执行(先备份 `reports.v7.bak/`,来源被清理时用 v7 内容做 unknown 拆分,不降级);真实数据三日比对逐位一致(Zcode 在 1.71% 容差内),证据 scratch/2026-09-23-v8-parity.md。此前 v3(schema v7)通过 102 项 Python 检查(含 15 项日报)与 Swift 策略检查;真实本机数据 182 天补录、总览/详情截图核对完成。OpenCode 来源于 2026-09-16 接入(schema v5→v6);2026-09-17 agent 会话过滤 + OpenCode 受管理口径(v6→v7,旧固化报告自动重算,现役 schema v7)。**准确性核对**:独立重算脚本(scratch/check_token_accuracy.py)与报告逐项对比,Codex/Claude/Pi/Kimi 三类逐位一致,Zcode 在跨零点分摊容差内(<2.2%,归日语义差非算术差;证据 scratch/2026-09-15-token-accuracy-check.md);OpenCode 近 3 天独立重算与报告逐位一致(141,744)。 +状态:**报告 schema 已升到 v9**(2026-10-16,claude 口径修正 H1016-1,见下);v8(2026-09-23,逐 model 用量,见[用量金额规范](usage-cost.md)):六来源产出 model_raw 与四项原始 token,`tasks[].models` 与 `totals.models` 入报告;金额不写入报告,由 `inbox usage` / 日报界面在读取时按价格表派生。v7 → v8 的迁移合并已随 v9 退役(版本升级即口径重建,见[用量金额规范](usage-cost.md) §3.1.4);真实数据三日比对逐位一致(Zcode 在 1.71% 容差内),证据 scratch/2026-09-23-v8-parity.md。**v9 修正 claude 转写重复计数**(H1016-1):Claude Code 对同一次 API 响应按 content block 写多行 assistant 记录(同 `message.id`/`requestId`,usage 相同),此前逐行累加把 claude 来源虚高 44%–84%(近 7 天实测);现按 `message.id` 只计一次,`REPORT_VERSION` 升 9 全量重算,且版本升级即口径重建——旧版本报告不作为定稿合并基线(H1016-1,见[用量金额规范](usage-cost.md) §3.1.4)。此前 v3(schema v7)通过 102 项 Python 检查(含 15 项日报)与 Swift 策略检查;真实本机数据 182 天补录、总览/详情截图核对完成。OpenCode 来源于 2026-09-16 接入(schema v5→v6);2026-09-17 agent 会话过滤 + OpenCode 受管理口径(v6→v7,旧固化报告自动重算,现役 schema v7)。**准确性核对**:独立重算脚本(scratch/check_token_accuracy.py)与报告逐项对比,Codex/Claude/Pi/Kimi 三类逐位一致,Zcode 在跨零点分摊容差内(<2.2%,归日语义差非算术差;证据 scratch/2026-09-15-token-accuracy-check.md);OpenCode 近 3 天独立重算与报告逐位一致(141,744)。 ## 度量口径(v3 核心) @@ -16,7 +16,7 @@ |---|---|---|---|---|---| | Zcode | 逐轮精确 | `input − cache_read + cache_creation` | `cache_read` | `output + reasoning` | 轮区间按天比例分摊 | | Codex | 请求级增量(rollout 识别与收件箱同口径:`session_meta` + 任意非空 originator,2026-09-17 起含 CLI/exec/workbench 变体——此前 Desktop-only 漏计当日全部 CLI 会话) | `last_token_usage.input + cache_write` | `cached_input_tokens` | `output + reasoning` | 每条增量按时间戳归日 | -| Claude | 逐条消息(2026-09-14 探针验证:5/5 桌面会话经 cliSessionId 命中 `~/.claude/projects//.jsonl`,证据 scratch/2026-09-14-claude-transcript-token-probe.md)。桌面登记表按 cliSessionId 配转写;未被登记的 CLI 直启转写按文件 mtime 预筛后直接解析(2026-09-17 起补采,与收件箱 claude CLI 接入同口径;标题/项目由收件箱已知行补齐) | `input_tokens + cache_creation_input_tokens` | `cache_read_input_tokens` | `output_tokens` | 消息时间戳 | +| Claude | 逐条消息,同一 `message.id` 只计一次(同一扫描内全局去重:同次响应按 content block 复制的行 usage 逐位相同、resume 复制的历史行也不再计,H1016-1。2026-09-14 探针验证:5/5 桌面会话经 cliSessionId 命中 `~/.claude/projects//.jsonl`,证据 scratch/2026-09-14-claude-transcript-token-probe.md)。桌面登记表按 cliSessionId 配转写;未被登记的 CLI 直启转写按文件 mtime 预筛后直接解析(2026-09-17 起补采,与收件箱 claude CLI 接入同口径;标题/项目由收件箱已知行补齐) | `input_tokens + cache_creation_input_tokens` | `cache_read_input_tokens` | `output_tokens` | 消息时间戳 | | Pi | 逐条 assistant 消息 | `usage.input + cacheWrite` | `usage.cacheRead` | `output + reasoning` | 消息时间戳 | | Kimi | 逐轮(`~/.kimi-code/sessions/**/agents/*/wire.jsonl` 的 `usage.record`) | `inputOther + inputCacheCreation` | `inputCacheRead` | `output` | `time` 毫秒 | | OpenCode | 逐条 assistant 消息(本地 SQLite `~/.local/share/opencode/opencode.db`),**受管理口径**(2026-09-17 起:只统计经 `bin/session-manager opencode` 注册到收件箱的会话,与 pi/kimi 同构;此前为全库直读,workbench 等其它工具经 server 拉起的会话被误计入——库内实测无创建者身份字段,`agent` 列恒为 build、`workspace_id`/`metadata` 空、`session_input.delivery` 整表未启用) | `input + cache.write` | `cache.read` | `output + reasoning` | 回合完成时刻(`time.completed`,缺失退回消息更新时间) | @@ -33,7 +33,7 @@ ## 触发与固化 - 无定时任务,报告按需生成:界面打开总览(窗口 onAppear)或点进某日详情时触发计算。 -- 过去日固化为 `~/.local/state/session-manager/reports/YYYY-MM-DD.{json,md}`(`version: 7`,0700 目录,原子写入);版本不匹配或缺失自动重算(一次性补录 182 天约 4 秒;新增来源等 schema 变更 bump 版本即全量重算,2026-09-16 v5→v6 已验证)。 +- 过去日固化为 `~/.local/state/session-manager/reports/YYYY-MM-DD.{json,md}`(现役 `version: 9`,0700 目录,原子写入);版本不匹配或缺失自动重算(一次性补录 182 天约 4 秒;新增来源等 schema 变更 bump 版本即全量重算,2026-09-16 v5→v6 已验证)。 - **过去日详情读定稿缓存**:版本匹配且生成时刻晚于该日结束即直接返回(约 30ms),缺失/未定稿才重扫来源并落盘;来源事后补录的历史数据需 `--refresh` 强制重算。 - **过去日重扫与磁盘现有报告按任务合并不降级**(`--refresh` 与补录走同一关口,细则见[用量金额规范](usage-cost.md) §3.1):任务三类合计小于现有报告时,三类按类别取 max(现有, 重扫),正差额(现有 − 重扫)记 unknown——三类合计与 model 明细保持一致,且不低于现有报告(2026-09-25 H0925-4:此前合计沿用现有报告,重扫总量更小但某一类因类别迁移更大时,合计与 model 明细不一致)。代价:同一批 token 在两次扫描间换了类别时合计会偏高(用户已知悉)。 - **今天始终实时计算、不固化**:现算并展示,详情汇总卡标「实时汇总 · 截至 HH:MM」;次日按未定稿规则补算一次后定稿。 @@ -68,6 +68,8 @@ bin/session-manager inbox pricing show [MODEL] | check [--days 30] | update [--a - **准确性核对(2026-09-15)**:独立重算脚本与报告逐项对比,Codex/Claude/Pi/Kimi 三类逐位一致;Zcode 在跨零点分摊容差内 <2.2%(归日语义差)。证据 scratch/2026-09-15-token-accuracy-check.md。 - 总览/详情截图核对通过(scratch/daily-report-v3-overview.png、daily-report-v3-day.png)。 +- **claude 转写重复计数修正(2026-10-16,H1016-1)**:独立核查确认同一次响应按 content block 写多行 assistant 记录(同 `message.id`/`requestId`、usage 逐位相同、`apiBlockIndex` 不同);近 7 天重复占 claude 来源 44%–84%(10-13 当天 14.9 亿中重复 12.5 亿)。修复 = 按 `message.id` 去重 + schema v9 全量重算;其余来源(Zcode/Codex/Kimi/Pi/OpenCode)经真实数据逐项核查无重复计数(Zcode 父轮级=自身请求之和,子代理独立记账;Codex 近 7 天零重复事件且 resume 不重放;OpenCode `total=四项之和`;Kimi 零重复)。证据 scratch/2026-10-16-claude-dup-audit/。 + ## 验收(编号) 2026-09-25 由本文既有条款整理,不新增需求;证据类型与覆盖列由 `harness/acceptance.py` 检查(见 [delivery-harness.md](delivery-harness.md))。 @@ -82,9 +84,9 @@ bin/session-manager inbox pricing show [MODEL] | check [--days 30] | update [--a | DR6 | `segments` 为真实活动段(Zcode 逐请求、其余逐条时间戳,按天窗口截断),不按首末时间画整段 | 夹具 | `test_daily_report.DailyReportTests.test_task_segments_follow_real_activity_not_first_to_last_span`、`test_daily_report.DailyReportTests.test_zcode_segments_use_model_requests_not_whole_turn`、`test_daily_report.DailyReportTests.test_segments_clamped_to_day_window_across_midnight` | | DR7 | agent 会话退出合计、计入 `agent_excluded` 注脚;手动覆盖与 origin 规则生效;`inbox usage` 补录路径同样带注脚 | 夹具 | `test_daily_report.DailyReportTests.test_agent_sessions_excluded_and_counted_in_footnote`、`test_daily_report.DailyReportTests.test_manual_override_excludes_from_daily_report`、`test_daily_report.DailyReportTests.test_origin_rule_overrides_report_scope`、`test_usage_report.BackfillAgentStatsTest` | | DR8 | OpenCode 受管理口径:只统计经包装器登记的会话 | 夹具 | `test_daily_report.DailyReportTests.test_opencode_assistant_usage_counted` | -| DR9 | 过去日固化为 json + md;今天始终实时、不落盘 | 夹具 | `test_daily_report.DailyReportTests.test_generate_day_writes_v8_and_markdown`、`test_daily_report.DailyReportTests.test_generate_day_keeps_today_live` | +| DR9 | 过去日固化为 json + md;今天始终实时、不落盘 | 夹具 | `test_daily_report.DailyReportTests.test_generate_day_writes_v9_and_markdown`、`test_daily_report.DailyReportTests.test_generate_day_keeps_today_live` | | DR10 | 过去日读定稿缓存;生成时刻早于该日结束或版本不符时重算 | 夹具 | `test_daily_report.DailyReportTests.test_overview_refreshes_past_day_snapshot_taken_before_day_end`、`test_daily_report.DailyReportTests.test_overview_backfill_cache_invalidates_v1_and_ranks_week_projects` | -| DR11 | 过去日重写按任务合并、不降级(`--refresh` 与补录走同一关口) | 夹具 + 性质 | `test_daily_report.DailyReportTests.test_refresh_does_not_degrade_restored_day`、`test_daily_report.DailyReportTests.test_backup_fallback_when_current_report_missing`、`test_properties.MergeNoDowngradeProperties` | +| DR11 | 过去日重写与磁盘同版本报告按任务合并、不降级(`--refresh` 与补录走同一关口);版本升级即口径重建,不与旧版本合并 | 夹具 + 性质 | `test_daily_report.DailyReportTests.test_refresh_does_not_degrade_same_version_day`、`test_daily_report.DailyReportTests.test_finalize_ignores_other_version_reports`、`test_daily_report.DailyReportTests.test_partial_cleanup_diff_becomes_unknown`、`test_properties.MergeNoDowngradeProperties` | | DR12 | 热力分级:0 无记录 / L1 <20M / L2 <100M / L3 <400M / L4 ≥400M | 单测 | `test_daily_report.DailyReportTests.test_heat_level_total_token_thresholds` | | DR13 | 数字格式 `tokenText`(k/M/B 规则)双端一致 | 单测 | `test_daily_report.DailyReportTests.test_token_text_units`、`tests/InboxPolicyTests.swift#tokenText(6_594) == "6.6k"` | | DR14 | 隐私:正文不提取、不持久化、不输出;唯一例外是标题摘要:claude CLI 首条合格用户消息、Pi 无自定义名时的首条用户消息,截取 ≤80 字符作标题 | 单测 | `test_cli_sessions.ClaudeCliTitlePrivacyTests.test_title_is_first_qualified_user_message_capped_at_80_chars`、`test_cli_sessions.ClaudeCliTitlePrivacyTests.test_title_at_exactly_80_chars_is_kept_whole`、`test_daily_report.DailyReportPrivacyTests.test_report_output_and_store_carry_no_message_bodies`、`test_source_body_privacy.SourceBodyPrivacyTests.test_codex_bodies_stay_out_of_store_and_report`、`test_source_body_privacy.SourceBodyPrivacyTests.test_zcode_bodies_stay_out_of_store_and_report`、`test_source_body_privacy.SourceBodyPrivacyTests.test_kimi_bodies_stay_out_of_store_and_report`、`test_source_body_privacy.SourceBodyPrivacyTests.test_pi_bodies_beyond_bounded_title_stay_out_of_store_and_report`、`test_source_body_privacy.SourceBodyPrivacyTests.test_opencode_bodies_stay_out_of_store_and_report`、`test_source_body_privacy.SourceBodyPrivacyTests.test_agy_bodies_stay_out_of_store` | diff --git a/docs/specs/usage-cost.md b/docs/specs/usage-cost.md index 757e258..204150f 100644 --- a/docs/specs/usage-cost.md +++ b/docs/specs/usage-cost.md @@ -49,7 +49,7 @@ ## 3. 报告 schema v8 -`REPORT_VERSION = 8`。在 v7 基础上新增字段,不删除任何现有字段。 +`REPORT_VERSION = 9`(2026-10-16 起,H1016-1:claude 转写按 `message.id` 去重)。字段结构与 v8 相同,不删除任何现有字段。 ```json "tasks": [{ @@ -62,13 +62,12 @@ } } }], -"totals": { "...v7 字段": "...", "models": { "": { "fresh_input": 0, "cache_write": 0, "cache_read": 0, "output": 0, "native_cost_usd": null } } }, -"migrated_from": 7 +"totals": { "...v7 字段": "...", "models": { "": { "fresh_input": 0, "cache_write": 0, "cache_read": 0, "output": 0, "native_cost_usd": null } } } ``` - 定稿报告中**不写金额**。金额是读取时的派生值(§5),因此价格表或汇率变化后不需要重扫来源。 - `fidelity=unavailable` 的任务保持 `models = {}`。 -- `migrated_from` 只在 §3.1 的保留情形下出现。 +- `migrated_from` 只在 v7→v8 迁移期产生,v9 起不再出现。 ### 3.1 v7 → v8 迁移(不得降级) @@ -79,8 +78,10 @@ - 只在 v7 中存在的任务(来源已被清理):保留 v7 记录,改写为 `models = {"unknown": {"fresh_input": input_tokens, "cache_write": 0, "cache_read": cache_tokens, "output": output_tokens}}`,并标记 `restored_from: 7`。**但如果该会话按当前有效来源判定为 agent,就不恢复**,计入 `agent_excluded`。 - 同一任务重扫的三类合计小于现有报告(来源部分被清理,或两次扫描间类别迁移使某一类上升):三类按类别取 max(现有, 重扫),正差额(现有 − 重扫)记为 `unknown`(2026-09-25 H0925-4 修订:此前三类合计沿用现有报告,某类上升时与 model 明细不一致;代价是同一批 token 换类别时合计偏高,用户已知悉)。 - totals 按合并后的任务重新汇总;只要有恢复发生,报告顶层就标记 `migrated_from: 7`。`unknown` 永远不定价。 -4. **不降级是长期约束,不只在迁移时生效**:任何重写过去日报告的路径(`--refresh`、以后再升 schema、`inbox usage` 补录),都按第 3 步的规则与磁盘上的现有报告(任意版本)合并,现有报告缺失时再用 `reports.v7.bak/` 中的同日文件。重扫结果永远不能让某个任务丢失 token,只有改判为 agent 属于正当减少。 -5. 今天的报告照常实时计算,不参与迁移。 + - max 合并的结果必须真正落盘:合并关口的短路判定要看任务 total 是否被抬高,不能只看任务数(H1016-1 评审修订:纯 cap 调整时任务数不变,按数判等会把 cap 结果丢弃、磁盘降级为重扫值)。 +4. **不降级是长期约束,不只在迁移时生效**:任何重写过去日报告的路径(`--refresh`、`inbox usage` 补录),都按第 3 步的规则与磁盘上的现有报告合并。重扫结果永远不能让某个任务丢失 token,只有改判为 agent 属于正当减少。 +5. **版本升级即口径重建(2026-10-16 H1016-1 修订)**:合并只对**同版本**报告生效;磁盘上旧版本报告(及 `reports.v7.bak/`)不再作为基线——旧版本的数值在口径修正后不可信(v9 修正 claude 转写重复计数,v8 的 claude 数值虚高 44%–84%,max 合并会把虚高值永久固化),版本 bump 后过去日一律从来源全量重算。v7→v8 的迁移合并(本节 1–3 步)是历史上唯一一次跨版本合并,随 v9 发布退役。 +6. 今天的报告照常实时计算,不参与迁移。 ## 4. 价格表 @@ -260,7 +261,7 @@ bin/session-manager inbox pricing show [MODEL] | check [--days 30] | update [--a | # | 项 | 方法 | 证据类型 | 覆盖 | |---|---|---|---|---| | U1 | v8 三类合计与 v7 逐位一致(Zcode 保持现有容差) | 用本机真实数据抽 3 个过去日,由独立脚本对比,证据放 `scratch/` | 真实数据 | 独立脚本对比,证据不入库 | -| U2 | 迁移不降级 | 夹具:v7 报告存在但来源已删;迁移后合计等于 v7,出现 `migrated_from: 7` 和 `unknown` model,备份目录存在 | 夹具 + 性质 | `test_daily_report.DailyReportTests.test_v7_migration_rewrites_to_unknown_when_sources_gone`、`test_daily_report.DailyReportTests.test_partial_cleanup_diff_becomes_unknown`、`test_properties.MergeNoDowngradeProperties` | +| U2 | 同版本重扫不降级;版本升级即重建、不与旧版本合并(H1016-1) | 夹具:同版本报告存在但来源已删,重扫后任务恢复、合计不降;磁盘留旧版本报告时全量重算且不带迁移标记 | 夹具 + 性质 | `test_daily_report.DailyReportTests.test_refresh_does_not_degrade_same_version_day`、`test_daily_report.DailyReportTests.test_finalize_ignores_other_version_reports`、`test_daily_report.DailyReportTests.test_partial_cleanup_diff_becomes_unknown`、`test_properties.MergeNoDowngradeProperties` | | U3 | 六个来源的 model 与四项拆分正确 | 每个来源一份夹具测试,包括 Codex 缺少 turn_context 时记为 unknown;Zcode 覆盖单 model 整轮归属、多 model 按占比拆分(拆分后合计等于轮级)、无 model_usage 记 unknown。真实数据:最近 7 天各来源 `unknown` 的 token 占比写进 PR | 夹具 + 真实数据 | `test_daily_report.DailyReportTests.test_zcode_reconciled_requests_split_by_model`、`test_daily_report.DailyReportTests.test_zcode_multi_model_splits_by_request_share`、`test_daily_report.DailyReportTests.test_zcode_turn_without_model_usage_is_unknown`、`test_daily_report.DailyReportTests.test_codex_model_from_turn_context_and_unknown_without`、`test_daily_report.DailyReportTests.test_claude_pi_models_and_native_cost`、`test_daily_report.DailyReportTests.test_kimi_opencode_models_and_native_cost` | | U4 | 规范名 | `GLM-5.3-Flash`、`zai-coding-plan/glm-5.3-flash` 合并;日期后缀被去掉;不做相似度匹配 | 单测 | `test_model_names.CanonicalTest` | | U5 | 查找顺序、历史价格、未定价 | 覆盖层 > 国产官方 > 公开价格;跨调价日的金额分别按新旧价计算;未知 model 进入 `unpriced_tokens` | 单测 + 性质 | `test_usage_cost.LookupOrderTest`、`test_usage_cost.HistoryPriceTest`、`test_usage_cost.CostTest`、`test_properties.PeriodCostProperties` | diff --git a/scripts/daily_report.py b/scripts/daily_report.py index 4025b8b..bec1242 100644 --- a/scripts/daily_report.py +++ b/scripts/daily_report.py @@ -3,11 +3,14 @@ token 三类口径:输入(新鲜输入+缓存写入)、缓存(缓存读取)、输出(含 reasoning); 三类之和 = 合计,参与一切比较与计算(热力分级/排名/占比/趋势),三类在界面全部展示。 取消与出错轮次的消耗照计。只读取时间戳、标题、项目与数值字段,从不读取或存储 -消息正文。过去日以报告文件固化(version=8),当日始终实时计算。 +消息正文。过去日以报告文件固化(version=9),当日始终实时计算。 -v8 在 v7 基础上新增逐 model 用量(schema 见用量金额规范 §3):每个来源产出 +v8 新增逐 model 用量(schema 见用量金额规范 §3):每个来源产出 model_raw 与四项原始 token(fresh_input/cache_write/cache_read/output), 三类由四项推出;金额不写入报告,是读取时的派生值。 +v9 修正 claude 口径:转写中同一次响应按 content block 复制的多行记录 +(同 message.id,usage 相同)只计一次;版本升级即口径重建,旧版本报告 +不作为定稿合并的基线(H1016-1)。 """ import json @@ -31,8 +34,7 @@ "waiting": "waiting", } NO_PROJECT = "(无项目)" -REPORT_VERSION = 8 -REPORT_V7 = 7 +REPORT_VERSION = 9 SEGMENT_GAP = 15 * 60 # 逐条时间戳来源:相邻消息间隔不超过 15 分钟视为同一段活动 CLASSES = ("input_tokens", "cache_tokens", "output_tokens") @@ -585,8 +587,14 @@ def _claude_data(home, window_start): # 登记目录缺失时跳过登记匹配,CLI 直启转写扫描照常执行。 root = home / "Library/Application Support/Claude/claude-code-sessions" + seen_messages = set() # 同一扫描内全局去重:resume 把历史行复制进新文件也不双计。 + def transcript_usage(path): records = [] + # Claude Code 对同一次 API 响应按 content block 写多行 assistant 记录 + # (同 message.id / requestId,usage 相同,H1016-1),逐行累加会把同一 + # 消耗计 2–4 倍:按 message.id 只计一次。去重放在窗口过滤之后,跨零点 + # 边界的块行不互相压制归日;id 由服务端生成、全局唯一,跨文件去重安全。 try: with path.open(errors="replace") as file: for line in file: @@ -603,6 +611,11 @@ def transcript_usage(path): stamp = seconds(record.get("timestamp")) if stamp <= 0 or stamp < window_start: continue + message_id = message.get("id") + if isinstance(message_id, str) and message_id: + if message_id in seen_messages: + continue + seen_messages.add(message_id) records.append( ( stamp, @@ -1352,24 +1365,6 @@ def load_report(root, date_text): return _read_report_version(root, date_text, REPORT_VERSION) -def _backup_v7_reports(root): - """首次遇到 v7 报告时整目录备份(规范 §3.1 只做一次);目录已存在就跳过。""" - directory = Path(root) / "reports.v7.bak" - if directory.exists(): - return - directory.mkdir(parents=True, mode=0o700) - for path in sorted(reports_dir(root).glob("*.json")): - try: - report = json.loads(path.read_text()) - except (OSError, ValueError): - continue - if not isinstance(report, dict) or report.get("version") != REPORT_V7: - continue - for source in (path, path.with_suffix(".md")): - if source.exists(): - (directory / source.name).write_bytes(source.read_bytes()) - - def _task_key(record): return record.get("provider"), record.get("session_id") @@ -1382,30 +1377,18 @@ def _task_total(record): ) -def _v7_unknown_models(record): - """v7 任务 → unknown 拆分(§3.1.3)。""" - return { - UNKNOWN: { - "fresh_input": int(record.get("input_tokens") or 0), - "cache_write": 0, - "cache_read": int(record.get("cache_tokens") or 0), - "output": int(record.get("output_tokens") or 0), - } - } - - def _merge_day_tasks(report, base, excluded_sessions): - """重扫报告与磁盘现有报告按 (provider, session_id) 合并,不降级(§3.1.3/4)。 + """重扫报告与磁盘现有同版本报告按 (provider, session_id) 合并,不降级(§3.1.3/4)。 - 重扫中存在的任务:采用重扫记录;其三类合计小于现有报告时,三类按类别取 max(现有, 重扫),正差额(现有 − 重扫)记 unknown(H0925-4:合计沿用旧值而 某类采用新值时,model 明细之和会与三类合计不一致)。 - - 只在现有报告存在的任务:恢复(v7 任务拆 unknown 并标记 restored_from; - 判定为 agent 的会话不恢复——正当排除,重扫注脚已含)。 - 返回 (merged_records, restored_v7)。""" + - 只在现有报告存在的任务:恢复(判定为 agent 的会话不恢复——正当排除, + 重扫注脚已含)。合并只对同版本报告生效;版本升级即口径重建,旧版本数值 + 不作为基线(H1016-1:v9 修正 claude 重复计数,max 合并会把虚高旧值固化)。""" base_map = {_task_key(record): record for record in base.get("tasks") or []} rescan_map = {_task_key(record): record for record in report.get("tasks") or []} - merged, restored = [], False + merged = [] for key, record in rescan_map.items(): old = base_map.get(key) if old is None or _task_total(old) <= _task_total(record): @@ -1444,25 +1427,22 @@ def _merge_day_tasks(report, base, excluded_sessions): unknown[name] = int(unknown.get(name) or 0) + diff[name] models[UNKNOWN] = unknown merged_record["models"] = models - merged_record["input_tokens"] = int(record.get("input_tokens") or 0) + diff["fresh_input"] - merged_record["cache_tokens"] = int(record.get("cache_tokens") or 0) + diff["cache_read"] - merged_record["output_tokens"] = int(record.get("output_tokens") or 0) + diff["output"] + merged_record["input_tokens"] = ( + int(record.get("input_tokens") or 0) + diff["fresh_input"] + ) + merged_record["cache_tokens"] = ( + int(record.get("cache_tokens") or 0) + diff["cache_read"] + ) + merged_record["output_tokens"] = ( + int(record.get("output_tokens") or 0) + diff["output"] + ) merged_record["total_tokens"] = _task_total(merged_record) merged.append(merged_record) for key, old in base_map.items(): - if key in rescan_map: - continue - if key in excluded_sessions: + if key in rescan_map or key in excluded_sessions: continue # 判定为 agent:正当排除,不恢复。 - record = dict(old) - is_v7 = old.get("version") == REPORT_V7 or "models" not in old - if is_v7: - record["models"] = _v7_unknown_models(old) - record["restored_from"] = REPORT_V7 - if _task_total(record) > 0 or record.get("fidelity") == "unavailable": - merged.append(record) - if is_v7: - restored = True + if _task_total(old) > 0 or old.get("fidelity") == "unavailable": + merged.append(dict(old)) merged.sort( key=lambda item: ( -item.get("total_tokens", 0), @@ -1470,60 +1450,43 @@ def _merge_day_tasks(report, base, excluded_sessions): item.get("session_id", ""), ) ) - return merged, restored + return merged def _merge_day_report(report, base, excluded_sessions): - """合并任务并用 build_report 重算 totals;返回 (new_report, restored_v7)。""" - merged, restored = _merge_day_tasks(report, base, excluded_sessions) - if ( - not restored - and len(merged) == len(report.get("tasks") or []) - and base.get("version") == REPORT_VERSION - ): - # 没有恢复发生且任务集不变:直接用重扫报告。 - return report, False - restored_flag = restored or any("restored_from" in record for record in merged) + """合并任务并用 build_report 重算 totals。""" + rescan_records = report.get("tasks") or [] + merged = _merge_day_tasks(report, base, excluded_sessions) + # max 合并只在现有合计更大时触发,且必然抬高该任务 total:逐位置比对 + # total 即可区分「未调整的重扫」与「被 cap 的重扫」——后者不能丢弃 + # (H1016-1 评审修订:len 相等会误判,把差额 unknown 连同 cap 一起丢掉)。 + unchanged = len(merged) == len(rescan_records) and all( + merged[index]["total_tokens"] == rescan_records[index]["total_tokens"] + for index in range(len(merged)) + ) + if unchanged: + return report new_report = build_report( parse_day(report["date"]), merged, report.get("generated_at") or time.time() ) new_report["generated_at"] = ( report.get("generated_at") or new_report["generated_at"] ) - if restored_flag: - new_report["migrated_from"] = REPORT_V7 - return new_report, restored_flag + return new_report def finalize_day_report( root, day, report, *, excluded_sessions=frozenset(), store=None, home=None ): - """过去日报告落盘关口:按任务合并且不降级(规范 §3.1.3/4,长期约束)。 + """过去日报告落盘关口:与磁盘上同版本报告按任务合并且不降级(§3.1.3/4)。 - 与磁盘上该日的现有报告(任意版本)合并;磁盘缺失时回退 reports.v7.bak/。 + 版本升级即口径重建:不与旧版本报告或历史备份合并(H1016-1——v9 修正 claude + 转写重复计数,旧版本数值虚高,max 合并会把虚高值永久固化)。 excluded_sessions 是本次扫描被判为 agent 的 (provider, session_id) 集合。 今天的报告不参与。""" - day_text = day.isoformat() - current = _read_report_version(root, day_text, REPORT_VERSION) - legacy = _read_report_version(root, day_text, REPORT_V7) - if legacy is not None: - _backup_v7_reports(root) - base = current or legacy - if base is None: - # 回退 reports.v7.bak(§3.1.4):备份目录平铺,不走 reports/ 拼接。 - backup_path = Path(root) / "reports.v7.bak" / f"{day_text}.json" - try: - backup = json.loads(backup_path.read_text()) - except (OSError, ValueError): - backup = None - if ( - isinstance(backup, dict) - and backup.get("version") == REPORT_V7 - and backup.get("date") == day_text - ): - base = backup - if base is not None: - report, _restored = _merge_day_report(report, base, excluded_sessions) + current = _read_report_version(root, day.isoformat(), REPORT_VERSION) + if current is not None: + report = _merge_day_report(report, current, excluded_sessions) _write_report(root, report) return report diff --git a/tests/test_daily_report.py b/tests/test_daily_report.py index a0b55fe..7086d82 100644 --- a/tests/test_daily_report.py +++ b/tests/test_daily_report.py @@ -1,5 +1,4 @@ import json -import shutil import sqlite3 import sys import tempfile @@ -779,6 +778,65 @@ def test_claude_cli_only_transcript_counted_once(self): ) self.assertEqual(by_sid["stale-desktop"]["total_tokens"], 15) + def test_claude_duplicate_message_records_counted_once(self): + # H1016-1:Claude Code 对同一次 API 响应按 content block 写多行 assistant + # 记录(同 message.id / requestId,usage 相同),逐行累加会把同一消耗 + # 计 2–4 倍;按 message.id 只计一次。 + (self.home / ".claude/projects/proj").mkdir(parents=True, exist_ok=True) + usage = { + "input_tokens": 100, + "cache_creation_input_tokens": 10, + "cache_read_input_tokens": 500, + "output_tokens": 20, + } + lines = [ + json.dumps( + { + "type": "assistant", + "sessionId": "dup-1", + "timestamp": iso(self.day, 9), + "requestId": "req_1", + "apiBlockIndex": block, + "message": {"role": "assistant", "id": "msg_1", "usage": usage}, + } + ) + for block in range(3) + ] + # 不同响应不受去重影响;无 id 的记录也不计入去重。 + lines.append( + json.dumps( + { + "type": "assistant", + "sessionId": "dup-1", + "timestamp": iso(self.day, 10), + "message": { + "role": "assistant", + "id": "msg_2", + "usage": { + "input_tokens": 30, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 5, + }, + }, + } + ) + ) + (self.home / ".claude/projects/proj" / "dup-1.jsonl").write_text( + "\n".join(lines) + "\n" + ) + records = scan_buckets(self.store, self.home, self.day, self.day)[self.day] + by_sid = {record["session_id"]: record for record in records} + self.assertEqual( + ( + by_sid["dup-1"]["input_tokens"], + by_sid["dup-1"]["cache_tokens"], + by_sid["dup-1"]["output_tokens"], + by_sid["dup-1"]["total_tokens"], + ), + (140, 500, 25, 665), + ) + def test_claude_transcript_counted_without_desktop_install(self): # 评审 R6:桌面登记目录从未存在(纯 CLI 用户)——旧实现因 # 「登记目录不存在」整段早退,全部 Claude 用量漏计。 @@ -848,7 +906,7 @@ def test_agent_sessions_excluded_and_counted_in_footnote(self): self.assertEqual(stats[self.day]["tasks"], {("codex", "ag-1")}) self.assertEqual(stats[self.day]["total_tokens"], 600) report = generate_day(self.store, self.home, self.day.isoformat(), refresh=True) - self.assertEqual(report["version"], 8) + self.assertEqual(report["version"], 9) self.assertEqual(report["agent_excluded"], {"tasks": 1, "total_tokens": 600}) self.assertEqual([t["session_id"] for t in report["tasks"]], ["sess_u"]) self.assertIn("1 个 agent 会话", render_markdown(report)) @@ -964,7 +1022,7 @@ def test_token_text_units(self): ["0", "895", "6.6k", "457k", "614k", "1M", "55.7M", "143M", "553M", "1B"], ) - def test_generate_day_writes_v8_and_markdown(self): + def test_generate_day_writes_v9_and_markdown(self): self.zcode_index("sess_a", "收件箱日报", "/work/session-manager") self.zcode_turn( "sess_a", @@ -976,7 +1034,7 @@ def test_generate_day_writes_v8_and_markdown(self): output=10_000, ) report = generate_day(self.store, self.home, self.day.isoformat()) - self.assertEqual(report["version"], 8) + self.assertEqual(report["version"], 9) self.assertEqual( ( report["totals"]["input_tokens"], @@ -997,7 +1055,7 @@ def test_generate_day_writes_v8_and_markdown(self): self.assertIn("900k", markdown) self.assertIn("收件箱日报", markdown) self.assertEqual( - load_report(self.store.root, self.day.isoformat())["version"], 8 + load_report(self.store.root, self.day.isoformat())["version"], 9 ) # 过去日已定稿:再次查看直接读缓存,不重扫来源(新增轮次不会出现)。 self.zcode_turn( @@ -1143,7 +1201,7 @@ def test_overview_backfill_cache_invalidates_v1_and_ranks_week_projects(self): 600, ) self.assertEqual( - load_report(self.store.root, self.day.isoformat())["version"], 8 + load_report(self.store.root, self.day.isoformat())["version"], 9 ) # 过去日读缓存:新增历史轮次不改变固化结果,总览与详情一致;只有 refresh 才重扫来源。 self.zcode_turn( @@ -1317,7 +1375,9 @@ def test_zcode_multi_model_splits_by_request_share(self): def test_zcode_turn_without_model_usage_is_unknown(self): # 该轮没有 model_usage 记录:整轮 unknown(token 仍以轮级为准)。 self.zcode_index("sess_a") - self.zcode_turn("sess_a", "t1", stamp(self.day, 10), stamp(self.day, 10, 30), fresh=88) + self.zcode_turn( + "sess_a", "t1", stamp(self.day, 10), stamp(self.day, 10, 30), fresh=88 + ) record = self.single(scan_buckets(self.store, self.home, self.day, self.day)) self.assertEqual(record["models"]["unknown"]["fresh_input"], 88) self.assertEqual(record["total_tokens"], 88) @@ -1513,133 +1573,86 @@ def test_totals_models_merge_across_tasks_with_raw_names(self): ) self.assertEqual(raw_names, ["GPT-6-Astra", "zai-coding-plan/gpt-6-astra"]) - def write_v7_report(self, total): + def write_day_report(self, task, version=9): + """把一条任务写成已定稿的当日报告(generated_at 晚于当日结束)。""" day = self.day.isoformat() - task = { + report = { + "version": version, + "date": day, + "generated_at": stamp(self.day + timedelta(days=1), 0) + 100, + "totals": { + "input_tokens": task["input_tokens"], + "cache_tokens": task["cache_tokens"], + "output_tokens": task["output_tokens"], + "total_tokens": task["total_tokens"], + "tasks": 1, + "turns": 1, + "sources": {}, + "projects": {}, + }, + "tasks": [task], + } + directory = self.store.root / "reports" + directory.mkdir(parents=True, exist_ok=True) + (directory / f"{day}.json").write_text(json.dumps(report)) + return report + + def day_task(self, total, input_tokens=60, cache_tokens=0, output_tokens=40): + return { "provider": "codex", "session_id": "old-1", "title": "旧任务", "project": "/work/x", "first_at": stamp(self.day, 9), "last_at": stamp(self.day, 10), - "input_tokens": 100, - "cache_tokens": 20, - "output_tokens": 30, + "input_tokens": input_tokens, + "cache_tokens": cache_tokens, + "output_tokens": output_tokens, "total_tokens": total, "turns": 1, "state": "idle", "fidelity": "exact", "segments": [[stamp(self.day, 9), stamp(self.day, 10)]], - } - totals = { - "input_tokens": 100, - "cache_tokens": 20, - "output_tokens": 30, - "total_tokens": total, - "tasks": 1, - "turns": 1, - "sources": { - "codex": { - key: task[key] - for key in ( - "input_tokens", - "cache_tokens", - "output_tokens", - "total_tokens", - ) - } - }, - "projects": { - "/work/x": { - key: task[key] - for key in ( - "input_tokens", - "cache_tokens", - "output_tokens", - "total_tokens", - ) + "models": { + "unknown": { + "fresh_input": input_tokens, + "cache_write": 0, + "cache_read": cache_tokens, + "output": output_tokens, } }, } - if total != 150: - totals["total_tokens"] = total - task["total_tokens"] = total - report = { - "version": 7, - "date": day, - "generated_at": stamp(self.day + timedelta(days=1), 0) + 100, - "totals": totals, - "tasks": [task], - } - directory = self.store.root / "reports" - directory.mkdir(parents=True, exist_ok=True) - (directory / f"{day}.json").write_text(json.dumps(report)) - return report - def test_v7_migration_rewrites_to_unknown_when_sources_gone(self): - # U2:v7 存在但来源已清理(v8 合计更少)→ 改用 v7 内容,unknown 拆分,不降级。 - self.write_v7_report(150) + def test_finalize_ignores_other_version_reports(self): + # H1016-1:版本升级即口径重建。磁盘上的旧版本报告数值不可信 + # (v8 的 claude 含转写重复计数),不作为合并基线,直接全量重算。 + self.write_day_report(self.day_task(150), version=8) report = generate_day(self.store, self.home, self.day.isoformat()) - self.assertEqual(report["version"], 8) - self.assertEqual(report.get("migrated_from"), 7) - self.assertEqual(report["totals"]["total_tokens"], 150) - self.assertEqual( - report["tasks"][0]["models"], - { - "unknown": { - "fresh_input": 100, - "cache_write": 0, - "cache_read": 20, - "output": 30, - } - }, - ) - self.assertEqual( - report["totals"]["models"]["unknown"], - { - "fresh_input": 100, - "cache_write": 0, - "cache_read": 20, - "output": 30, - "native_cost_usd": None, - }, - ) - backup = self.store.root / "reports.v7.bak" / f"{self.day.isoformat()}.json" - self.assertTrue(backup.exists()) - self.assertEqual(json.loads(backup.read_text())["version"], 7) - # 迁移结果已固化:再次读取命中 v8 缓存。 + self.assertEqual(report["version"], 9) + self.assertEqual(report["totals"]["total_tokens"], 0) + self.assertNotIn("migrated_from", report) + for task in report["tasks"]: + self.assertNotIn("restored_from", task) + # 旧文件被 v9 覆盖,不残留旧版本缓存。 self.assertEqual( - load_report(self.store.root, self.day.isoformat())["version"], 8 - ) - def test_v7_migration_uses_v8_when_not_smaller(self): - # 按任务合并(§3.1.3 修订):重扫任务用 v8,v7 独有任务恢复并标记。 - self.write_v7_report(10) - self.zcode_index("sess_a") - self.zcode_turn( - "sess_a", "t1", stamp(self.day, 10), stamp(self.day, 10, 30), fresh=100 + load_report(self.store.root, self.day.isoformat())["version"], 9 ) + + def test_refresh_does_not_degrade_same_version_day(self): + # 同版本不降级(§3.1.4 长期约束):来源被清理后重扫更少,任务照常恢复。 + self.write_day_report(self.day_task(150, input_tokens=100, output_tokens=30)) report = generate_day(self.store, self.home, self.day.isoformat()) - self.assertEqual(report["version"], 8) - self.assertEqual(report.get("migrated_from"), 7) - self.assertEqual(report["totals"]["total_tokens"], 110) - restored = [task for task in report["tasks"] if task.get("restored_from") == 7] - self.assertEqual([task["session_id"] for task in restored], ["old-1"]) - self.assertTrue((self.store.root / "reports.v7.bak").exists()) + self.assertEqual(report["totals"]["total_tokens"], 150) + # 来源仍为空时再 refresh:合计保持 150,不降为 0。 + again = generate_day(self.store, self.home, self.day.isoformat(), refresh=True) + self.assertEqual(again["totals"]["total_tokens"], 150) + self.assertEqual(again["tasks"][0]["session_id"], "old-1") def test_partial_cleanup_diff_becomes_unknown(self): - # 同一任务 v8 合计小于 v7(部分来源被清理):三类沿用 v7,差额记 unknown。 - self.write_v7_report(100) - # v7 任务三类对齐 total=100(60 输入 / 0 缓存 / 40 输出)。 - day_path = self.store.root / "reports" / f"{self.day.isoformat()}.json" - data = json.loads(day_path.read_text()) - task = data["tasks"][0] - task.update(input_tokens=60, cache_tokens=0, output_tokens=40, total_tokens=100) - for group in ("sources", "projects"): - for entry in data["totals"][group].values(): - entry.update(input_tokens=60, cache_tokens=0, output_tokens=40, total_tokens=100) - data["totals"].update(input_tokens=60, cache_tokens=0, output_tokens=40, total_tokens=100) - day_path.write_text(json.dumps(data)) - # 重扫同 session_id(codex old-1)但只有 60 tokens + # 同一任务重扫合计小于现有报告(部分来源被清理):三类按类别取 max, + # 正差额记 unknown,model 明细之和与三类合计一致(H0925-4)。 + self.write_day_report(self.day_task(100)) + # 重扫同 session_id(codex old-1)但只有 60 tokens。 usage = { "input_tokens": 40, "cache_write_input_tokens": 0, @@ -1648,39 +1661,22 @@ def test_partial_cleanup_diff_becomes_unknown(self): "reasoning_output_tokens": 0, } self.codex_rollout("old-1", [("token_count", (self.day, 9, 30), usage)]) - report = generate_day(self.store, self.home, self.day.isoformat()) + # refresh=True 走真实重扫合并路径(不带 refresh 会命中定稿缓存,测不到合并)。 + report = generate_day(self.store, self.home, self.day.isoformat(), refresh=True) task = next(t for t in report["tasks"] if t["session_id"] == "old-1") - self.assertEqual(task["total_tokens"], 100) # 沿用 v7 合计 - unknown = task["models"]["unknown"] - # unknown = 重扫自身的 unknown(40f+20o,codex 无 turn_context) + self.assertEqual(task["total_tokens"], 100) # 沿用现有合计 + # unknown = 重扫自身的 unknown(40f+20o) # + 清理差额(20f + 20o)→ 60f / 40o,与沿用后的三类合计一致。 - self.assertEqual(unknown["fresh_input"], 60) - self.assertEqual(unknown["output"], 40) + self.assertEqual(task["models"]["unknown"]["fresh_input"], 60) + self.assertEqual(task["models"]["unknown"]["output"], 40) self.assertEqual(report["totals"]["total_tokens"], 100) - - def test_refresh_does_not_degrade_restored_day(self): - # 对已恢复日 --refresh:重扫更少也不降级(§3.1.4 长期约束)。 - self.write_v7_report(150) - report = generate_day(self.store, self.home, self.day.isoformat()) - self.assertEqual(report["totals"]["total_tokens"], 150) - # 再次 refresh(来源仍为空):合计保持 150,不降为 0。 - again = generate_day(self.store, self.home, self.day.isoformat(), refresh=True) - self.assertEqual(again["totals"]["total_tokens"], 150) - self.assertEqual(again.get("migrated_from"), 7) - - def test_backup_fallback_when_current_report_missing(self): - # 磁盘现有报告缺失时回退 reports.v7.bak(§3.1.4)。 - self.write_v7_report(150) - backup = self.store.root / "reports.v7.bak" - backup.mkdir(parents=True, exist_ok=True) - shutil.copy2( - self.store.root / "reports" / f"{self.day.isoformat()}.json", - backup / f"{self.day.isoformat()}.json", - ) - (self.store.root / "reports" / f"{self.day.isoformat()}.json").unlink() - report = generate_day(self.store, self.home, self.day.isoformat()) - self.assertEqual(report["totals"]["total_tokens"], 150) - self.assertEqual(report.get("migrated_from"), 7) + # 不降级落盘:再次读取(缓存路径)也是 100,不是重扫的 60。 + self.assertEqual( + load_report(self.store.root, self.day.isoformat())["totals"][ + "total_tokens" + ], + 100, + ) def test_cost_level_thresholds_on_unsorted_amounts(self): # R17 回归:乱序金额排序后取 50/75/90 分位,各级分布正确。 @@ -1689,7 +1685,10 @@ def test_cost_level_thresholds_on_unsorted_amounts(self): amounts = sorted([5, 100, 20, 8, 50, 200, 3, 12, 80, 1]) # 10 个样本乱序输入 thresholds = [amounts[int(len(amounts) * q)] for q in (0.5, 0.75, 0.9)] self.assertEqual(thresholds, [20, 80, 200]) # int(10*q) 索引取 5/7/9 号样本 - levels = [cost_level(value, thresholds) for value in [1, 3, 5, 8, 12, 20, 50, 80, 100, 200]] + levels = [ + cost_level(value, thresholds) + for value in [1, 3, 5, 8, 12, 20, 50, 80, 100, 200] + ] self.assertEqual(levels, [1, 1, 1, 1, 1, 2, 2, 3, 3, 4]) self.assertEqual(cost_level(0, thresholds), 0) self.assertEqual(cost_level(10, [0, 0, 0]), 0) # 样本不足时不分级 @@ -1699,9 +1698,13 @@ def test_cost_level_grades_sub_yuan_amounts_and_thresholds(self): from daily_report import cost_level self.assertEqual(cost_level(0.5, [0.8, 2, 5]), 1) # 金额 0.5 落在最低档之下 - self.assertEqual(cost_level(0.7, [0.6, 0.9, 3]), 2) # 最低阈值 0.6 < 1 仍参与分级 + self.assertEqual( + cost_level(0.7, [0.6, 0.9, 3]), 2 + ) # 最低阈值 0.6 < 1 仍参与分级 self.assertEqual(cost_level(4, [0.6, 0.9, 3]), 4) # 高档阈值不受低阈值影响 - self.assertEqual(cost_level(0.7, [0.6, 0, 3]), 3) # 归零判定只看最低阈值,中档阈值不参与 + self.assertEqual( + cost_level(0.7, [0.6, 0, 3]), 3 + ) # 归零判定只看最低阈值,中档阈值不参与 class DailyReportPrivacyTests(unittest.TestCase): @@ -1786,7 +1789,9 @@ def test_report_output_and_store_carry_no_message_bodies(self): serialized = json.dumps(report, ensure_ascii=False) reports = self.store.root / "reports" markdown = (reports / f"{self.day.isoformat()}.md").read_text() - self.assert_no_markers([self.TAIL, self.SECOND, self.ASSISTANT], serialized, markdown) + self.assert_no_markers( + [self.TAIL, self.SECOND, self.ASSISTANT], serialized, markdown + ) for file in sorted(self.store.root.rglob("*")): if not file.is_file(): continue @@ -1801,7 +1806,9 @@ def test_report_output_and_store_carry_no_message_bodies(self): class MergeDayTasksContractTests(unittest.TestCase): """变异测试缺口:性质测试只断言合并的内部一致与不降级,以下精确行为此前无测试。""" - def _task(self, provider, session, input_tokens, cache_tokens, output_tokens, **extra): + def _task( + self, provider, session, input_tokens, cache_tokens, output_tokens, **extra + ): record = { "provider": provider, "session_id": session, @@ -1830,8 +1837,7 @@ def test_equal_totals_with_different_classes_take_rescan(self): old = self._task("pi", "s1", 100, 0, 100) new = self._task("pi", "s1", 0, 200, 0) - merged, restored = _merge_day_tasks({"tasks": [new]}, {"tasks": [old]}, set()) - self.assertFalse(restored) + merged = _merge_day_tasks({"tasks": [new]}, {"tasks": [old]}, set()) self.assertEqual(merged, [new]) def test_downgrade_protection_caps_each_class_at_max(self): @@ -1841,7 +1847,7 @@ def test_downgrade_protection_caps_each_class_at_max(self): old = self._task("pi", "s1", 0, 100, 0) new = self._task("pi", "s1", 0, 30, 0) - merged, _ = _merge_day_tasks({"tasks": [new]}, {"tasks": [old]}, set()) + merged = _merge_day_tasks({"tasks": [new]}, {"tasks": [old]}, set()) task = merged[0] self.assertEqual( (task["input_tokens"], task["cache_tokens"], task["output_tokens"]), @@ -1865,7 +1871,7 @@ def test_zero_total_restored_task_dropped_unless_unavailable(self): zero = self._task("pi", "z1", 0, 0, 0) unavailable = self._task("pi", "z2", 0, 0, 0, fidelity="unavailable") tiny = self._task("pi", "z3", 1, 0, 0) - merged, _ = _merge_day_tasks( + merged = _merge_day_tasks( {"tasks": []}, {"tasks": [zero, unavailable, tiny]}, set() ) self.assertEqual( @@ -1886,9 +1892,7 @@ def test_cost_levels_graded_by_daily_amount(self): pricing = store.root / "pricing" pricing.mkdir(parents=True, exist_ok=True) (pricing / "override.json").write_text( - json.dumps( - {"glm-x": {"input": 1.0, "output": 2.0, "currency": "CNY"}} - ) + json.dumps({"glm-x": {"input": 1.0, "output": 2.0, "currency": "CNY"}}) ) today = date.today() for amount in range(1, 11): diff --git a/tests/test_properties.py b/tests/test_properties.py index 40b3b68..2525a1a 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -156,8 +156,8 @@ def check(rng): # ---- 过去日报告合并不降级(V080-R2、V080-R3) --------------------------------------------- -def random_task(rng, provider, session, v7=False): - """任务记录;约一成三类合计为 0。v7=True 时模拟旧版报告(没有 models)。""" +def random_task(rng, provider, session): + """任务记录;约一成三类合计为 0。""" parts = [0, 0, 0] if rng.random() < 0.1 else [rng.randint(0, 5000) for _ in range(3)] fresh = rng.randint(0, parts[0]) if parts[0] else 0 record = { @@ -179,8 +179,6 @@ def random_task(rng, provider, session, v7=False): } }, } - if v7: - del record["models"] return record @@ -204,7 +202,7 @@ def test_h0925_4_category_totals_match_models_after_downgrade_protection(self): new = {"provider": "zcode", "session_id": "s0", "input_tokens": 1786, "cache_tokens": 4487, "output_tokens": 846, "total_tokens": 7119, "fidelity": "exact", "models": {"m": {"fresh_input": 1786, "cache_write": 0, "cache_read": 4487, "output": 846}}} - merged, _ = _merge_day_tasks({"tasks": [new]}, {"tasks": [old]}, set()) + merged = _merge_day_tasks({"tasks": [new]}, {"tasks": [old]}, set()) task = merged[0] self.assertEqual(model_sums(task), (task["input_tokens"], task["cache_tokens"], task["output_tokens"])) @@ -212,27 +210,17 @@ def test_h0925_4_category_totals_match_models_after_downgrade_protection(self): class MergeNoDowngradeProperties(Property): def test_merged_tasks_are_internally_consistent(self): """每个合并后的任务:各 model 的 token 之和等于三类合计(差额必须如数记入 unknown); - 发生 v7 恢复时打上标记;结果按合计降序、来源、会话排序。变异测试发现这些此前都没被检查。""" + 结果按合计降序、来源、会话排序。变异测试发现这些此前都没被检查。""" def check(rng): keys = [(rng.choice(["pi", "codex", "zcode"]), f"s{i}") for i in range(rng.randint(1, 8))] - base_tasks = [random_task(rng, *key, v7=rng.random() < 0.3) for key in keys if rng.random() < 0.8] + base_tasks = [random_task(rng, *key) for key in keys if rng.random() < 0.8] rescan_tasks = [random_task(rng, *key) for key in keys if rng.random() < 0.7] excluded = {key for key in keys if rng.random() < 0.15} - merged, restored = _merge_day_tasks({"tasks": rescan_tasks}, {"tasks": base_tasks}, excluded) - rescan_map = {_task_key(task): task for task in rescan_tasks} + merged = _merge_day_tasks({"tasks": rescan_tasks}, {"tasks": base_tasks}, excluded) for task in merged: sums = model_sums(task) self.assertEqual(sums, (task["input_tokens"], task["cache_tokens"], task["output_tokens"]), task) - rescan_keys = set(rescan_map) - v7_restorable = [ - task - for task in base_tasks - if "models" not in task and _task_key(task) not in rescan_keys and _task_key(task) not in excluded - ] - self.assertEqual(restored, bool(v7_restorable)) - restored_keys = {_task_key(task) for task in merged if task.get("restored_from") == 7} - self.assertEqual(restored_keys, {_task_key(task) for task in v7_restorable if _task_total(task) > 0}) order = [(-task.get("total_tokens", 0), task["provider"], task["session_id"]) for task in merged] self.assertEqual(order, sorted(order)) @@ -247,7 +235,7 @@ def check(rng): base_tasks = [random_task(rng, *key) for key in keys if rng.random() < 0.8] rescan_tasks = [random_task(rng, *key) for key in keys if rng.random() < 0.7] excluded = {key for key in keys if rng.random() < 0.15} - merged, _ = _merge_day_tasks({"tasks": rescan_tasks}, {"tasks": base_tasks}, excluded) + merged = _merge_day_tasks({"tasks": rescan_tasks}, {"tasks": base_tasks}, excluded) merged_map = {_task_key(task): task for task in merged} rescan_keys = {_task_key(task) for task in rescan_tasks} for old in base_tasks: