codebee 0.1.66 → 0.1.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +543 -520
  2. package/LICENSE +21 -21
  3. package/README.md +11 -7
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/acceptance.py +100 -0
  12. package/app/core/aiflavor.py +197 -197
  13. package/app/core/artifacts.py +148 -148
  14. package/app/core/assets.py +110 -0
  15. package/app/core/attachments.py +623 -532
  16. package/app/core/automation.py +758 -758
  17. package/app/core/backup.py +470 -470
  18. package/app/core/beekeeper.py +183 -183
  19. package/app/core/board.py +209 -209
  20. package/app/core/bookmeta.py +930 -796
  21. package/app/core/bookmeta_catalog.py +180 -180
  22. package/app/core/branching.py +137 -137
  23. package/app/core/builtin_agent.py +1234 -1234
  24. package/app/core/capability.py +144 -144
  25. package/app/core/catalog.py +42 -0
  26. package/app/core/chaptersafe.py +32 -32
  27. package/app/core/cleanup.py +303 -303
  28. package/app/core/compaction.py +201 -201
  29. package/app/core/covergen.py +356 -356
  30. package/app/core/defectretro.py +43 -0
  31. package/app/core/diagnostics.py +114 -114
  32. package/app/core/dispatch.py +173 -173
  33. package/app/core/dispatch_log.py +128 -113
  34. package/app/core/env_scrub.py +85 -85
  35. package/app/core/error_codes.py +23 -23
  36. package/app/core/errorlog.py +137 -137
  37. package/app/core/evaluation.py +174 -174
  38. package/app/core/failure_notify.py +290 -290
  39. package/app/core/flows.py +48 -2
  40. package/app/core/goal_service.py +167 -167
  41. package/app/core/health.py +374 -374
  42. package/app/core/history.py +38 -38
  43. package/app/core/hooks.py +162 -162
  44. package/app/core/jobs.py +46 -26
  45. package/app/core/knowledge.py +57 -3
  46. package/app/core/manager.py +2122 -2115
  47. package/app/core/market.py +362 -362
  48. package/app/core/market_remote.py +124 -26
  49. package/app/core/mocks.py +64 -64
  50. package/app/core/modelhub.py +51 -3
  51. package/app/core/notify.py +106 -106
  52. package/app/core/operations.py +228 -0
  53. package/app/core/paihang.py +138 -109
  54. package/app/core/paths.py +2 -2
  55. package/app/core/pipeline.py +221 -12
  56. package/app/core/planner.py +6 -1
  57. package/app/core/portguard.py +111 -111
  58. package/app/core/preview.py +293 -293
  59. package/app/core/publish/__init__.py +7 -7
  60. package/app/core/publish/auto.py +367 -367
  61. package/app/core/publish/browser.py +539 -533
  62. package/app/core/publish/fanqie.py +159 -128
  63. package/app/core/publish/flow.py +440 -432
  64. package/app/core/publish/flows-fanqie-calibrated.json +332 -332
  65. package/app/core/publish/flows-qimao-calibrated.json +7 -3
  66. package/app/core/publish/ledger.py +203 -198
  67. package/app/core/publish/manager.py +692 -623
  68. package/app/core/publish/qimao.py +141 -141
  69. package/app/core/publish/ws.py +139 -139
  70. package/app/core/redact.py +38 -38
  71. package/app/core/repeat_guard.py +129 -129
  72. package/app/core/revisions.py +51 -0
  73. package/app/core/router.py +398 -398
  74. package/app/core/runner.py +2067 -2050
  75. package/app/core/selfupdate.py +384 -384
  76. package/app/core/session_log.py +161 -161
  77. package/app/core/sessions.py +312 -312
  78. package/app/core/settings.py +149 -149
  79. package/app/core/settings_schema.py +262 -262
  80. package/app/core/share_page.py +114 -114
  81. package/app/core/skill_scan.py +122 -114
  82. package/app/core/skillpacks/fanqie-novel.md +80 -80
  83. package/app/core/skillpacks/market/character-bible.md +66 -66
  84. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  85. package/app/core/skillpacks/market/git-workflow.md +57 -57
  86. package/app/core/skillpacks/market/release-notes.md +72 -72
  87. package/app/core/skillpacks/market/weekly-report.md +71 -71
  88. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  89. package/app/core/skills.py +109 -2
  90. package/app/core/step_runner.py +132 -132
  91. package/app/core/store.py +278 -41
  92. package/app/core/task_compile.py +199 -199
  93. package/app/core/telemetry.py +292 -292
  94. package/app/core/tlsctx.py +64 -64
  95. package/app/core/token_meter.py +147 -147
  96. package/app/core/tracing.py +144 -0
  97. package/app/core/upstream.py +41 -41
  98. package/app/core/usage.py +862 -858
  99. package/app/core/volumes.py +389 -355
  100. package/app/core/wxdigest.py +858 -858
  101. package/app/core/zentao.py +2465 -2447
  102. package/app/main.py +2981 -2838
  103. package/app/pick_dialog.py +110 -110
  104. package/app/ui/app.js +14399 -13994
  105. package/app/ui/artdiff.js +74 -0
  106. package/app/ui/board.html +162 -162
  107. package/app/ui/board.js +305 -305
  108. package/app/ui/i18n.js +2770 -2716
  109. package/app/ui/icons/bee.svg +79 -79
  110. package/app/ui/index.html +6 -0
  111. package/app/ui/manifest.json +16 -16
  112. package/app/ui/qrcode.js +2297 -2297
  113. package/app/ui/style.css +96 -24
  114. package/bin/tutti.js +147 -147
  115. package/package.json +46 -46
  116. package/tools/wx_reader.py +261 -261
package/app/core/usage.py CHANGED
@@ -1,858 +1,862 @@
1
- # -*- coding: utf-8 -*-
2
- """用量台账:每次真实 LLM 调用(CLI 智能体 / 编排者直连 API)追加一条记录,
3
- 支持按天/工具(CLI)/智能体/模型/角色/任务类型多维聚合——用量统计页的数据源。
4
-
5
- 落盘:data/usage/usage-YYYYMM.jsonl(按月分文件,append-only,一行一记录)。
6
- 并发安全:全局锁 + 追加写;读侧每次全量扫描再聚合(调用频次为分钟级,
7
- 文件规模可控;聚合在请求线程内完成,不引入后台任务)。
8
-
9
- 记一条的入口是 record():字段残缺不抛错(埋点失败绝不能影响任务执行)。
10
- """
11
- from __future__ import annotations
12
-
13
- import json
14
- import math
15
- import threading
16
- import time
17
-
18
- from . import paths
19
-
20
- LOCK = threading.RLock()
21
-
22
- # 维度 → 显示名(聚合接口与前端共用)
23
- DIMENSIONS = {
24
- "tool": "工具(CLI)",
25
- "agent": "智能体",
26
- "model": "模型",
27
- "role": "步骤角色",
28
- "task_type": "任务类型",
29
- }
30
-
31
- FIELDS = ("ts", "day", "run_id", "step", "task_id", "task_type", "role", "agent", "agent_label",
32
- "tool", "model", "provider", "ok", "duration_s",
33
- "input", "output", "cached", "reasoning", "total", "cost_usd", "source",
34
- "first_token_ms", "tokens_per_sec")
35
-
36
-
37
- def _month_file(day):
38
- return paths.USAGE_DIR / ("usage-%s.jsonl" % day[:7].replace("-", ""))
39
-
40
-
41
- def _quality_file(day):
42
- return paths.USAGE_DIR / ("routing-quality-%s.jsonl" % day[:7].replace("-", ""))
43
-
44
-
45
- def _parse_int(v):
46
- try:
47
- return int(v or 0)
48
- except (TypeError, ValueError):
49
- return 0
50
-
51
-
52
- def _parse_float(v):
53
- try:
54
- return float(v or 0.0)
55
- except (TypeError, ValueError):
56
- return 0.0
57
-
58
-
59
- def _parse_bool(v):
60
- if v is None:
61
- return False
62
- return bool(v)
63
-
64
-
65
- def record(source="", run_id="", task_id="", task_type="", role="", step=0,
66
- agent="", agent_label="", tool="", model="", provider="",
67
- ok=True, duration_s=0.0, cost_usd=0.0, usage=None,
68
- first_token_ms=0.0, tokens_per_sec=0.0):
69
- """追加一条用量记录。usage 为细分 dict:{input, output, cached, reasoning, total};
70
- 缺省字段按 0 处理。任何异常都吞掉——统计永远不能拖垮业务调用方。
71
-
72
- step 为该运行内的步骤号:与 backfill_from_runs 的去重键一致,缺了会导致
73
- 启动回填把同一步骤重复入账。
74
- """
75
- try:
76
- u = usage or {}
77
- rec = {
78
- "ts": time.strftime("%Y-%m-%d %H:%M:%S"),
79
- "day": time.strftime("%Y-%m-%d"),
80
- "source": str(source or "pipeline")[:24],
81
- "run_id": str(run_id or "")[:64],
82
- "step": _parse_int(step),
83
- "task_id": str(task_id or "")[:64],
84
- "task_type": str(task_type or "")[:32] or "unknown",
85
- "role": str(role or "")[:40] or "unknown",
86
- "agent": str(agent or "")[:40] or "unknown",
87
- "agent_label": str(agent_label or "")[:60],
88
- "tool": str(tool or "")[:24] or "unknown",
89
- "model": str(model or "")[:80] or "(默认)",
90
- "provider": str(provider or "")[:60],
91
- "ok": _parse_bool(ok),
92
- "duration_s": round(_parse_float(duration_s), 1),
93
- "cost_usd": round(_parse_float(cost_usd), 4),
94
- }
95
- inp = max(0, _parse_int(u.get("input")))
96
- out = max(0, _parse_int(u.get("output")))
97
- cach = max(0, _parse_int(u.get("cached")))
98
- reas = max(0, _parse_int(u.get("reasoning")))
99
- total = _parse_int(u.get("total")) or (inp + out + cach)
100
- rec.update({"input": inp, "output": out, "cached": cach,
101
- "reasoning": reas, "total": total})
102
- # 压缩省量注记(source=compaction 专用):上下文折叠净省的估算 token,
103
- # 与消耗并列成账——省了多少不再是黑箱。零值不落字段防老记录膨胀。
104
- saved = max(0, _parse_int(u.get("saved")))
105
- if saved:
106
- rec["saved"] = saved
107
- # 体验指标(§07):只有内置直连的流式路径可测,CLI 事件流没有逐 token
108
- # 时刻。零值不落字段——"缺字段"表示不可测,不能读成"首字 0 毫秒"。
109
- ft = round(_parse_float(first_token_ms), 1)
110
- if ft > 0:
111
- rec["first_token_ms"] = ft
112
- tps = round(_parse_float(tokens_per_sec), 2)
113
- if tps > 0:
114
- rec["tokens_per_sec"] = tps
115
- line = json.dumps(rec, ensure_ascii=False)
116
- with LOCK:
117
- paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
118
- with open(_month_file(rec["day"]), "a", encoding="utf-8") as f:
119
- f.write(line + "\n")
120
- _ROUTING_CACHE["data"].clear()
121
- _ROUTING_RECORDS_CACHE.clear()
122
- _HOURLY_CACHE["ts"] = 0.0
123
- except Exception:
124
- pass
125
-
126
-
127
- def backfill_from_runs():
128
- """把历史 run.json 里已记录的 token 用量回填进台账(启动时调用,幂等)。
129
-
130
- 埋点是后加的:此前的运行只在 run.json 的步骤里存了 tokens/cost_usd 总量,
131
- 没有输入/输出/缓存细分,所以回填记录 input/output/cached 记 0、只有 total,
132
- 并以 source="backfill" 标记便于区分。已回填的 (run_id, 步骤号) 会跳过,
133
- 重复启动不会产生重复记录。
134
- """
135
- try:
136
- runs_dir = paths.RUNS_DIR
137
- if not runs_dir.is_dir():
138
- return 0
139
- # 已入账的 (run_id, role, 步骤号) 集合——含真实埋点与既往回填
140
- seen = set()
141
- for r in _iter_records(0):
142
- seen.add((str(r.get("run_id") or ""), str(r.get("role") or ""),
143
- _parse_int(r.get("step"))))
144
- added = 0
145
- # 旧 run 未存任务类型,从 tasks/*.json 补齐(缺了就记 unknown)
146
- task_types = {}
147
- try:
148
- for tp in paths.TASKS_DIR.glob("*.json"):
149
- try:
150
- t = json.loads(tp.read_text(encoding="utf-8"))
151
- task_types[t.get("id")] = t.get("type") or ""
152
- except Exception:
153
- continue
154
- except Exception:
155
- pass
156
- for p in sorted(runs_dir.glob("*/run.json")):
157
- try:
158
- run = json.loads(p.read_text(encoding="utf-8"))
159
- except Exception:
160
- continue
161
- if not isinstance(run, dict):
162
- continue
163
- run_id = str(run.get("id") or p.parent.name)
164
- day = str(run.get("started_at") or run.get("created_at") or "")[:10]
165
- if not day:
166
- continue
167
- for s in (run.get("steps") or []):
168
- if not isinstance(s, dict):
169
- continue
170
- total = _parse_int(s.get("tokens"))
171
- if total <= 0:
172
- continue # 无 token 的步骤(验证/合并/mock)不入账
173
- n = _parse_int(s.get("n"))
174
- key = (run_id, str(s.get("role") or ""), n)
175
- if key in seen:
176
- continue
177
- seen.add(key)
178
- rec = {
179
- "ts": "%s %s" % (day, str(s.get("started_at") or "00:00:00")[:8]),
180
- "day": day, "source": "backfill", "run_id": run_id,
181
- "step": n,
182
- "task_id": str(run.get("task_id") or ""),
183
- "task_type": task_types.get(str(run.get("task_id") or "")) or "unknown",
184
- "role": str(s.get("role") or "")[:40] or "unknown",
185
- "agent": str(s.get("agent") or "")[:40] or "unknown",
186
- "agent_label": str(s.get("agent_label") or "")[:60],
187
- "tool": _tool_of(str(s.get("agent") or "")),
188
- "model": str(s.get("model") or "")[:80] or "(历史未记录)",
189
- "provider": "",
190
- "ok": str(s.get("status") or "") == "done",
191
- "duration_s": round(_parse_float(s.get("duration_s")), 1),
192
- "cost_usd": round(_parse_float(s.get("cost_usd")), 4),
193
- "input": 0, "output": 0, "cached": 0, "reasoning": 0,
194
- "total": total,
195
- }
196
- with LOCK:
197
- paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
198
- with open(_month_file(day), "a", encoding="utf-8") as f:
199
- f.write(json.dumps(rec, ensure_ascii=False) + "\n")
200
- added += 1
201
- return added
202
- except Exception:
203
- return 0
204
-
205
-
206
- # 智能体 id → 工具 kind(回填时旧记录没有 tool 字段,只能从 id 推断)
207
- _AGENT_TOOL = {"codex-cli": "codex", "claude-code": "claude", "qwen-cli": "qwen",
208
- "qwencode": "qwen", "opencode": "opencode", "aider": "aider",
209
- "orchestrator": "orchestrator"}
210
-
211
-
212
- def _tool_of(agent_id):
213
- a = str(agent_id or "").lower()
214
- if a in _AGENT_TOOL:
215
- return _AGENT_TOOL[a]
216
- for k, v in _AGENT_TOOL.items():
217
- if a.startswith(k):
218
- return v
219
- return a or "unknown"
220
-
221
-
222
- _HOURLY_CACHE = {"ts": 0.0, "val": {}}
223
- _HOURLY_TTL = 60.0 # 秒:路由调用频繁但台账追加低频,60s 缓存足够新鲜
224
- _ROUTING_CACHE = {"data": {}}
225
- _ROUTING_RECORDS_CACHE = {}
226
- _ROUTING_TTL = 15.0
227
-
228
-
229
- def agent_tokens_recent(agent, hours=1):
230
- """该智能体近 N 小时的 token 总量(路由配额惩罚用)。按 ts 前缀过滤,
231
- 60 秒 TTL 进程内缓存——运行中每次路由都查也只扫两天的台账。"""
232
- try:
233
- agent = str(agent or "")
234
- now = time.time()
235
- if now - _HOURLY_CACHE["ts"] > _HOURLY_TTL:
236
- bound = time.strftime("%Y-%m-%d %H:%M:%S",
237
- time.localtime(now - hours * 3600.0))
238
- total = {}
239
- for r in _iter_records(2):
240
- if str(r.get("ts") or "") < bound:
241
- continue
242
- a = str(r.get("agent") or "")
243
- # record() 将规范化后的 token 总数落在顶层 ``total``;旧实现
244
- # 误读不存在的嵌套 usage 字段,导致配额路由永远认为本小时
245
- # 用量为 0,超过供应商额度也不会降权。
246
- total[a] = total.get(a, 0) + max(0, _parse_int(r.get("total")))
247
- _HOURLY_CACHE.update(ts=now, val=total)
248
- return int(_HOURLY_CACHE["val"].get(agent) or 0)
249
- except Exception:
250
- return 0
251
-
252
-
253
- def _iter_records(days):
254
- """按时间范围读取台账(days=0 表示全部)。返回按写入顺序的记录列表。"""
255
- out = []
256
- try:
257
- files = sorted(paths.USAGE_DIR.glob("usage-*.jsonl")) if paths.USAGE_DIR.is_dir() else []
258
- except Exception:
259
- return out
260
- since_day = ""
261
- if days:
262
- import datetime
263
- d = (datetime.date.today() - datetime.timedelta(days=int(days) - 1)).isoformat()
264
- since_day = d
265
- since_month = since_day[:7].replace("-", "") if since_day else ""
266
- for p in files:
267
- if since_month and p.stem.rsplit("-", 1)[-1] < since_month:
268
- continue
269
- try:
270
- for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
271
- line = line.strip()
272
- if not line.startswith("{"):
273
- continue
274
- try:
275
- r = json.loads(line)
276
- except Exception:
277
- continue
278
- if not isinstance(r, dict):
279
- continue
280
- if since_day and str(r.get("day", "")) < since_day:
281
- continue
282
- out.append(r)
283
- except Exception:
284
- continue
285
- return out
286
-
287
-
288
- def _iter_quality_records(days):
289
- """读取独立质量台账;不混入用量统计的调用数、token 和成本。"""
290
- out = []
291
- try:
292
- files = sorted(paths.USAGE_DIR.glob("routing-quality-*.jsonl")) \
293
- if paths.USAGE_DIR.is_dir() else []
294
- except Exception:
295
- return out
296
- since_day = ""
297
- if days:
298
- import datetime
299
- since_day = (datetime.date.today()
300
- - datetime.timedelta(days=int(days) - 1)).isoformat()
301
- since_month = since_day[:7].replace("-", "") if since_day else ""
302
- for path in files:
303
- if since_month and path.stem.rsplit("-", 1)[-1] < since_month:
304
- continue
305
- try:
306
- for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
307
- try:
308
- row = json.loads(line)
309
- except Exception:
310
- continue
311
- if isinstance(row, dict) and (not since_day or row.get("day", "") >= since_day):
312
- out.append(row)
313
- except Exception:
314
- continue
315
- return out
316
-
317
-
318
- def _routing_records(days):
319
- """同一轮候选评分共享一次台账解析,避免每个候选重复扫全盘。"""
320
- key = (str(paths.USAGE_DIR), int(days))
321
- now = time.time()
322
- with LOCK:
323
- cached = _ROUTING_RECORDS_CACHE.get(key)
324
- if cached and now - cached[0] <= _ROUTING_TTL:
325
- return list(cached[1]), list(cached[2])
326
- calls, quality = _iter_records(days), _iter_quality_records(days)
327
- with LOCK:
328
- _ROUTING_RECORDS_CACHE[key] = (now, list(calls), list(quality))
329
- return calls, quality
330
-
331
-
332
- def record_quality_for_run(run_id, quality_ok, agent=""):
333
- """把验收结论归因到最终实际产出者,不重复计入用量。"""
334
- try:
335
- run_id = str(run_id or "")[:64]
336
- if not run_id:
337
- return 0
338
- calls = [r for r in _iter_records(2)
339
- if r.get("run_id") == run_id and r.get("ok") is True]
340
- prefixes = ("implement", "fix", "draft", "revise", "polish", "direct", "author")
341
- calls = [r for r in calls if str(r.get("role") or "").lower().startswith(prefixes)]
342
- agent = str(agent or "")[:40]
343
- unique = {}
344
- for row in calls:
345
- key = tuple(str(row.get(k) or "") for k in
346
- ("task_id", "task_type", "role", "agent", "model", "provider"))
347
- unique[key] = row
348
- if not unique:
349
- return 0
350
- existing = {(r.get("run_id"), r.get("role"), r.get("agent"),
351
- r.get("model"), r.get("provider"))
352
- for r in _iter_quality_records(2)}
353
- day = time.strftime("%Y-%m-%d")
354
- rows = []
355
- for row in unique.values():
356
- identity = (run_id, row.get("role"), row.get("agent"),
357
- row.get("model"), row.get("provider"))
358
- if identity in existing:
359
- continue
360
- # 成功调用不等于产出通过。换将前的实现者即使传输成功,也已被质量
361
- # 门淘汰,必须留下负样本;最终实际产出者才继承本次验收结论。
362
- row_quality_ok = bool(quality_ok)
363
- if agent and str(row.get("agent") or "") != agent:
364
- row_quality_ok = False
365
- rows.append({"ts": time.strftime("%Y-%m-%d %H:%M:%S"), "day": day,
366
- "run_id": run_id, "task_id": row.get("task_id") or "",
367
- "task_type": row.get("task_type") or "unknown",
368
- "role": row.get("role") or "unknown",
369
- "agent": row.get("agent") or "unknown",
370
- "model": row.get("model") or "(默认)",
371
- "provider": row.get("provider") or "",
372
- "quality_ok": row_quality_ok})
373
- if not rows:
374
- return 0
375
- with LOCK:
376
- paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
377
- with open(_quality_file(day), "a", encoding="utf-8") as handle:
378
- for row in rows:
379
- handle.write(json.dumps(row, ensure_ascii=False) + "\n")
380
- _ROUTING_CACHE["data"].clear()
381
- _ROUTING_RECORDS_CACHE.clear()
382
- return len(rows)
383
- except Exception:
384
- return 0
385
-
386
-
387
- def _num(r, key):
388
- return _parse_int(r.get(key))
389
-
390
-
391
- def _percentile(values, percentile):
392
- """线性插值百分位,空样本返回 0。"""
393
- if not values:
394
- return 0.0
395
- ordered = sorted(float(v) for v in values)
396
- if len(ordered) == 1:
397
- return round(ordered[0], 2)
398
- pos = (len(ordered) - 1) * float(percentile)
399
- lo = int(math.floor(pos))
400
- hi = int(math.ceil(pos))
401
- if lo == hi:
402
- return round(ordered[lo], 2)
403
- return round(ordered[lo] + (ordered[hi] - ordered[lo]) * (pos - lo), 2)
404
-
405
-
406
- def routing_stats(task_type="", role="", agent="", model="", provider="",
407
- days=90, min_samples=3):
408
- """返回路由用的近期真实运行指标。
409
-
410
- 只读取脱敏用量台账;精确维度样本不足时依次回退到任务+角色、任务、
411
- 全局,成功率使用 Beta(3, 1) 平滑,避免单次失败永久压低新候选。
412
- """
413
- try:
414
- task_type = str(task_type or "")[:32]
415
- role = str(role or "")[:40]
416
- agent = str(agent or "")[:40]
417
- model = str(model or "")[:80]
418
- provider = str(provider or "")[:60]
419
- days = max(0, int(days))
420
- min_samples = max(1, int(min_samples))
421
- except (TypeError, ValueError):
422
- days, min_samples = 90, 3
423
- # DATA_DIR 可在测试、多实例或运行时配置切换;纳入缓存键,避免跨目录
424
- # 复用另一套台账的线上指标。
425
- key = (str(paths.DATA_DIR), task_type, role, agent, model, provider,
426
- days, min_samples)
427
- now = time.time()
428
- with LOCK:
429
- cached = _ROUTING_CACHE["data"].get(key)
430
- if cached and now - cached[0] <= _ROUTING_TTL:
431
- return dict(cached[1])
432
- records, quality_records = _routing_records(days)
433
-
434
- def matches(record, filters):
435
- return all(str(record.get(field) or "") == value
436
- for field, value in filters.items() if value)
437
-
438
- # 先保留所有已提供的维度;后续逐层放宽,确保模型和 CLI 都能共享一套查询。
439
- exact = {"task_type": task_type, "role": role, "agent": agent,
440
- "model": model, "provider": provider}
441
- fallbacks = [(exact, "exact")]
442
- task_role = {"task_type": task_type, "role": role}
443
- if task_role != exact:
444
- fallbacks.append((task_role, "task-role"))
445
- if task_type:
446
- fallbacks.append(({"task_type": task_type}, "task"))
447
- fallbacks.append(({}, "global"))
448
- selected, label = [], "global"
449
- for filters, candidate_label in fallbacks:
450
- candidate = [r for r in records if matches(r, filters)]
451
- if len(candidate) >= min_samples or (candidate_label == "global" and candidate):
452
- selected, label = candidate, candidate_label
453
- break
454
- chosen_filters = next((filters for filters, candidate_label in fallbacks
455
- if candidate_label == label), {})
456
- quality_selected = [r for r in quality_records if matches(r, chosen_filters)]
457
- success_rows = quality_selected or selected
458
- success_key = "quality_ok" if quality_selected else "ok"
459
- successes = sum(1 for r in success_rows if bool(r.get(success_key)))
460
- durations = [max(0.0, _parse_float(r.get("duration_s"))) for r in selected]
461
- costs = [max(0.0, _parse_float(r.get("cost_usd"))) for r in selected]
462
- samples = len(selected)
463
- success_samples = len(success_rows)
464
- result = {
465
- "samples": samples,
466
- "quality_samples": len(quality_selected),
467
- "success_samples": success_samples,
468
- "successes": successes,
469
- "success_rate": round((successes + 3.0) / (success_samples + 4.0), 4),
470
- "p50_duration_s": _percentile(durations, 0.50),
471
- "p95_duration_s": _percentile(durations, 0.95),
472
- "avg_cost_usd": round(sum(costs) / samples, 6) if samples else 0.0,
473
- "fallback": label,
474
- }
475
- with LOCK:
476
- _ROUTING_CACHE["data"][key] = (now, dict(result))
477
- return result
478
-
479
-
480
- def _group(records, key):
481
- """按 key 聚合:{name: {calls, ok, tokens, input, output, cached, cache_rate, cost_usd, duration_s}}。"""
482
- groups = {}
483
- for r in records:
484
- name = str(r.get(key) or "") or "unknown"
485
- g = groups.setdefault(name, {"calls": 0, "ok": 0, "tokens": 0,
486
- "input": 0, "output": 0, "cached": 0,
487
- "cost_usd": 0.0, "duration_s": 0.0})
488
- g["calls"] += 1
489
- if r.get("ok"):
490
- g["ok"] += 1
491
- g["tokens"] += _num(r, "total")
492
- g["input"] += _num(r, "input")
493
- g["output"] += _num(r, "output")
494
- g["cached"] += _num(r, "cached")
495
- g["cost_usd"] = round(g["cost_usd"] + float(r.get("cost_usd") or 0.0), 4)
496
- g["duration_s"] = round(g["duration_s"] + float(r.get("duration_s") or 0.0), 1)
497
- # §07 验收指标:缓存命中率(cached / (input + cached)),与 totals.cache_rate 同口径
498
- for g in groups.values():
499
- denom = g["input"] + g["cached"]
500
- g["cache_rate"] = round(g["cached"] * 100.0 / denom, 1) if denom else 0.0
501
- return groups
502
-
503
-
504
- def _model_display_map(records):
505
- """模型名归一映射 {casefold: 展示名}:同一模型不同大小写(GLM-5.3-Flash 与
506
- glm-5.3-flash)会被拆成两行统计,这里把 casefold 相同的归为一组,
507
- 展示名取组内出现次数最多的原始写法(平局取更长写法,信息更全)。"""
508
- canon = {}
509
- for r in records:
510
- name = str(r.get("model") or "") or "unknown"
511
- c = canon.setdefault(name.casefold(), {})
512
- c["total"] = c.get("total", 0) + 1
513
- c["names"] = c.get("names") or {}
514
- c["names"][name] = (c["names"].get(name) or 0) + 1
515
- display = {}
516
- for k, c in canon.items():
517
- display[k] = max(c["names"].items(),
518
- key=lambda kv: (kv[1], len(kv[0])))[0]
519
- return display
520
-
521
-
522
- def _remap_model(records, display):
523
- """把 records 的 model 字段替换为归一后的展示名(不改动原记录)。"""
524
- out = []
525
- for r in records:
526
- name = str(r.get("model") or "") or "unknown"
527
- r2 = dict(r)
528
- r2["model"] = display.get(name.casefold(), name)
529
- out.append(r2)
530
- return out
531
-
532
-
533
- def _dim_rows(records, key, normalize=False):
534
- rows = []
535
- if normalize:
536
- records = _remap_model(records, _model_display_map(records))
537
- for name, g in _group(records, key).items():
538
- rows.append({"key": name, **g})
539
- rows.sort(key=lambda x: -x["tokens"])
540
- return rows
541
-
542
-
543
- def _streaks(dayset):
544
- """连续活跃天数:current=从今天(今天没用量则从昨天)往回的连续天数;
545
- longest=历史上最长连续段。dayset 为 ISO 日期字符串集合。"""
546
- import datetime
547
- ds = set()
548
- for s in dayset:
549
- try:
550
- ds.add(datetime.date.fromisoformat(str(s)))
551
- except (ValueError, TypeError):
552
- continue
553
- if not ds:
554
- return 0, 0
555
- today = datetime.date.today()
556
- anchor = today if today in ds else today - datetime.timedelta(days=1)
557
- current = 0
558
- d = anchor
559
- while d in ds:
560
- current += 1
561
- d -= datetime.timedelta(days=1)
562
- longest = run = 0
563
- prev = None
564
- for d in sorted(ds):
565
- run = run + 1 if (prev is not None and (d - prev).days == 1) else 1
566
- longest = max(longest, run)
567
- prev = d
568
- return current, longest
569
-
570
-
571
- def summary(days=30, recent_limit=30):
572
- """多维聚合。days=0 表示全部历史。by_day 连续补零,方便前端直接画趋势。
573
-
574
- 一次全量扫描后在内存里按范围过滤(_iter_records 本就逐文件全读,
575
- 这样热力图/连续天数所需的全历史数据不再二次扫描):
576
- - all_by_day:全历史按日 tokens(热力图、连续天数);
577
- - by_day_model:范围内按日按模型 tokens(多模型趋势折线,模型名已归一);
578
- - totals 增 peak_tokens / max_duration_s / streak_current / streak_longest。
579
- """
580
- import datetime
581
- with LOCK:
582
- all_records = _iter_records(0)
583
- now = datetime.date.today()
584
- span = int(days) if days else 0
585
- since = (now - datetime.timedelta(days=max(span, 1) - 1)) if span else None
586
- if span:
587
- since_s = since.isoformat()
588
- records = [r for r in all_records if str(r.get("day") or "") >= since_s]
589
- else:
590
- records = all_records
591
-
592
- # 模型名归一:维度行与按日趋势共用同一展示名映射
593
- model_display = _model_display_map(records)
594
- model_records = _remap_model(records, model_display)
595
-
596
- day_groups = _group(records, "day")
597
- by_day = []
598
- if span:
599
- for i in range(span):
600
- d = (now - datetime.timedelta(days=span - 1 - i)).isoformat()
601
- g = day_groups.get(d, {})
602
- by_day.append({"day": d, "calls": g.get("calls", 0),
603
- "ok": g.get("ok", 0),
604
- "tokens": g.get("tokens", 0),
605
- "input": g.get("input", 0), "output": g.get("output", 0),
606
- "cached": g.get("cached", 0),
607
- "cache_rate": g.get("cache_rate", 0.0),
608
- "duration_s": g.get("duration_s", 0.0),
609
- "cost_usd": round(g.get("cost_usd", 0.0), 4)})
610
- else:
611
- for d in sorted(day_groups):
612
- g = day_groups[d]
613
- by_day.append({"day": d, "calls": g["calls"], "ok": g["ok"],
614
- "tokens": g["tokens"],
615
- "input": g["input"], "output": g["output"],
616
- "cached": g.get("cached", 0),
617
- "cache_rate": g.get("cache_rate", 0.0),
618
- "duration_s": g.get("duration_s", 0.0),
619
- "cost_usd": g["cost_usd"]})
620
-
621
- # 按日按模型 tokens(范围内;与 by_day 同一天序列,无数据天给空表)
622
- day_model = {}
623
- for r in model_records:
624
- d = str(r.get("day") or "")
625
- if not d:
626
- continue
627
- m = day_model.setdefault(d, {})
628
- name = str(r.get("model") or "unknown")
629
- m[name] = m.get(name, 0) + _num(r, "total")
630
- by_day_model = [{"day": bd["day"], "models": day_model.get(bd["day"], {})}
631
- for bd in by_day]
632
-
633
- # 全历史按日 tokens(热力图 / 连续天数;不补零,按日期排序)
634
- all_day_groups = _group(all_records, "day")
635
- all_by_day = [{"day": d, "tokens": all_day_groups[d]["tokens"]}
636
- for d in sorted(all_day_groups)]
637
-
638
- totals = {
639
- "calls": len(records),
640
- "ok": sum(1 for r in records if r.get("ok")),
641
- "tokens": sum(_num(r, "total") for r in records),
642
- "input": sum(_num(r, "input") for r in records),
643
- "output": sum(_num(r, "output") for r in records),
644
- "cached": sum(_num(r, "cached") for r in records),
645
- "reasoning": sum(_num(r, "reasoning") for r in records),
646
- "cost_usd": round(sum(float(r.get("cost_usd") or 0.0) for r in records), 4),
647
- "duration_s": round(sum(float(r.get("duration_s") or 0.0) for r in records), 1),
648
- }
649
- totals["failed"] = totals["calls"] - totals["ok"]
650
- totals["days_active"] = len(day_groups)
651
- totals["avg_tokens_per_call"] = int(totals["tokens"] / totals["calls"]) if totals["calls"] else 0
652
- denom = totals["input"] + totals["cached"]
653
- totals["cache_rate"] = round(totals["cached"] * 100.0 / denom, 1) if denom else 0.0
654
- totals["peak_tokens"] = max((bd["tokens"] for bd in by_day), default=0)
655
- totals["max_duration_s"] = round(max((float(r.get("duration_s") or 0.0) for r in records), default=0.0), 1)
656
- # 压缩省量(source=compaction 记录的 saved 合计):与消耗并列的第七维,
657
- # 台账从此能答「省了多少」;无压缩记录时为 0,老前端不读不受影响
658
- totals["compaction_saved"] = sum(_num(r, "saved") for r in records)
659
- # 体验指标:只统计可测记录(缺失与 0 都表示"这条没测到",不参与均值),
660
- # 否则 CLI 路径会把首字延迟平均数拉到 0,读数即假数。
661
- ft_list = sorted(_parse_float(r.get("first_token_ms")) for r in records
662
- if _parse_float(r.get("first_token_ms")) > 0)
663
- tps_list = [_parse_float(r.get("tokens_per_sec")) for r in records
664
- if _parse_float(r.get("tokens_per_sec")) > 0]
665
- totals["perf_samples"] = len(ft_list)
666
- totals["avg_first_token_ms"] = (round(sum(ft_list) / len(ft_list), 1)
667
- if ft_list else 0.0)
668
- totals["p95_first_token_ms"] = _percentile(ft_list, 0.95) if ft_list else 0.0
669
- totals["avg_tokens_per_sec"] = (round(sum(tps_list) / len(tps_list), 2)
670
- if tps_list else 0.0)
671
- cur, lng = _streaks(str(r.get("day") or "") for r in all_records)
672
- totals["streak_current"] = cur
673
- totals["streak_longest"] = lng
674
-
675
- recent = sorted(records, key=lambda r: str(r.get("ts", "")), reverse=True)[:recent_limit]
676
- return {
677
- "generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
678
- "range": {"days": span, "since": since.isoformat() if since else ""},
679
- "totals": totals,
680
- "by_day": by_day,
681
- "by_day_model": by_day_model,
682
- "all_by_day": all_by_day,
683
- "by_tool": _dim_rows(records, "tool"),
684
- "by_agent": _dim_rows(records, "agent"),
685
- "by_model": _dim_rows(records, "model", normalize=True),
686
- "by_role": _dim_rows(records, "role"),
687
- "by_task_type": _dim_rows(records, "task_type"),
688
- "recent": recent,
689
- }
690
-
691
-
692
- THIN_SAMPLES = 10 # 低于此样本数即「薄样本」,口径见执行标准第 6 条
693
-
694
- _DURATION_BASELINES = {
695
- "direct": 90, "rank_scan": 240, "email": 180, "weekly_report": 240,
696
- "translation": 240, "speech": 360, "video_script": 360, "article": 480,
697
- "doc": 480, "resume": 420, "code": 600, "tech_proposal": 720,
698
- "research": 900, "novel": 720, "serial_novel": 2400,
699
- }
700
-
701
-
702
- def estimate(task_type="", days=90, mode="auto", thinking="standard", rounds=None):
703
- """同类任务开跑前成本与耗时预估(数据源为本地用量台账)。
704
-
705
- 同一 run 的多条步骤记录加总为一个样本,优先用成功 run,给中位数/平均/P90。
706
- 无历史样本时使用流程级保守基线;模式与思考程度只做透明倍率修正。"""
707
- # days<=0(UI 的「全部」档)不能退化成「只扫今天」:基线会跟着窗口一起坍缩,
708
- # 于是全历史被估成 480 秒常数。按保守年限扫全量。
709
- span = 3650 if days is not None and int(days) <= 0 else max(1, min(3650, int(days or 90)))
710
- with LOCK:
711
- records = _iter_records(span)
712
- runs = {}
713
- for r in records:
714
- tt = str(r.get("task_type") or "unknown")
715
- if task_type and tt != task_type:
716
- continue
717
- rid = str(r.get("run_id") or "")
718
- if not rid:
719
- continue
720
- g = runs.setdefault(rid, {"tokens": 0, "cost": 0.0,
721
- "duration": 0.0, "ok": False})
722
- g["tokens"] += _num(r, "total")
723
- g["cost"] += _parse_float(r.get("cost_usd"))
724
- g["duration"] += max(0.0, _parse_float(r.get("duration_s")))
725
- if r.get("ok"):
726
- g["ok"] = True
727
- ok_runs = [g for g in runs.values() if g["ok"]]
728
- basis = ok_runs or list(runs.values())
729
- mode_factor = {"fast": 0.60, "auto": 1.0, "expert": 1.65,
730
- "manual": 1.0}.get(str(mode or "auto"), 1.0)
731
- thinking_factor = {"low": 0.78, "standard": 1.0, "high": 1.45,
732
- "auto": 1.0}.get(str(thinking or "standard"), 1.0)
733
- factor = mode_factor * thinking_factor
734
- try:
735
- if rounds is not None and int(rounds) > 2:
736
- factor *= 1.0 + min(3, int(rounds) - 2) * 0.18
737
- except (TypeError, ValueError):
738
- pass
739
- if not basis:
740
- # 全历史里一条同类样本都没有(article/doc 等低频类型)时才用基线,
741
- # 并在 duration_source 上如实标 baseline,UI 必须点名它不是实测。
742
- baseline = _DURATION_BASELINES.get(task_type, 480)
743
- median_s = max(30, int(baseline * factor))
744
- return {"task_type": task_type or "", "days": span, "samples": 0,
745
- "estimated_duration_s": median_s,
746
- "p90_duration_s": max(median_s + 30, int(median_s * 1.7)),
747
- # 标准「成本与时延预算」第 6 条:样本 <10 即薄样本,UI 必须点名,
748
- # 不能把"仅此一例"的历史当实测承诺。
749
- "thin": True, "min_samples_for_estimate": THIN_SAMPLES,
750
- "duration_source": "baseline"}
751
- toks = sorted(g["tokens"] for g in basis)
752
- costs = sorted(g["cost"] for g in basis)
753
- durations = sorted(g["duration"] for g in basis if g["duration"] > 0)
754
- n = len(toks)
755
- mid = n // 2
756
- med = toks[mid] if n % 2 else (toks[mid - 1] + toks[mid]) / 2.0
757
- med_c = costs[mid] if n % 2 else (costs[mid - 1] + costs[mid]) / 2.0
758
- import datetime
759
- import math
760
- if durations:
761
- dn = len(durations)
762
- dmid = dn // 2
763
- dmed = (durations[dmid] if dn % 2
764
- else (durations[dmid - 1] + durations[dmid]) / 2.0)
765
- dp90 = durations[max(0, min(dn - 1, math.ceil(0.9 * dn) - 1))]
766
- duration_source = "history"
767
- else:
768
- dmed = _DURATION_BASELINES.get(task_type, 480)
769
- dp90 = dmed * 1.7
770
- duration_source = "baseline"
771
- return {
772
- "task_type": task_type or "",
773
- "days": span,
774
- "samples": n,
775
- "ok_samples": len(ok_runs),
776
- "avg_tokens": int(sum(toks) / n),
777
- "median_tokens": int(med),
778
- "p90_tokens": toks[max(0, min(n - 1, math.ceil(0.9 * n) - 1))],
779
- "avg_cost_usd": round(sum(costs) / n, 4),
780
- "median_cost_usd": round(med_c, 4),
781
- "estimated_duration_s": max(30, int(dmed * factor)),
782
- "p90_duration_s": max(60, int(dp90 * factor)),
783
- "duration_samples": len(durations),
784
- "thin": n < THIN_SAMPLES, "min_samples_for_estimate": THIN_SAMPLES,
785
- "duration_source": duration_source,
786
- "since": (datetime.date.today() - datetime.timedelta(days=span - 1)).isoformat(),
787
- }
788
-
789
-
790
- def _audit_file(day):
791
- return paths.USAGE_DIR / ("estimate-audit-%s.jsonl" % day[:7].replace("-", ""))
792
-
793
-
794
- _AUDITED = {} # run_id -> 落盘路径;幂等去重,上限后整体清空防无界增长
795
- _AUDIT_LIMIT = 4000
796
- TERMINAL_STATUSES = ("done", "failed", "cancelled", "timeout")
797
-
798
-
799
- def audit_run(run):
800
- """事后对账:开跑前的预估快照 vs 本次真实用量,一条 run 一行。
801
-
802
- 预估值只取 run 上的快照(store.create_run 落的 estimated_duration_s 等),
803
- 绝不回读当前 estimate()——那会把本次刚写入的消耗算进"预估",比值永远偏低。
804
- 快照缺失(回填前的老 run、非编排类)时如实标 basis=none,不猜一个数充数。
805
- """
806
- try:
807
- run_id = str((run or {}).get("id") or "")
808
- if not run_id or run_id in _AUDITED:
809
- return None
810
- status = str(run.get("status") or "")
811
- if status not in TERMINAL_STATUSES:
812
- return None
813
- task_type = str(run.get("task_type") or (run.get("op") or {}).get("type") or "")
814
- est_s = _parse_float(run.get("estimated_duration_s"))
815
- est_p90 = _parse_float(run.get("estimated_p90_s"))
816
- basis = "stored"
817
- if est_s <= 0:
818
- # 快照缺失时退而用同类型历史口径,但必须落 basis=recomputed 标出来源
819
- snap = estimate(task_type=task_type, days=(run.get("estimate_days") or 90))
820
- est_s = _parse_float(snap.get("estimated_duration_s"))
821
- est_p90 = _parse_float(snap.get("p90_duration_s"))
822
- basis = "recomputed" if est_s > 0 else "none"
823
- actual_s = 0.0
824
- actual_tokens = 0
825
- for r in _iter_records(366):
826
- if str(r.get("run_id") or "") != run_id:
827
- continue
828
- actual_s += max(0.0, _parse_float(r.get("duration_s")))
829
- actual_tokens += _parse_int(r.get("total"))
830
- rec = {
831
- "ts": time.strftime("%Y-%m-%d %H:%M:%S"),
832
- "day": time.strftime("%Y-%m-%d"),
833
- "run_id": run_id[:64],
834
- "task_id": str(run.get("task_id") or "")[:64],
835
- "task_type": task_type[:32] or "unknown",
836
- "status": status[:16],
837
- "est_duration_s": round(est_s, 1),
838
- "est_p90_s": round(est_p90, 1),
839
- "est_source": str(run.get("estimate_source") or "")[:16],
840
- "est_samples": _parse_int(run.get("estimate_samples")),
841
- "est_basis": basis[:16],
842
- "actual_duration_s": round(actual_s, 1),
843
- "actual_tokens": actual_tokens,
844
- "ratio": round(actual_s / est_s, 3) if est_s > 0 else 0.0,
845
- "over_p90": bool(est_p90 > 0 and actual_s > est_p90),
846
- }
847
- line = json.dumps(rec, ensure_ascii=False)
848
- path = _audit_file(rec["day"])
849
- with LOCK:
850
- path.parent.mkdir(parents=True, exist_ok=True)
851
- with open(path, "a", encoding="utf-8") as f:
852
- f.write(line + "\n")
853
- if len(_AUDITED) >= _AUDIT_LIMIT:
854
- _AUDITED.clear()
855
- _AUDITED[run_id] = str(path)
856
- return rec
857
- except Exception:
858
- return None
1
+ # -*- coding: utf-8 -*-
2
+ """用量台账:每次真实 LLM 调用(CLI 智能体 / 编排者直连 API)追加一条记录,
3
+ 支持按天/工具(CLI)/智能体/模型/角色/任务类型多维聚合——用量统计页的数据源。
4
+
5
+ 落盘:data/usage/usage-YYYYMM.jsonl(按月分文件,append-only,一行一记录)。
6
+ 并发安全:全局锁 + 追加写;读侧每次全量扫描再聚合(调用频次为分钟级,
7
+ 文件规模可控;聚合在请求线程内完成,不引入后台任务)。
8
+
9
+ 记一条的入口是 record():字段残缺不抛错(埋点失败绝不能影响任务执行)。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import math
15
+ import threading
16
+ import time
17
+
18
+ from . import paths
19
+
20
+ LOCK = threading.RLock()
21
+
22
+ # 维度 → 显示名(聚合接口与前端共用)
23
+ DIMENSIONS = {
24
+ "tool": "工具(CLI)",
25
+ "agent": "智能体",
26
+ "model": "模型",
27
+ "role": "步骤角色",
28
+ "task_type": "任务类型",
29
+ }
30
+
31
+ FIELDS = ("ts", "day", "run_id", "step", "task_id", "task_type", "role", "agent", "agent_label",
32
+ "tool", "model", "provider", "ok", "duration_s",
33
+ "input", "output", "cached", "reasoning", "total", "cost_usd", "source",
34
+ "first_token_ms", "tokens_per_sec")
35
+
36
+
37
+ def _month_file(day):
38
+ return paths.USAGE_DIR / ("usage-%s.jsonl" % day[:7].replace("-", ""))
39
+
40
+
41
+ def _quality_file(day):
42
+ return paths.USAGE_DIR / ("routing-quality-%s.jsonl" % day[:7].replace("-", ""))
43
+
44
+
45
+ def _parse_int(v):
46
+ try:
47
+ return int(v or 0)
48
+ except (TypeError, ValueError):
49
+ return 0
50
+
51
+
52
+ def _parse_float(v):
53
+ try:
54
+ return float(v or 0.0)
55
+ except (TypeError, ValueError):
56
+ return 0.0
57
+
58
+
59
+ def _parse_bool(v):
60
+ if v is None:
61
+ return False
62
+ return bool(v)
63
+
64
+
65
+ def record(source="", run_id="", task_id="", task_type="", role="", step=0,
66
+ agent="", agent_label="", tool="", model="", provider="",
67
+ ok=True, duration_s=0.0, cost_usd=0.0, usage=None,
68
+ first_token_ms=0.0, tokens_per_sec=0.0):
69
+ """追加一条用量记录。usage 为细分 dict:{input, output, cached, reasoning, total};
70
+ 缺省字段按 0 处理。任何异常都吞掉——统计永远不能拖垮业务调用方。
71
+
72
+ step 为该运行内的步骤号:与 backfill_from_runs 的去重键一致,缺了会导致
73
+ 启动回填把同一步骤重复入账。
74
+ """
75
+ try:
76
+ u = usage or {}
77
+ rec = {
78
+ "ts": time.strftime("%Y-%m-%d %H:%M:%S"),
79
+ "day": time.strftime("%Y-%m-%d"),
80
+ "source": str(source or "pipeline")[:24],
81
+ "run_id": str(run_id or "")[:64],
82
+ "step": _parse_int(step),
83
+ "task_id": str(task_id or "")[:64],
84
+ "task_type": str(task_type or "")[:32] or "unknown",
85
+ "role": str(role or "")[:40] or "unknown",
86
+ "agent": str(agent or "")[:40] or "unknown",
87
+ "agent_label": str(agent_label or "")[:60],
88
+ "tool": str(tool or "")[:24] or "unknown",
89
+ "model": str(model or "")[:80] or "(默认)",
90
+ "provider": str(provider or "")[:60],
91
+ "ok": _parse_bool(ok),
92
+ "duration_s": round(_parse_float(duration_s), 1),
93
+ "cost_usd": round(_parse_float(cost_usd), 4),
94
+ }
95
+ inp = max(0, _parse_int(u.get("input")))
96
+ out = max(0, _parse_int(u.get("output")))
97
+ cach = max(0, _parse_int(u.get("cached")))
98
+ reas = max(0, _parse_int(u.get("reasoning")))
99
+ total = _parse_int(u.get("total")) or (inp + out + cach)
100
+ rec.update({"input": inp, "output": out, "cached": cach,
101
+ "reasoning": reas, "total": total})
102
+ # 压缩省量注记(source=compaction 专用):上下文折叠净省的估算 token,
103
+ # 与消耗并列成账——省了多少不再是黑箱。零值不落字段防老记录膨胀。
104
+ saved = max(0, _parse_int(u.get("saved")))
105
+ if saved:
106
+ rec["saved"] = saved
107
+ # 体验指标(§07):只有内置直连的流式路径可测,CLI 事件流没有逐 token
108
+ # 时刻。零值不落字段——"缺字段"表示不可测,不能读成"首字 0 毫秒"。
109
+ ft = round(_parse_float(first_token_ms), 1)
110
+ if ft > 0:
111
+ rec["first_token_ms"] = ft
112
+ tps = round(_parse_float(tokens_per_sec), 2)
113
+ if tps > 0:
114
+ rec["tokens_per_sec"] = tps
115
+ line = json.dumps(rec, ensure_ascii=False)
116
+ with LOCK:
117
+ paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
118
+ with open(_month_file(rec["day"]), "a", encoding="utf-8") as f:
119
+ f.write(line + "\n")
120
+ _ROUTING_CACHE["data"].clear()
121
+ _ROUTING_RECORDS_CACHE.clear()
122
+ _HOURLY_CACHE["ts"] = 0.0
123
+ except Exception:
124
+ pass
125
+
126
+
127
+ def backfill_from_runs():
128
+ """把历史 run.json 里已记录的 token 用量回填进台账(启动时调用,幂等)。
129
+
130
+ 埋点是后加的:此前的运行只在 run.json 的步骤里存了 tokens/cost_usd 总量,
131
+ 没有输入/输出/缓存细分,所以回填记录 input/output/cached 记 0、只有 total,
132
+ 并以 source="backfill" 标记便于区分。已回填的 (run_id, 步骤号) 会跳过,
133
+ 重复启动不会产生重复记录。
134
+ """
135
+ try:
136
+ runs_dir = paths.RUNS_DIR
137
+ if not runs_dir.is_dir():
138
+ return 0
139
+ # 已入账的 (run_id, role, 步骤号) 集合——含真实埋点与既往回填
140
+ seen = set()
141
+ for r in _iter_records(0):
142
+ seen.add((str(r.get("run_id") or ""), str(r.get("role") or ""),
143
+ _parse_int(r.get("step"))))
144
+ added = 0
145
+ # 旧 run 未存任务类型,从 tasks/*.json 补齐(缺了就记 unknown)
146
+ task_types = {}
147
+ try:
148
+ for tp in paths.TASKS_DIR.glob("*.json"):
149
+ try:
150
+ t = json.loads(tp.read_text(encoding="utf-8"))
151
+ task_types[t.get("id")] = t.get("type") or ""
152
+ except Exception:
153
+ continue
154
+ except Exception:
155
+ pass
156
+ for p in sorted(runs_dir.glob("*/run.json")):
157
+ try:
158
+ run = json.loads(p.read_text(encoding="utf-8"))
159
+ except Exception:
160
+ continue
161
+ if not isinstance(run, dict):
162
+ continue
163
+ run_id = str(run.get("id") or p.parent.name)
164
+ day = str(run.get("started_at") or run.get("created_at") or "")[:10]
165
+ if not day:
166
+ continue
167
+ for s in (run.get("steps") or []):
168
+ if not isinstance(s, dict):
169
+ continue
170
+ total = _parse_int(s.get("tokens"))
171
+ if total <= 0:
172
+ continue # 无 token 的步骤(验证/合并/mock)不入账
173
+ n = _parse_int(s.get("n"))
174
+ key = (run_id, str(s.get("role") or ""), n)
175
+ if key in seen:
176
+ continue
177
+ seen.add(key)
178
+ rec = {
179
+ "ts": "%s %s" % (day, str(s.get("started_at") or "00:00:00")[:8]),
180
+ "day": day, "source": "backfill", "run_id": run_id,
181
+ "step": n,
182
+ "task_id": str(run.get("task_id") or ""),
183
+ "task_type": task_types.get(str(run.get("task_id") or "")) or "unknown",
184
+ "role": str(s.get("role") or "")[:40] or "unknown",
185
+ "agent": str(s.get("agent") or "")[:40] or "unknown",
186
+ "agent_label": str(s.get("agent_label") or "")[:60],
187
+ "tool": _tool_of(str(s.get("agent") or "")),
188
+ "model": str(s.get("model") or "")[:80] or "(历史未记录)",
189
+ "provider": "",
190
+ "ok": str(s.get("status") or "") == "done",
191
+ "duration_s": round(_parse_float(s.get("duration_s")), 1),
192
+ "cost_usd": round(_parse_float(s.get("cost_usd")), 4),
193
+ "input": 0, "output": 0, "cached": 0, "reasoning": 0,
194
+ "total": total,
195
+ }
196
+ with LOCK:
197
+ paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
198
+ with open(_month_file(day), "a", encoding="utf-8") as f:
199
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
200
+ added += 1
201
+ return added
202
+ except Exception:
203
+ return 0
204
+
205
+
206
+ # 智能体 id → 工具 kind(回填时旧记录没有 tool 字段,只能从 id 推断)
207
+ _AGENT_TOOL = {"codex-cli": "codex", "claude-code": "claude", "qwen-cli": "qwen",
208
+ "qwencode": "qwen", "opencode": "opencode", "aider": "aider",
209
+ "orchestrator": "orchestrator"}
210
+
211
+
212
+ def _tool_of(agent_id):
213
+ a = str(agent_id or "").lower()
214
+ if a in _AGENT_TOOL:
215
+ return _AGENT_TOOL[a]
216
+ for k, v in _AGENT_TOOL.items():
217
+ if a.startswith(k):
218
+ return v
219
+ return a or "unknown"
220
+
221
+
222
+ _HOURLY_CACHE = {"ts": 0.0, "val": {}}
223
+ _HOURLY_TTL = 60.0 # 秒:路由调用频繁但台账追加低频,60s 缓存足够新鲜
224
+ _ROUTING_CACHE = {"data": {}}
225
+ _ROUTING_RECORDS_CACHE = {}
226
+ _ROUTING_TTL = 15.0
227
+
228
+
229
+ def agent_tokens_recent(agent, hours=1):
230
+ """该智能体近 N 小时的 token 总量(路由配额惩罚用)。按 ts 前缀过滤,
231
+ 60 秒 TTL 进程内缓存——运行中每次路由都查也只扫两天的台账。"""
232
+ try:
233
+ agent = str(agent or "")
234
+ now = time.time()
235
+ if now - _HOURLY_CACHE["ts"] > _HOURLY_TTL:
236
+ bound = time.strftime("%Y-%m-%d %H:%M:%S",
237
+ time.localtime(now - hours * 3600.0))
238
+ total = {}
239
+ for r in _iter_records(2):
240
+ if str(r.get("ts") or "") < bound:
241
+ continue
242
+ a = str(r.get("agent") or "")
243
+ # record() 将规范化后的 token 总数落在顶层 ``total``;旧实现
244
+ # 误读不存在的嵌套 usage 字段,导致配额路由永远认为本小时
245
+ # 用量为 0,超过供应商额度也不会降权。
246
+ total[a] = total.get(a, 0) + max(0, _parse_int(r.get("total")))
247
+ _HOURLY_CACHE.update(ts=now, val=total)
248
+ return int(_HOURLY_CACHE["val"].get(agent) or 0)
249
+ except Exception:
250
+ return 0
251
+
252
+
253
+ def _iter_records(days):
254
+ """按时间范围读取台账(days=0 表示全部)。返回按写入顺序的记录列表。"""
255
+ out = []
256
+ try:
257
+ files = sorted(paths.USAGE_DIR.glob("usage-*.jsonl")) if paths.USAGE_DIR.is_dir() else []
258
+ except Exception:
259
+ return out
260
+ since_day = ""
261
+ if days:
262
+ import datetime
263
+ d = (datetime.date.today() - datetime.timedelta(days=int(days) - 1)).isoformat()
264
+ since_day = d
265
+ since_month = since_day[:7].replace("-", "") if since_day else ""
266
+ for p in files:
267
+ if since_month and p.stem.rsplit("-", 1)[-1] < since_month:
268
+ continue
269
+ try:
270
+ for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
271
+ line = line.strip()
272
+ if not line.startswith("{"):
273
+ continue
274
+ try:
275
+ r = json.loads(line)
276
+ except Exception:
277
+ continue
278
+ if not isinstance(r, dict):
279
+ continue
280
+ if since_day and str(r.get("day", "")) < since_day:
281
+ continue
282
+ out.append(r)
283
+ except Exception:
284
+ continue
285
+ return out
286
+
287
+
288
+ def _iter_quality_records(days):
289
+ """读取独立质量台账;不混入用量统计的调用数、token 和成本。"""
290
+ out = []
291
+ try:
292
+ files = sorted(paths.USAGE_DIR.glob("routing-quality-*.jsonl")) \
293
+ if paths.USAGE_DIR.is_dir() else []
294
+ except Exception:
295
+ return out
296
+ since_day = ""
297
+ if days:
298
+ import datetime
299
+ since_day = (datetime.date.today()
300
+ - datetime.timedelta(days=int(days) - 1)).isoformat()
301
+ since_month = since_day[:7].replace("-", "") if since_day else ""
302
+ for path in files:
303
+ if since_month and path.stem.rsplit("-", 1)[-1] < since_month:
304
+ continue
305
+ try:
306
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
307
+ try:
308
+ row = json.loads(line)
309
+ except Exception:
310
+ continue
311
+ if isinstance(row, dict) and (not since_day or row.get("day", "") >= since_day):
312
+ out.append(row)
313
+ except Exception:
314
+ continue
315
+ return out
316
+
317
+
318
+ def _routing_records(days):
319
+ """同一轮候选评分共享一次台账解析,避免每个候选重复扫全盘。"""
320
+ key = (str(paths.USAGE_DIR), int(days))
321
+ now = time.time()
322
+ with LOCK:
323
+ cached = _ROUTING_RECORDS_CACHE.get(key)
324
+ if cached and now - cached[0] <= _ROUTING_TTL:
325
+ return list(cached[1]), list(cached[2])
326
+ calls, quality = _iter_records(days), _iter_quality_records(days)
327
+ with LOCK:
328
+ _ROUTING_RECORDS_CACHE[key] = (now, list(calls), list(quality))
329
+ return calls, quality
330
+
331
+
332
+ def record_quality_for_run(run_id, quality_ok, agent=""):
333
+ """把验收结论归因到最终实际产出者,不重复计入用量。"""
334
+ try:
335
+ run_id = str(run_id or "")[:64]
336
+ if not run_id:
337
+ return 0
338
+ calls = [r for r in _iter_records(2)
339
+ if r.get("run_id") == run_id and r.get("ok") is True]
340
+ prefixes = ("implement", "fix", "draft", "revise", "polish", "direct", "author")
341
+ calls = [r for r in calls if str(r.get("role") or "").lower().startswith(prefixes)]
342
+ agent = str(agent or "")[:40]
343
+ unique = {}
344
+ for row in calls:
345
+ key = tuple(str(row.get(k) or "") for k in
346
+ ("task_id", "task_type", "role", "agent", "model", "provider"))
347
+ unique[key] = row
348
+ if not unique:
349
+ return 0
350
+ existing = {(r.get("run_id"), r.get("role"), r.get("agent"),
351
+ r.get("model"), r.get("provider"))
352
+ for r in _iter_quality_records(2)}
353
+ day = time.strftime("%Y-%m-%d")
354
+ rows = []
355
+ for row in unique.values():
356
+ identity = (run_id, row.get("role"), row.get("agent"),
357
+ row.get("model"), row.get("provider"))
358
+ if identity in existing:
359
+ continue
360
+ # 成功调用不等于产出通过。换将前的实现者即使传输成功,也已被质量
361
+ # 门淘汰,必须留下负样本;最终实际产出者才继承本次验收结论。
362
+ row_quality_ok = bool(quality_ok)
363
+ if agent and str(row.get("agent") or "") != agent:
364
+ row_quality_ok = False
365
+ rows.append({"ts": time.strftime("%Y-%m-%d %H:%M:%S"), "day": day,
366
+ "run_id": run_id, "task_id": row.get("task_id") or "",
367
+ "task_type": row.get("task_type") or "unknown",
368
+ "role": row.get("role") or "unknown",
369
+ "agent": row.get("agent") or "unknown",
370
+ "model": row.get("model") or "(默认)",
371
+ "provider": row.get("provider") or "",
372
+ "quality_ok": row_quality_ok})
373
+ if not rows:
374
+ return 0
375
+ with LOCK:
376
+ paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
377
+ with open(_quality_file(day), "a", encoding="utf-8") as handle:
378
+ for row in rows:
379
+ handle.write(json.dumps(row, ensure_ascii=False) + "\n")
380
+ _ROUTING_CACHE["data"].clear()
381
+ _ROUTING_RECORDS_CACHE.clear()
382
+ return len(rows)
383
+ except Exception:
384
+ return 0
385
+
386
+
387
+ def _num(r, key):
388
+ return _parse_int(r.get(key))
389
+
390
+
391
+ def _percentile(values, percentile):
392
+ """线性插值百分位,空样本返回 0。"""
393
+ if not values:
394
+ return 0.0
395
+ ordered = sorted(float(v) for v in values)
396
+ if len(ordered) == 1:
397
+ return round(ordered[0], 2)
398
+ pos = (len(ordered) - 1) * float(percentile)
399
+ lo = int(math.floor(pos))
400
+ hi = int(math.ceil(pos))
401
+ if lo == hi:
402
+ return round(ordered[lo], 2)
403
+ return round(ordered[lo] + (ordered[hi] - ordered[lo]) * (pos - lo), 2)
404
+
405
+
406
+ def routing_stats(task_type="", role="", agent="", model="", provider="",
407
+ days=90, min_samples=3):
408
+ """返回路由用的近期真实运行指标。
409
+
410
+ 只读取脱敏用量台账;精确维度样本不足时依次回退到任务+角色、任务、
411
+ 全局,成功率使用 Beta(3, 1) 平滑,避免单次失败永久压低新候选。
412
+ """
413
+ try:
414
+ task_type = str(task_type or "")[:32]
415
+ role = str(role or "")[:40]
416
+ agent = str(agent or "")[:40]
417
+ model = str(model or "")[:80]
418
+ provider = str(provider or "")[:60]
419
+ days = max(0, int(days))
420
+ min_samples = max(1, int(min_samples))
421
+ except (TypeError, ValueError):
422
+ days, min_samples = 90, 3
423
+ # DATA_DIR 可在测试、多实例或运行时配置切换;纳入缓存键,避免跨目录
424
+ # 复用另一套台账的线上指标。
425
+ key = (str(paths.DATA_DIR), task_type, role, agent, model, provider,
426
+ days, min_samples)
427
+ now = time.time()
428
+ with LOCK:
429
+ cached = _ROUTING_CACHE["data"].get(key)
430
+ if cached and now - cached[0] <= _ROUTING_TTL:
431
+ return dict(cached[1])
432
+ records, quality_records = _routing_records(days)
433
+
434
+ def matches(record, filters):
435
+ return all(str(record.get(field) or "") == value
436
+ for field, value in filters.items() if value)
437
+
438
+ # 先保留所有已提供的维度;后续逐层放宽,确保模型和 CLI 都能共享一套查询。
439
+ exact = {"task_type": task_type, "role": role, "agent": agent,
440
+ "model": model, "provider": provider}
441
+ fallbacks = [(exact, "exact")]
442
+ task_role = {"task_type": task_type, "role": role}
443
+ if task_role != exact:
444
+ fallbacks.append((task_role, "task-role"))
445
+ if task_type:
446
+ fallbacks.append(({"task_type": task_type}, "task"))
447
+ fallbacks.append(({}, "global"))
448
+ selected, label = [], "global"
449
+ for filters, candidate_label in fallbacks:
450
+ candidate = [r for r in records if matches(r, filters)]
451
+ if len(candidate) >= min_samples or (candidate_label == "global" and candidate):
452
+ selected, label = candidate, candidate_label
453
+ break
454
+ chosen_filters = next((filters for filters, candidate_label in fallbacks
455
+ if candidate_label == label), {})
456
+ quality_selected = [r for r in quality_records if matches(r, chosen_filters)]
457
+ success_rows = quality_selected or selected
458
+ success_key = "quality_ok" if quality_selected else "ok"
459
+ successes = sum(1 for r in success_rows if bool(r.get(success_key)))
460
+ durations = [max(0.0, _parse_float(r.get("duration_s"))) for r in selected]
461
+ costs = [max(0.0, _parse_float(r.get("cost_usd"))) for r in selected]
462
+ samples = len(selected)
463
+ success_samples = len(success_rows)
464
+ result = {
465
+ "samples": samples,
466
+ "quality_samples": len(quality_selected),
467
+ "success_samples": success_samples,
468
+ "successes": successes,
469
+ "success_rate": round((successes + 3.0) / (success_samples + 4.0), 4),
470
+ "p50_duration_s": _percentile(durations, 0.50),
471
+ "p95_duration_s": _percentile(durations, 0.95),
472
+ "avg_cost_usd": round(sum(costs) / samples, 6) if samples else 0.0,
473
+ "fallback": label,
474
+ }
475
+ with LOCK:
476
+ _ROUTING_CACHE["data"][key] = (now, dict(result))
477
+ return result
478
+
479
+
480
+ def _group(records, key):
481
+ """按 key 聚合:{name: {calls, ok, tokens, input, output, cached, cache_rate, cost_usd, duration_s, saved}}。"""
482
+ groups = {}
483
+ for r in records:
484
+ name = str(r.get(key) or "") or "unknown"
485
+ g = groups.setdefault(name, {"calls": 0, "ok": 0, "tokens": 0,
486
+ "input": 0, "output": 0, "cached": 0,
487
+ "cost_usd": 0.0, "duration_s": 0.0,
488
+ "saved": 0})
489
+ g["calls"] += 1
490
+ if r.get("ok"):
491
+ g["ok"] += 1
492
+ g["tokens"] += _num(r, "total")
493
+ g["input"] += _num(r, "input")
494
+ g["output"] += _num(r, "output")
495
+ g["cached"] += _num(r, "cached")
496
+ g["saved"] += _num(r, "saved")
497
+ g["cost_usd"] = round(g["cost_usd"] + float(r.get("cost_usd") or 0.0), 4)
498
+ g["duration_s"] = round(g["duration_s"] + float(r.get("duration_s") or 0.0), 1)
499
+ # §07 验收指标:缓存命中率(cached / (input + cached)),与 totals.cache_rate 同口径
500
+ for g in groups.values():
501
+ denom = g["input"] + g["cached"]
502
+ g["cache_rate"] = round(g["cached"] * 100.0 / denom, 1) if denom else 0.0
503
+ return groups
504
+
505
+
506
+ def _model_display_map(records):
507
+ """模型名归一映射 {casefold: 展示名}:同一模型不同大小写(GLM-5.3-Flash 与
508
+ glm-5.3-flash)会被拆成两行统计,这里把 casefold 相同的归为一组,
509
+ 展示名取组内出现次数最多的原始写法(平局取更长写法,信息更全)。"""
510
+ canon = {}
511
+ for r in records:
512
+ name = str(r.get("model") or "") or "unknown"
513
+ c = canon.setdefault(name.casefold(), {})
514
+ c["total"] = c.get("total", 0) + 1
515
+ c["names"] = c.get("names") or {}
516
+ c["names"][name] = (c["names"].get(name) or 0) + 1
517
+ display = {}
518
+ for k, c in canon.items():
519
+ display[k] = max(c["names"].items(),
520
+ key=lambda kv: (kv[1], len(kv[0])))[0]
521
+ return display
522
+
523
+
524
+ def _remap_model(records, display):
525
+ """把 records 的 model 字段替换为归一后的展示名(不改动原记录)。"""
526
+ out = []
527
+ for r in records:
528
+ name = str(r.get("model") or "") or "unknown"
529
+ r2 = dict(r)
530
+ r2["model"] = display.get(name.casefold(), name)
531
+ out.append(r2)
532
+ return out
533
+
534
+
535
+ def _dim_rows(records, key, normalize=False):
536
+ rows = []
537
+ if normalize:
538
+ records = _remap_model(records, _model_display_map(records))
539
+ for name, g in _group(records, key).items():
540
+ rows.append({"key": name, **g})
541
+ rows.sort(key=lambda x: -x["tokens"])
542
+ return rows
543
+
544
+
545
+ def _streaks(dayset):
546
+ """连续活跃天数:current=从今天(今天没用量则从昨天)往回的连续天数;
547
+ longest=历史上最长连续段。dayset 为 ISO 日期字符串集合。"""
548
+ import datetime
549
+ ds = set()
550
+ for s in dayset:
551
+ try:
552
+ ds.add(datetime.date.fromisoformat(str(s)))
553
+ except (ValueError, TypeError):
554
+ continue
555
+ if not ds:
556
+ return 0, 0
557
+ today = datetime.date.today()
558
+ anchor = today if today in ds else today - datetime.timedelta(days=1)
559
+ current = 0
560
+ d = anchor
561
+ while d in ds:
562
+ current += 1
563
+ d -= datetime.timedelta(days=1)
564
+ longest = run = 0
565
+ prev = None
566
+ for d in sorted(ds):
567
+ run = run + 1 if (prev is not None and (d - prev).days == 1) else 1
568
+ longest = max(longest, run)
569
+ prev = d
570
+ return current, longest
571
+
572
+
573
+ def summary(days=30, recent_limit=30):
574
+ """多维聚合。days=0 表示全部历史。by_day 连续补零,方便前端直接画趋势。
575
+
576
+ 一次全量扫描后在内存里按范围过滤(_iter_records 本就逐文件全读,
577
+ 这样热力图/连续天数所需的全历史数据不再二次扫描):
578
+ - all_by_day:全历史按日 tokens(热力图、连续天数);
579
+ - by_day_model:范围内按日按模型 tokens(多模型趋势折线,模型名已归一);
580
+ - totals 增 peak_tokens / max_duration_s / streak_current / streak_longest。
581
+ """
582
+ import datetime
583
+ with LOCK:
584
+ all_records = _iter_records(0)
585
+ now = datetime.date.today()
586
+ span = int(days) if days else 0
587
+ since = (now - datetime.timedelta(days=max(span, 1) - 1)) if span else None
588
+ if span:
589
+ since_s = since.isoformat()
590
+ records = [r for r in all_records if str(r.get("day") or "") >= since_s]
591
+ else:
592
+ records = all_records
593
+
594
+ # 模型名归一:维度行与按日趋势共用同一展示名映射
595
+ model_display = _model_display_map(records)
596
+ model_records = _remap_model(records, model_display)
597
+
598
+ day_groups = _group(records, "day")
599
+ by_day = []
600
+ if span:
601
+ for i in range(span):
602
+ d = (now - datetime.timedelta(days=span - 1 - i)).isoformat()
603
+ g = day_groups.get(d, {})
604
+ by_day.append({"day": d, "calls": g.get("calls", 0),
605
+ "ok": g.get("ok", 0),
606
+ "tokens": g.get("tokens", 0),
607
+ "input": g.get("input", 0), "output": g.get("output", 0),
608
+ "cached": g.get("cached", 0),
609
+ "cache_rate": g.get("cache_rate", 0.0),
610
+ "duration_s": g.get("duration_s", 0.0),
611
+ "saved": g.get("saved", 0),
612
+ "cost_usd": round(g.get("cost_usd", 0.0), 4)})
613
+ else:
614
+ for d in sorted(day_groups):
615
+ g = day_groups[d]
616
+ by_day.append({"day": d, "calls": g["calls"], "ok": g["ok"],
617
+ "tokens": g["tokens"],
618
+ "input": g["input"], "output": g["output"],
619
+ "cached": g.get("cached", 0),
620
+ "cache_rate": g.get("cache_rate", 0.0),
621
+ "duration_s": g.get("duration_s", 0.0),
622
+ "saved": g.get("saved", 0),
623
+ "cost_usd": g["cost_usd"]})
624
+
625
+ # 按日按模型 tokens(范围内;与 by_day 同一天序列,无数据天给空表)
626
+ day_model = {}
627
+ for r in model_records:
628
+ d = str(r.get("day") or "")
629
+ if not d:
630
+ continue
631
+ m = day_model.setdefault(d, {})
632
+ name = str(r.get("model") or "unknown")
633
+ m[name] = m.get(name, 0) + _num(r, "total")
634
+ by_day_model = [{"day": bd["day"], "models": day_model.get(bd["day"], {})}
635
+ for bd in by_day]
636
+
637
+ # 全历史按日 tokens(热力图 / 连续天数;不补零,按日期排序)
638
+ all_day_groups = _group(all_records, "day")
639
+ all_by_day = [{"day": d, "tokens": all_day_groups[d]["tokens"]}
640
+ for d in sorted(all_day_groups)]
641
+
642
+ totals = {
643
+ "calls": len(records),
644
+ "ok": sum(1 for r in records if r.get("ok")),
645
+ "tokens": sum(_num(r, "total") for r in records),
646
+ "input": sum(_num(r, "input") for r in records),
647
+ "output": sum(_num(r, "output") for r in records),
648
+ "cached": sum(_num(r, "cached") for r in records),
649
+ "reasoning": sum(_num(r, "reasoning") for r in records),
650
+ "cost_usd": round(sum(float(r.get("cost_usd") or 0.0) for r in records), 4),
651
+ "duration_s": round(sum(float(r.get("duration_s") or 0.0) for r in records), 1),
652
+ }
653
+ totals["failed"] = totals["calls"] - totals["ok"]
654
+ totals["days_active"] = len(day_groups)
655
+ totals["avg_tokens_per_call"] = int(totals["tokens"] / totals["calls"]) if totals["calls"] else 0
656
+ denom = totals["input"] + totals["cached"]
657
+ totals["cache_rate"] = round(totals["cached"] * 100.0 / denom, 1) if denom else 0.0
658
+ totals["peak_tokens"] = max((bd["tokens"] for bd in by_day), default=0)
659
+ totals["max_duration_s"] = round(max((float(r.get("duration_s") or 0.0) for r in records), default=0.0), 1)
660
+ # 压缩省量(source=compaction 记录的 saved 合计):与消耗并列的第七维,
661
+ # 台账从此能答「省了多少」;无压缩记录时为 0,老前端不读不受影响
662
+ totals["compaction_saved"] = sum(_num(r, "saved") for r in records)
663
+ # 体验指标:只统计可测记录(缺失与 0 都表示"这条没测到",不参与均值),
664
+ # 否则 CLI 路径会把首字延迟平均数拉到 0,读数即假数。
665
+ ft_list = sorted(_parse_float(r.get("first_token_ms")) for r in records
666
+ if _parse_float(r.get("first_token_ms")) > 0)
667
+ tps_list = [_parse_float(r.get("tokens_per_sec")) for r in records
668
+ if _parse_float(r.get("tokens_per_sec")) > 0]
669
+ totals["perf_samples"] = len(ft_list)
670
+ totals["avg_first_token_ms"] = (round(sum(ft_list) / len(ft_list), 1)
671
+ if ft_list else 0.0)
672
+ totals["p95_first_token_ms"] = _percentile(ft_list, 0.95) if ft_list else 0.0
673
+ totals["avg_tokens_per_sec"] = (round(sum(tps_list) / len(tps_list), 2)
674
+ if tps_list else 0.0)
675
+ cur, lng = _streaks(str(r.get("day") or "") for r in all_records)
676
+ totals["streak_current"] = cur
677
+ totals["streak_longest"] = lng
678
+
679
+ recent = sorted(records, key=lambda r: str(r.get("ts", "")), reverse=True)[:recent_limit]
680
+ return {
681
+ "generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
682
+ "range": {"days": span, "since": since.isoformat() if since else ""},
683
+ "totals": totals,
684
+ "by_day": by_day,
685
+ "by_day_model": by_day_model,
686
+ "all_by_day": all_by_day,
687
+ "by_tool": _dim_rows(records, "tool"),
688
+ "by_agent": _dim_rows(records, "agent"),
689
+ "by_model": _dim_rows(records, "model", normalize=True),
690
+ "by_role": _dim_rows(records, "role"),
691
+ "by_task_type": _dim_rows(records, "task_type"),
692
+ "recent": recent,
693
+ }
694
+
695
+
696
+ THIN_SAMPLES = 10 # 低于此样本数即「薄样本」,口径见执行标准第 6 条
697
+
698
+ _DURATION_BASELINES = {
699
+ "direct": 90, "rank_scan": 240, "email": 180, "weekly_report": 240,
700
+ "translation": 240, "speech": 360, "video_script": 360, "article": 480,
701
+ "doc": 480, "resume": 420, "code": 600, "tech_proposal": 720,
702
+ "research": 900, "novel": 720, "serial_novel": 2400,
703
+ }
704
+
705
+
706
+ def estimate(task_type="", days=90, mode="auto", thinking="standard", rounds=None):
707
+ """同类任务开跑前成本与耗时预估(数据源为本地用量台账)。
708
+
709
+ 同一 run 的多条步骤记录加总为一个样本,优先用成功 run,给中位数/平均/P90。
710
+ 无历史样本时使用流程级保守基线;模式与思考程度只做透明倍率修正。"""
711
+ # days<=0(UI 的「全部」档)不能退化成「只扫今天」:基线会跟着窗口一起坍缩,
712
+ # 于是全历史被估成 480 秒常数。按保守年限扫全量。
713
+ span = 3650 if days is not None and int(days) <= 0 else max(1, min(3650, int(days or 90)))
714
+ with LOCK:
715
+ records = _iter_records(span)
716
+ runs = {}
717
+ for r in records:
718
+ tt = str(r.get("task_type") or "unknown")
719
+ if task_type and tt != task_type:
720
+ continue
721
+ rid = str(r.get("run_id") or "")
722
+ if not rid:
723
+ continue
724
+ g = runs.setdefault(rid, {"tokens": 0, "cost": 0.0,
725
+ "duration": 0.0, "ok": False})
726
+ g["tokens"] += _num(r, "total")
727
+ g["cost"] += _parse_float(r.get("cost_usd"))
728
+ g["duration"] += max(0.0, _parse_float(r.get("duration_s")))
729
+ if r.get("ok"):
730
+ g["ok"] = True
731
+ ok_runs = [g for g in runs.values() if g["ok"]]
732
+ basis = ok_runs or list(runs.values())
733
+ mode_factor = {"fast": 0.60, "auto": 1.0, "expert": 1.65,
734
+ "manual": 1.0}.get(str(mode or "auto"), 1.0)
735
+ thinking_factor = {"low": 0.78, "standard": 1.0, "high": 1.45,
736
+ "auto": 1.0}.get(str(thinking or "standard"), 1.0)
737
+ factor = mode_factor * thinking_factor
738
+ try:
739
+ if rounds is not None and int(rounds) > 2:
740
+ factor *= 1.0 + min(3, int(rounds) - 2) * 0.18
741
+ except (TypeError, ValueError):
742
+ pass
743
+ if not basis:
744
+ # 全历史里一条同类样本都没有(article/doc 等低频类型)时才用基线,
745
+ # 并在 duration_source 上如实标 baseline,UI 必须点名它不是实测。
746
+ baseline = _DURATION_BASELINES.get(task_type, 480)
747
+ median_s = max(30, int(baseline * factor))
748
+ return {"task_type": task_type or "", "days": span, "samples": 0,
749
+ "estimated_duration_s": median_s,
750
+ "p90_duration_s": max(median_s + 30, int(median_s * 1.7)),
751
+ # 标准「成本与时延预算」第 6 条:样本 <10 即薄样本,UI 必须点名,
752
+ # 不能把"仅此一例"的历史当实测承诺。
753
+ "thin": True, "min_samples_for_estimate": THIN_SAMPLES,
754
+ "duration_source": "baseline"}
755
+ toks = sorted(g["tokens"] for g in basis)
756
+ costs = sorted(g["cost"] for g in basis)
757
+ durations = sorted(g["duration"] for g in basis if g["duration"] > 0)
758
+ n = len(toks)
759
+ mid = n // 2
760
+ med = toks[mid] if n % 2 else (toks[mid - 1] + toks[mid]) / 2.0
761
+ med_c = costs[mid] if n % 2 else (costs[mid - 1] + costs[mid]) / 2.0
762
+ import datetime
763
+ import math
764
+ if durations:
765
+ dn = len(durations)
766
+ dmid = dn // 2
767
+ dmed = (durations[dmid] if dn % 2
768
+ else (durations[dmid - 1] + durations[dmid]) / 2.0)
769
+ dp90 = durations[max(0, min(dn - 1, math.ceil(0.9 * dn) - 1))]
770
+ duration_source = "history"
771
+ else:
772
+ dmed = _DURATION_BASELINES.get(task_type, 480)
773
+ dp90 = dmed * 1.7
774
+ duration_source = "baseline"
775
+ return {
776
+ "task_type": task_type or "",
777
+ "days": span,
778
+ "samples": n,
779
+ "ok_samples": len(ok_runs),
780
+ "avg_tokens": int(sum(toks) / n),
781
+ "median_tokens": int(med),
782
+ "p90_tokens": toks[max(0, min(n - 1, math.ceil(0.9 * n) - 1))],
783
+ "avg_cost_usd": round(sum(costs) / n, 4),
784
+ "median_cost_usd": round(med_c, 4),
785
+ "estimated_duration_s": max(30, int(dmed * factor)),
786
+ "p90_duration_s": max(60, int(dp90 * factor)),
787
+ "duration_samples": len(durations),
788
+ "thin": n < THIN_SAMPLES, "min_samples_for_estimate": THIN_SAMPLES,
789
+ "duration_source": duration_source,
790
+ "since": (datetime.date.today() - datetime.timedelta(days=span - 1)).isoformat(),
791
+ }
792
+
793
+
794
+ def _audit_file(day):
795
+ return paths.USAGE_DIR / ("estimate-audit-%s.jsonl" % day[:7].replace("-", ""))
796
+
797
+
798
+ _AUDITED = {} # run_id -> 落盘路径;幂等去重,上限后整体清空防无界增长
799
+ _AUDIT_LIMIT = 4000
800
+ TERMINAL_STATUSES = ("done", "failed", "cancelled", "timeout")
801
+
802
+
803
+ def audit_run(run):
804
+ """事后对账:开跑前的预估快照 vs 本次真实用量,一条 run 一行。
805
+
806
+ 预估值只取 run 上的快照(store.create_run 落的 estimated_duration_s 等),
807
+ 绝不回读当前 estimate()——那会把本次刚写入的消耗算进"预估",比值永远偏低。
808
+ 快照缺失(回填前的老 run、非编排类)时如实标 basis=none,不猜一个数充数。
809
+ """
810
+ try:
811
+ run_id = str((run or {}).get("id") or "")
812
+ if not run_id or run_id in _AUDITED:
813
+ return None
814
+ status = str(run.get("status") or "")
815
+ if status not in TERMINAL_STATUSES:
816
+ return None
817
+ task_type = str(run.get("task_type") or (run.get("op") or {}).get("type") or "")
818
+ est_s = _parse_float(run.get("estimated_duration_s"))
819
+ est_p90 = _parse_float(run.get("estimated_p90_s"))
820
+ basis = "stored"
821
+ if est_s <= 0:
822
+ # 快照缺失时退而用同类型历史口径,但必须落 basis=recomputed 标出来源
823
+ snap = estimate(task_type=task_type, days=(run.get("estimate_days") or 90))
824
+ est_s = _parse_float(snap.get("estimated_duration_s"))
825
+ est_p90 = _parse_float(snap.get("p90_duration_s"))
826
+ basis = "recomputed" if est_s > 0 else "none"
827
+ actual_s = 0.0
828
+ actual_tokens = 0
829
+ for r in _iter_records(366):
830
+ if str(r.get("run_id") or "") != run_id:
831
+ continue
832
+ actual_s += max(0.0, _parse_float(r.get("duration_s")))
833
+ actual_tokens += _parse_int(r.get("total"))
834
+ rec = {
835
+ "ts": time.strftime("%Y-%m-%d %H:%M:%S"),
836
+ "day": time.strftime("%Y-%m-%d"),
837
+ "run_id": run_id[:64],
838
+ "task_id": str(run.get("task_id") or "")[:64],
839
+ "task_type": task_type[:32] or "unknown",
840
+ "status": status[:16],
841
+ "est_duration_s": round(est_s, 1),
842
+ "est_p90_s": round(est_p90, 1),
843
+ "est_source": str(run.get("estimate_source") or "")[:16],
844
+ "est_samples": _parse_int(run.get("estimate_samples")),
845
+ "est_basis": basis[:16],
846
+ "actual_duration_s": round(actual_s, 1),
847
+ "actual_tokens": actual_tokens,
848
+ "ratio": round(actual_s / est_s, 3) if est_s > 0 else 0.0,
849
+ "over_p90": bool(est_p90 > 0 and actual_s > est_p90),
850
+ }
851
+ line = json.dumps(rec, ensure_ascii=False)
852
+ path = _audit_file(rec["day"])
853
+ with LOCK:
854
+ path.parent.mkdir(parents=True, exist_ok=True)
855
+ with open(path, "a", encoding="utf-8") as f:
856
+ f.write(line + "\n")
857
+ if len(_AUDITED) >= _AUDIT_LIMIT:
858
+ _AUDITED.clear()
859
+ _AUDITED[run_id] = str(path)
860
+ return rec
861
+ except Exception:
862
+ return None