codebee 0.1.102 → 0.1.103

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 (121) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -21
  3. package/README.md +4 -8
  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 -100
  12. package/app/core/acp_client.py +457 -457
  13. package/app/core/aiflavor.py +197 -197
  14. package/app/core/analytics.py +38 -38
  15. package/app/core/artifacts.py +148 -148
  16. package/app/core/assets.py +110 -110
  17. package/app/core/attachments.py +623 -623
  18. package/app/core/audit.py +30 -30
  19. package/app/core/backend_profiles.py +188 -188
  20. package/app/core/backup.py +470 -470
  21. package/app/core/beekeeper.py +183 -183
  22. package/app/core/benchstore.py +102 -102
  23. package/app/core/bookmeta.py +1036 -1036
  24. package/app/core/bookmeta_catalog.py +180 -180
  25. package/app/core/branching.py +137 -137
  26. package/app/core/capability.py +144 -144
  27. package/app/core/chaptersafe.py +32 -32
  28. package/app/core/cleanup.py +303 -303
  29. package/app/core/command_auth.py +302 -302
  30. package/app/core/compaction.py +201 -201
  31. package/app/core/contracts.py +255 -255
  32. package/app/core/covergen.py +356 -356
  33. package/app/core/defectretro.py +43 -43
  34. package/app/core/diagnostics.py +114 -114
  35. package/app/core/dispatch.py +1 -1
  36. package/app/core/dispatch_log.py +128 -128
  37. package/app/core/env_scrub.py +85 -85
  38. package/app/core/errorlog.py +137 -137
  39. package/app/core/eval_matrix.py +91 -91
  40. package/app/core/evaluation.py +174 -174
  41. package/app/core/failure_notify.py +290 -290
  42. package/app/core/flow_graph.py +33 -33
  43. package/app/core/goal_service.py +167 -167
  44. package/app/core/health.py +389 -389
  45. package/app/core/history.py +38 -38
  46. package/app/core/hooks.py +162 -162
  47. package/app/core/knowledge_pipeline.py +86 -86
  48. package/app/core/manager.py +2385 -2385
  49. package/app/core/mocks.py +64 -64
  50. package/app/core/notify.py +201 -201
  51. package/app/core/novel_quality.py +181 -181
  52. package/app/core/openapi.py +217 -217
  53. package/app/core/operations.py +228 -228
  54. package/app/core/paihang.py +145 -145
  55. package/app/core/pipeline.py +2 -2
  56. package/app/core/portguard.py +111 -111
  57. package/app/core/presence.py +77 -77
  58. package/app/core/preview.py +293 -293
  59. package/app/core/project_memory.py +206 -206
  60. package/app/core/prompt_guard.py +78 -78
  61. package/app/core/publish/__init__.py +7 -7
  62. package/app/core/publish/auto.py +659 -659
  63. package/app/core/publish/chaprows.py +138 -138
  64. package/app/core/publish/flows-fanqie-calibrated.json +442 -442
  65. package/app/core/publish/manager.py +1185 -1185
  66. package/app/core/publish/qimao.py +193 -193
  67. package/app/core/publish/volumes.py +108 -108
  68. package/app/core/publish/ws.py +139 -139
  69. package/app/core/quality_gate.py +201 -201
  70. package/app/core/redact.py +38 -38
  71. package/app/core/repeat_guard.py +129 -129
  72. package/app/core/retrieval.py +137 -137
  73. package/app/core/sandbox_runtime.py +95 -95
  74. package/app/core/selfupdate.py +384 -384
  75. package/app/core/session_log.py +161 -161
  76. package/app/core/sessions.py +312 -312
  77. package/app/core/settings.py +215 -215
  78. package/app/core/settings_schema.py +282 -282
  79. package/app/core/share_page.py +114 -114
  80. package/app/core/skill_scan.py +139 -139
  81. package/app/core/skillpacks/market/character-bible.md +66 -66
  82. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  83. package/app/core/skillpacks/market/git-workflow.md +57 -57
  84. package/app/core/skillpacks/market/release-notes.md +72 -72
  85. package/app/core/skillpacks/market/weekly-report.md +71 -71
  86. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  87. package/app/core/step_runner.py +132 -132
  88. package/app/core/story_tracking.py +275 -275
  89. package/app/core/task_compile.py +199 -199
  90. package/app/core/telemetry.py +292 -292
  91. package/app/core/tlsctx.py +64 -64
  92. package/app/core/token_meter.py +147 -147
  93. package/app/core/tool_outputs.py +107 -107
  94. package/app/core/tracing.py +207 -207
  95. package/app/core/upstream.py +41 -41
  96. package/app/core/usage.py +1038 -1038
  97. package/app/core/volumes.py +389 -389
  98. package/app/core/webhooks.py +89 -89
  99. package/app/core/wxdigest.py +865 -865
  100. package/app/core/zentao.py +2692 -2692
  101. package/app/mcp_server.py +244 -244
  102. package/app/output/_createbook_body.json +1 -0
  103. package/app/output/_probe_fq_click.py +50 -0
  104. package/app/output/_probe_fq_form.py +42 -0
  105. package/app/output/_probe_fq_modal.py +55 -0
  106. package/app/output/_probe_fq_points.py +71 -0
  107. package/app/output/_probe_fq_submit.py +34 -0
  108. package/app/output/_probe_fq_watch.py +62 -0
  109. package/app/output/workbench-clean-preview.jpg +0 -0
  110. package/app/output/workbench-reference-render.png +0 -0
  111. package/app/output/workbench-shell.png +0 -0
  112. package/app/pick_dialog.py +110 -110
  113. package/app/ui/app.js +2 -3
  114. package/app/ui/artdiff.js +74 -74
  115. package/app/ui/i18n.js +0 -1
  116. package/app/ui/icons/bee.svg +79 -79
  117. package/app/ui/manifest.json +16 -16
  118. package/app/ui/qrcode.js +2297 -2297
  119. package/bin/tutti.js +147 -147
  120. package/package.json +1 -1
  121. package/tools/wx_reader.py +261 -261
package/app/core/usage.py CHANGED
@@ -1,1038 +1,1038 @@
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
- # ---------------------------------------------------------------- 用量版本(SSE 轻量事件)
23
- # 台账每落一条记录就 bump 一次版本号。SSE 循环看到变化只推一个几十字节的
24
- # usage 事件让前端刷新侧栏用量条,绝不为此构建/推送全量状态——运行中记账
25
- # 是秒级频率,全量 payload 会被放大成推送风暴。版本号自带合并:两次读取
26
- # 之间落多少条记录都只算一次变化。
27
- _VER_CV = threading.Condition()
28
- _USAGE_VER = 1
29
-
30
-
31
- def bump_usage():
32
- global _USAGE_VER
33
- with _VER_CV:
34
- _USAGE_VER += 1
35
- _VER_CV.notify_all()
36
-
37
-
38
- def usage_version():
39
- return _USAGE_VER
40
-
41
-
42
- # 维度 → 显示名(聚合接口与前端共用)
43
- DIMENSIONS = {
44
- "tool": "工具(CLI)",
45
- "agent": "智能体",
46
- "model": "模型",
47
- "role": "步骤角色",
48
- "task_type": "任务类型",
49
- }
50
-
51
- FIELDS = ("ts", "day", "run_id", "step", "task_id", "task_type", "role", "agent", "agent_label",
52
- "tool", "model", "provider", "ok", "duration_s",
53
- "input", "output", "cached", "reasoning", "total", "cost_usd", "source",
54
- "first_token_ms", "tokens_per_sec")
55
-
56
-
57
- def _month_file(day):
58
- return paths.USAGE_DIR / ("usage-%s.jsonl" % day[:7].replace("-", ""))
59
-
60
-
61
- def _quality_file(day):
62
- return paths.USAGE_DIR / ("routing-quality-%s.jsonl" % day[:7].replace("-", ""))
63
-
64
-
65
- def _parse_int(v):
66
- try:
67
- return int(v or 0)
68
- except (TypeError, ValueError):
69
- return 0
70
-
71
-
72
- def _parse_float(v):
73
- try:
74
- return float(v or 0.0)
75
- except (TypeError, ValueError):
76
- return 0.0
77
-
78
-
79
- def _parse_bool(v):
80
- if v is None:
81
- return False
82
- return bool(v)
83
-
84
-
85
- def record(source="", run_id="", task_id="", task_type="", role="", step=0,
86
- agent="", agent_label="", tool="", model="", provider="",
87
- ok=True, duration_s=0.0, cost_usd=0.0, usage=None,
88
- first_token_ms=0.0, tokens_per_sec=0.0):
89
- """追加一条用量记录。usage 为细分 dict:{input, output, cached, reasoning, total};
90
- 缺省字段按 0 处理。任何异常都吞掉——统计永远不能拖垮业务调用方。
91
-
92
- step 为该运行内的步骤号:与 backfill_from_runs 的去重键一致,缺了会导致
93
- 启动回填把同一步骤重复入账。
94
- """
95
- try:
96
- u = usage or {}
97
- rec = {
98
- "ts": time.strftime("%Y-%m-%d %H:%M:%S"),
99
- "day": time.strftime("%Y-%m-%d"),
100
- "source": str(source or "pipeline")[:24],
101
- "run_id": str(run_id or "")[:64],
102
- "step": _parse_int(step),
103
- "task_id": str(task_id or "")[:64],
104
- "task_type": str(task_type or "")[:32] or "unknown",
105
- "role": str(role or "")[:40] or "unknown",
106
- "agent": str(agent or "")[:40] or "unknown",
107
- "agent_label": str(agent_label or "")[:60],
108
- "tool": str(tool or "")[:24] or "unknown",
109
- "model": str(model or "")[:80] or "(默认)",
110
- "provider": str(provider or "")[:60],
111
- "ok": _parse_bool(ok),
112
- "duration_s": round(_parse_float(duration_s), 1),
113
- "cost_usd": round(_parse_float(cost_usd), 4),
114
- }
115
- inp = max(0, _parse_int(u.get("input")))
116
- out = max(0, _parse_int(u.get("output")))
117
- cach = max(0, _parse_int(u.get("cached")))
118
- reas = max(0, _parse_int(u.get("reasoning")))
119
- total = _parse_int(u.get("total")) or (inp + out + cach)
120
- rec.update({"input": inp, "output": out, "cached": cach,
121
- "reasoning": reas, "total": total})
122
- # 金额闭环:调用方没给费用(CLI/直连/评测普遍如此)→ 按标定单价折算
123
- # (¥/百万 tokens;手标优先,回落导入价表;未标价 = 不记钱,宁缺毋滥)。
124
- # 列名 cost_usd 沿用历史,语义 = 费用(¥)。
125
- if _parse_float(rec.get("cost_usd")) <= 0 and (inp or out) \
126
- and rec.get("model") not in ("", "(默认)"):
127
- try:
128
- from . import modelhub
129
- pr = modelhub.model_price(str(rec["model"]), provider=str(rec["provider"]))
130
- if pr:
131
- rec["cost_usd"] = round((pr["in"] * inp + pr["out"] * out) / 1e6, 4)
132
- except Exception:
133
- pass
134
- # 压缩省量注记(source=compaction 专用):上下文折叠净省的估算 token,
135
- # 与消耗并列成账——省了多少不再是黑箱。零值不落字段防老记录膨胀。
136
- saved = max(0, _parse_int(u.get("saved")))
137
- if saved:
138
- rec["saved"] = saved
139
- # 体验指标(§07):只有内置直连的流式路径可测,CLI 事件流没有逐 token
140
- # 时刻。零值不落字段——"缺字段"表示不可测,不能读成"首字 0 毫秒"。
141
- ft = round(_parse_float(first_token_ms), 1)
142
- if ft > 0:
143
- rec["first_token_ms"] = ft
144
- tps = round(_parse_float(tokens_per_sec), 2)
145
- if tps > 0:
146
- rec["tokens_per_sec"] = tps
147
- line = json.dumps(rec, ensure_ascii=False)
148
- with LOCK:
149
- paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
150
- with open(_month_file(rec["day"]), "a", encoding="utf-8") as f:
151
- f.write(line + "\n")
152
- _ROUTING_CACHE["data"].clear()
153
- _ROUTING_RECORDS_CACHE.clear()
154
- _HOURLY_CACHE["ts"] = 0.0
155
- bump_usage() # 台账变了:SSE 推 usage 事件,侧栏用量条实时跟进
156
- if _budget_caps_on():
157
- # 花费预警(硬顶的事前半环):只在配了上限时起线程,绝不拖慢记账
158
- threading.Thread(target=_budget_alert_check, daemon=True,
159
- args=(rec["day"],)).start()
160
- except Exception:
161
- pass
162
-
163
-
164
- def _budget_caps_on():
165
- try:
166
- from .settings_schema import get as ss_get, register_default_namespaces
167
- register_default_namespaces()
168
- return (float(ss_get("budget", "daily_cost_yuan") or 0) > 0
169
- or float(ss_get("budget", "monthly_cost_yuan") or 0) > 0)
170
- except Exception:
171
- return False
172
-
173
-
174
- _ALERT_PUSH = None # main.py 启动注入 notify.push_text——usage(L0) 不反向
175
- # 依赖 notify(L1),静态图不成环(同 set_run_estimator 模式)
176
-
177
-
178
- def set_alert_push(fn):
179
- """注入花费预警的推送函数;未注入则预警静默跳过。"""
180
- global _ALERT_PUSH
181
- _ALERT_PUSH = fn if callable(fn) else None
182
-
183
-
184
- def _budget_alert_check(day):
185
- """花费预警推送(50%/80%,日/月各自去重一次):预警是硬顶的事前提醒,
186
- 触发阈值状态落 data/budget_alerts.json(按天重置)。失败静默。"""
187
- try:
188
- from .settings_schema import get as ss_get, register_default_namespaces
189
- register_default_namespaces()
190
- dcap = float(ss_get("budget", "daily_cost_yuan") or 0)
191
- mcap = float(ss_get("budget", "monthly_cost_yuan") or 0)
192
- today, month = cost_snapshot()
193
- marks = []
194
- for scope, spent, cap in (("d", today, dcap), ("m", month, mcap)):
195
- if cap <= 0 or spent <= 0:
196
- continue
197
- pct = spent / cap
198
- for th in (0.8, 0.5):
199
- if pct >= th:
200
- marks.append((scope, int(th * 100), spent, cap))
201
- break
202
- if not marks:
203
- return
204
- sp = paths.DATA_DIR / "budget_alerts.json"
205
- try:
206
- st = json.loads(sp.read_text(encoding="utf-8"))
207
- except Exception:
208
- st = {}
209
- if st.get("day") != day or not isinstance(st.get("done"), list):
210
- st = {"day": day, "done": []}
211
- done = st["done"]
212
- new = [m for m in marks if ("%s%d" % (m[0], m[1])) not in done]
213
- if not new:
214
- return
215
- st["done"] = done + ["%s%d" % (m[0], m[1]) for m in new]
216
- sp.parent.mkdir(parents=True, exist_ok=True)
217
- tmp = sp.with_suffix(".tmp")
218
- tmp.write_text(json.dumps(st, ensure_ascii=False), encoding="utf-8")
219
- tmp.replace(sp)
220
- if not _ALERT_PUSH:
221
- return
222
- lines = ["💸 CodeBee 花费预警"]
223
- for scope, pct, spent, cap in new:
224
- lines.append("%s花费 ¥%.2f,已达%s上限 ¥%.2f 的 %d%%" % (
225
- "今日" if scope == "d" else "本月", spent,
226
- "每日" if scope == "d" else "每月", cap, pct))
227
- lines.append("(达 100% 后新步骤将停止,可在设置→编排设置→预算调整)")
228
- _ALERT_PUSH("\n".join(lines))
229
- except Exception:
230
- pass
231
-
232
-
233
- EXPORT_COLUMNS = ("ts", "day", "source", "task_type", "role", "agent", "model",
234
- "provider", "ok", "input", "output", "cached", "reasoning",
235
- "total", "cost_usd")
236
-
237
-
238
- def export_csv(days=30, max_rows=10000):
239
- """台账明细导出 CSV(UTF-8 BOM,Excel 直开;行数封顶防炸)。返回 bytes。"""
240
- import csv as _csv
241
- import io as _io
242
- buf = _io.StringIO()
243
- writer = _csv.writer(buf)
244
- writer.writerow(EXPORT_COLUMNS)
245
- n = 0
246
- for r in _iter_records(max(0, int(days or 0))):
247
- writer.writerow([r.get(c, "") for c in EXPORT_COLUMNS])
248
- n += 1
249
- if n >= max_rows:
250
- break
251
- return b"\xef\xbb\xbf" + buf.getvalue().encode("utf-8")
252
-
253
-
254
- def backfill_from_runs():
255
- """把历史 run.json 里已记录的 token 用量回填进台账(启动时调用,幂等)。
256
- 埋点是后加的:此前的运行只在 run.json 的步骤里存了 tokens/cost_usd 总量,
257
- 没有输入/输出/缓存细分,所以回填记录 input/output/cached 记 0、只有 total,
258
- 并以 source="backfill" 标记便于区分。已回填的 (run_id, 步骤号) 会跳过,
259
- 重复启动不会产生重复记录。
260
- """
261
- try:
262
- runs_dir = paths.RUNS_DIR
263
- if not runs_dir.is_dir():
264
- return 0
265
- # 已入账的 (run_id, role, 步骤号) 集合——含真实埋点与既往回填
266
- seen = set()
267
- for r in _iter_records(0):
268
- seen.add((str(r.get("run_id") or ""), str(r.get("role") or ""),
269
- _parse_int(r.get("step"))))
270
- added = 0
271
- # 旧 run 未存任务类型,从 tasks/*.json 补齐(缺了就记 unknown)
272
- task_types = {}
273
- try:
274
- for tp in paths.TASKS_DIR.glob("*.json"):
275
- try:
276
- t = json.loads(tp.read_text(encoding="utf-8"))
277
- task_types[t.get("id")] = t.get("type") or ""
278
- except Exception:
279
- continue
280
- except Exception:
281
- pass
282
- for p in sorted(runs_dir.glob("*/run.json")):
283
- try:
284
- run = json.loads(p.read_text(encoding="utf-8"))
285
- except Exception:
286
- continue
287
- if not isinstance(run, dict):
288
- continue
289
- run_id = str(run.get("id") or p.parent.name)
290
- day = str(run.get("started_at") or run.get("created_at") or "")[:10]
291
- if not day:
292
- continue
293
- for s in (run.get("steps") or []):
294
- if not isinstance(s, dict):
295
- continue
296
- total = _parse_int(s.get("tokens"))
297
- if total <= 0:
298
- continue # 无 token 的步骤(验证/合并/mock)不入账
299
- n = _parse_int(s.get("n"))
300
- key = (run_id, str(s.get("role") or ""), n)
301
- if key in seen:
302
- continue
303
- seen.add(key)
304
- rec = {
305
- "ts": "%s %s" % (day, str(s.get("started_at") or "00:00:00")[:8]),
306
- "day": day, "source": "backfill", "run_id": run_id,
307
- "step": n,
308
- "task_id": str(run.get("task_id") or ""),
309
- "task_type": task_types.get(str(run.get("task_id") or "")) or "unknown",
310
- "role": str(s.get("role") or "")[:40] or "unknown",
311
- "agent": str(s.get("agent") or "")[:40] or "unknown",
312
- "agent_label": str(s.get("agent_label") or "")[:60],
313
- "tool": _tool_of(str(s.get("agent") or "")),
314
- "model": str(s.get("model") or "")[:80] or "(历史未记录)",
315
- "provider": "",
316
- "ok": str(s.get("status") or "") == "done",
317
- "duration_s": round(_parse_float(s.get("duration_s")), 1),
318
- "cost_usd": round(_parse_float(s.get("cost_usd")), 4),
319
- "input": 0, "output": 0, "cached": 0, "reasoning": 0,
320
- "total": total,
321
- }
322
- with LOCK:
323
- paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
324
- with open(_month_file(day), "a", encoding="utf-8") as f:
325
- f.write(json.dumps(rec, ensure_ascii=False) + "\n")
326
- added += 1
327
- return added
328
- except Exception:
329
- return 0
330
-
331
-
332
- # 智能体 id → 工具 kind(回填时旧记录没有 tool 字段,只能从 id 推断)
333
- _AGENT_TOOL = {"codex-cli": "codex", "claude-code": "claude", "qwen-cli": "qwen",
334
- "qwencode": "qwen", "opencode": "opencode", "aider": "aider",
335
- "orchestrator": "orchestrator"}
336
-
337
-
338
- def _tool_of(agent_id):
339
- a = str(agent_id or "").lower()
340
- if a in _AGENT_TOOL:
341
- return _AGENT_TOOL[a]
342
- for k, v in _AGENT_TOOL.items():
343
- if a.startswith(k):
344
- return v
345
- return a or "unknown"
346
-
347
-
348
- _HOURLY_CACHE = {"ts": 0.0, "val": {}}
349
- _HOURLY_TTL = 60.0 # 秒:路由调用频繁但台账追加低频,60s 缓存足够新鲜
350
- _ROUTING_CACHE = {"data": {}}
351
- _ROUTING_RECORDS_CACHE = {}
352
- _ROUTING_TTL = 15.0
353
-
354
-
355
- def agent_tokens_recent(agent, hours=1):
356
- """该智能体近 N 小时的 token 总量(路由配额惩罚用)。按 ts 前缀过滤,
357
- 60 秒 TTL 进程内缓存——运行中每次路由都查也只扫两天的台账。"""
358
- try:
359
- agent = str(agent or "")
360
- now = time.time()
361
- if now - _HOURLY_CACHE["ts"] > _HOURLY_TTL:
362
- bound = time.strftime("%Y-%m-%d %H:%M:%S",
363
- time.localtime(now - hours * 3600.0))
364
- total = {}
365
- for r in _iter_records(2):
366
- if str(r.get("ts") or "") < bound:
367
- continue
368
- a = str(r.get("agent") or "")
369
- # record() 将规范化后的 token 总数落在顶层 ``total``;旧实现
370
- # 误读不存在的嵌套 usage 字段,导致配额路由永远认为本小时
371
- # 用量为 0,超过供应商额度也不会降权。
372
- total[a] = total.get(a, 0) + max(0, _parse_int(r.get("total")))
373
- _HOURLY_CACHE.update(ts=now, val=total)
374
- return int(_HOURLY_CACHE["val"].get(agent) or 0)
375
- except Exception:
376
- return 0
377
-
378
-
379
- # 文件级解析缓存:台账 append-only(写入只增不改前缀),按 (mtime_ns, size)
380
- # 命中就复用已解析记录。用量条实时化后 summary 会被按秒级反复调用,缓存让
381
- # 每次只重解析当天新落的小文件,历史月份不再逐次全量 json.loads。缓存的是
382
- # 未过滤的全量记录,days 过滤在读出后做,同一份缓存可服务不同取数窗口。
383
- # 读+stat 全程持 LOCK(RLock 可重入,summary 外层已持也不死锁),杜绝
384
- # 「读到旧内容、却按新 mtime 入缓存」的竞态把新记录吞到下次追加才可见。
385
- _FILE_CACHE = {}
386
-
387
-
388
- def _iter_records(days):
389
- """按时间范围读取台账(days=0 表示全部)。返回按写入顺序的记录列表。"""
390
- out = []
391
- try:
392
- files = sorted(paths.USAGE_DIR.glob("usage-*.jsonl")) if paths.USAGE_DIR.is_dir() else []
393
- except Exception:
394
- return out
395
- since_day = ""
396
- if days:
397
- import datetime
398
- d = (datetime.date.today() - datetime.timedelta(days=int(days) - 1)).isoformat()
399
- since_day = d
400
- since_month = since_day[:7].replace("-", "") if since_day else ""
401
- for p in files:
402
- if since_month and p.stem.rsplit("-", 1)[-1] < since_month:
403
- continue
404
- try:
405
- with LOCK:
406
- st = p.stat()
407
- key = str(p)
408
- hit = _FILE_CACHE.get(key)
409
- if hit and hit[0] == (st.st_mtime_ns, st.st_size):
410
- recs = hit[1]
411
- else:
412
- recs = []
413
- for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
414
- line = line.strip()
415
- if not line.startswith("{"):
416
- continue
417
- try:
418
- r = json.loads(line)
419
- except Exception:
420
- continue
421
- if not isinstance(r, dict):
422
- continue
423
- recs.append(r)
424
- _FILE_CACHE[key] = ((st.st_mtime_ns, st.st_size), recs)
425
- except Exception:
426
- continue
427
- for r in recs:
428
- if since_day and str(r.get("day", "")) < since_day:
429
- continue
430
- out.append(r)
431
- return out
432
-
433
-
434
- def cost_snapshot():
435
- """(今日实付, 当月实付) 美元快照——花费硬顶闸(pipeline 预算检查)用。
436
-
437
- 复用 _iter_records 的按文件缓存;窗口取 31 天恒覆盖整个当月,
438
- 单月文件量级下每次调用开销可忽略。任何异常返回 (0.0, 0.0)(闸放行,
439
- 统计层故障不能反过来把业务锁死)。
440
- """
441
- try:
442
- import datetime
443
- today = datetime.date.today().isoformat()
444
- month = today[:7]
445
- t = m = 0.0
446
- for r in _iter_records(31):
447
- c = _parse_float(r.get("cost_usd"))
448
- if c <= 0:
449
- continue
450
- d = str(r.get("day") or "")
451
- if d == today:
452
- t += c
453
- m += c
454
- elif d.startswith(month):
455
- m += c
456
- return round(t, 4), round(m, 4)
457
- except Exception:
458
- return 0.0, 0.0
459
-
460
-
461
- def _iter_quality_records(days):
462
- """读取独立质量台账;不混入用量统计的调用数、token 和成本。"""
463
- out = []
464
- try:
465
- files = sorted(paths.USAGE_DIR.glob("routing-quality-*.jsonl")) \
466
- if paths.USAGE_DIR.is_dir() else []
467
- except Exception:
468
- return out
469
- since_day = ""
470
- if days:
471
- import datetime
472
- since_day = (datetime.date.today()
473
- - datetime.timedelta(days=int(days) - 1)).isoformat()
474
- since_month = since_day[:7].replace("-", "") if since_day else ""
475
- for path in files:
476
- if since_month and path.stem.rsplit("-", 1)[-1] < since_month:
477
- continue
478
- try:
479
- for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
480
- try:
481
- row = json.loads(line)
482
- except Exception:
483
- continue
484
- if isinstance(row, dict) and (not since_day or row.get("day", "") >= since_day):
485
- out.append(row)
486
- except Exception:
487
- continue
488
- return out
489
-
490
-
491
- def _routing_records(days):
492
- """同一轮候选评分共享一次台账解析,避免每个候选重复扫全盘。"""
493
- key = (str(paths.USAGE_DIR), int(days))
494
- now = time.time()
495
- with LOCK:
496
- cached = _ROUTING_RECORDS_CACHE.get(key)
497
- if cached and now - cached[0] <= _ROUTING_TTL:
498
- return list(cached[1]), list(cached[2])
499
- calls, quality = _iter_records(days), _iter_quality_records(days)
500
- with LOCK:
501
- _ROUTING_RECORDS_CACHE[key] = (now, list(calls), list(quality))
502
- return calls, quality
503
-
504
-
505
- def record_quality_for_run(run_id, quality_ok, agent=""):
506
- """把验收结论归因到最终实际产出者,不重复计入用量。"""
507
- try:
508
- run_id = str(run_id or "")[:64]
509
- if not run_id:
510
- return 0
511
- calls = [r for r in _iter_records(2)
512
- if r.get("run_id") == run_id and r.get("ok") is True]
513
- prefixes = ("implement", "fix", "draft", "revise", "polish", "direct", "author")
514
- calls = [r for r in calls if str(r.get("role") or "").lower().startswith(prefixes)]
515
- agent = str(agent or "")[:40]
516
- unique = {}
517
- for row in calls:
518
- key = tuple(str(row.get(k) or "") for k in
519
- ("task_id", "task_type", "role", "agent", "model", "provider"))
520
- unique[key] = row
521
- if not unique:
522
- return 0
523
- existing = {(r.get("run_id"), r.get("role"), r.get("agent"),
524
- r.get("model"), r.get("provider"))
525
- for r in _iter_quality_records(2)}
526
- day = time.strftime("%Y-%m-%d")
527
- rows = []
528
- for row in unique.values():
529
- identity = (run_id, row.get("role"), row.get("agent"),
530
- row.get("model"), row.get("provider"))
531
- if identity in existing:
532
- continue
533
- # 成功调用不等于产出通过。换将前的实现者即使传输成功,也已被质量
534
- # 门淘汰,必须留下负样本;最终实际产出者才继承本次验收结论。
535
- row_quality_ok = bool(quality_ok)
536
- if agent and str(row.get("agent") or "") != agent:
537
- row_quality_ok = False
538
- rows.append({"ts": time.strftime("%Y-%m-%d %H:%M:%S"), "day": day,
539
- "run_id": run_id, "task_id": row.get("task_id") or "",
540
- "task_type": row.get("task_type") or "unknown",
541
- "role": row.get("role") or "unknown",
542
- "agent": row.get("agent") or "unknown",
543
- "model": row.get("model") or "(默认)",
544
- "provider": row.get("provider") or "",
545
- "quality_ok": row_quality_ok})
546
- if not rows:
547
- return 0
548
- with LOCK:
549
- paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
550
- with open(_quality_file(day), "a", encoding="utf-8") as handle:
551
- for row in rows:
552
- handle.write(json.dumps(row, ensure_ascii=False) + "\n")
553
- _ROUTING_CACHE["data"].clear()
554
- _ROUTING_RECORDS_CACHE.clear()
555
- return len(rows)
556
- except Exception:
557
- return 0
558
-
559
-
560
- def _num(r, key):
561
- return _parse_int(r.get(key))
562
-
563
-
564
- def _percentile(values, percentile):
565
- """线性插值百分位,空样本返回 0。"""
566
- if not values:
567
- return 0.0
568
- ordered = sorted(float(v) for v in values)
569
- if len(ordered) == 1:
570
- return round(ordered[0], 2)
571
- pos = (len(ordered) - 1) * float(percentile)
572
- lo = int(math.floor(pos))
573
- hi = int(math.ceil(pos))
574
- if lo == hi:
575
- return round(ordered[lo], 2)
576
- return round(ordered[lo] + (ordered[hi] - ordered[lo]) * (pos - lo), 2)
577
-
578
-
579
- def routing_stats(task_type="", role="", agent="", model="", provider="",
580
- days=90, min_samples=3):
581
- """返回路由用的近期真实运行指标。
582
-
583
- 只读取脱敏用量台账;精确维度样本不足时依次回退到任务+角色、任务、
584
- 全局,成功率使用 Beta(3, 1) 平滑,避免单次失败永久压低新候选。
585
- """
586
- try:
587
- task_type = str(task_type or "")[:32]
588
- role = str(role or "")[:40]
589
- agent = str(agent or "")[:40]
590
- model = str(model or "")[:80]
591
- provider = str(provider or "")[:60]
592
- days = max(0, int(days))
593
- min_samples = max(1, int(min_samples))
594
- except (TypeError, ValueError):
595
- days, min_samples = 90, 3
596
- # DATA_DIR 可在测试、多实例或运行时配置切换;纳入缓存键,避免跨目录
597
- # 复用另一套台账的线上指标。
598
- key = (str(paths.DATA_DIR), task_type, role, agent, model, provider,
599
- days, min_samples)
600
- now = time.time()
601
- with LOCK:
602
- cached = _ROUTING_CACHE["data"].get(key)
603
- if cached and now - cached[0] <= _ROUTING_TTL:
604
- return dict(cached[1])
605
- records, quality_records = _routing_records(days)
606
-
607
- def matches(record, filters):
608
- return all(str(record.get(field) or "") == value
609
- for field, value in filters.items() if value)
610
-
611
- # 先保留所有已提供的维度;后续逐层放宽,确保模型和 CLI 都能共享一套查询。
612
- exact = {"task_type": task_type, "role": role, "agent": agent,
613
- "model": model, "provider": provider}
614
- fallbacks = [(exact, "exact")]
615
- task_role = {"task_type": task_type, "role": role}
616
- if task_role != exact:
617
- fallbacks.append((task_role, "task-role"))
618
- if task_type:
619
- fallbacks.append(({"task_type": task_type}, "task"))
620
- fallbacks.append(({}, "global"))
621
- selected, label = [], "global"
622
- for filters, candidate_label in fallbacks:
623
- candidate = [r for r in records if matches(r, filters)]
624
- if len(candidate) >= min_samples or (candidate_label == "global" and candidate):
625
- selected, label = candidate, candidate_label
626
- break
627
- chosen_filters = next((filters for filters, candidate_label in fallbacks
628
- if candidate_label == label), {})
629
- quality_selected = [r for r in quality_records if matches(r, chosen_filters)]
630
- success_rows = quality_selected or selected
631
- success_key = "quality_ok" if quality_selected else "ok"
632
- successes = sum(1 for r in success_rows if bool(r.get(success_key)))
633
- durations = [max(0.0, _parse_float(r.get("duration_s"))) for r in selected]
634
- costs = [max(0.0, _parse_float(r.get("cost_usd"))) for r in selected]
635
- samples = len(selected)
636
- success_samples = len(success_rows)
637
- result = {
638
- "samples": samples,
639
- "quality_samples": len(quality_selected),
640
- "success_samples": success_samples,
641
- "successes": successes,
642
- "success_rate": round((successes + 3.0) / (success_samples + 4.0), 4),
643
- "p50_duration_s": _percentile(durations, 0.50),
644
- "p95_duration_s": _percentile(durations, 0.95),
645
- "avg_cost_usd": round(sum(costs) / samples, 6) if samples else 0.0,
646
- "fallback": label,
647
- }
648
- with LOCK:
649
- _ROUTING_CACHE["data"][key] = (now, dict(result))
650
- return result
651
-
652
-
653
- def _group(records, key):
654
- """按 key 聚合:{name: {calls, ok, tokens, input, output, cached, cache_rate, cost_usd, duration_s, saved}}。"""
655
- groups = {}
656
- for r in records:
657
- name = str(r.get(key) or "") or "unknown"
658
- g = groups.setdefault(name, {"calls": 0, "ok": 0, "tokens": 0,
659
- "input": 0, "output": 0, "cached": 0,
660
- "cost_usd": 0.0, "duration_s": 0.0,
661
- "saved": 0})
662
- g["calls"] += 1
663
- if r.get("ok"):
664
- g["ok"] += 1
665
- g["tokens"] += _num(r, "total")
666
- g["input"] += _num(r, "input")
667
- g["output"] += _num(r, "output")
668
- g["cached"] += _num(r, "cached")
669
- g["saved"] += _num(r, "saved")
670
- g["cost_usd"] = round(g["cost_usd"] + float(r.get("cost_usd") or 0.0), 4)
671
- g["duration_s"] = round(g["duration_s"] + float(r.get("duration_s") or 0.0), 1)
672
- # §07 验收指标:缓存命中率(cached / (input + cached)),与 totals.cache_rate 同口径
673
- for g in groups.values():
674
- denom = g["input"] + g["cached"]
675
- g["cache_rate"] = round(g["cached"] * 100.0 / denom, 1) if denom else 0.0
676
- return groups
677
-
678
-
679
- def _model_display_map(records):
680
- """模型名归一映射 {casefold: 展示名}:同一模型不同大小写(GLM-5.3-Flash 与
681
- glm-5.3-flash)会被拆成两行统计,这里把 casefold 相同的归为一组,
682
- 展示名取组内出现次数最多的原始写法(平局取更长写法,再平局取大写字母
683
- 更多的规范形——台账倒序读入时 2:2 平局的裁断不再随记录顺序漂移)。"""
684
- canon = {}
685
- for r in records:
686
- name = str(r.get("model") or "") or "unknown"
687
- c = canon.setdefault(name.casefold(), {})
688
- c["total"] = c.get("total", 0) + 1
689
- c["names"] = c.get("names") or {}
690
- c["names"][name] = (c["names"].get(name) or 0) + 1
691
- display = {}
692
- for k, c in canon.items():
693
- display[k] = max(c["names"].items(),
694
- key=lambda kv: (kv[1], len(kv[0]),
695
- sum(ch.isupper() for ch in kv[0])))[0]
696
- return display
697
-
698
-
699
- def _remap_model(records, display):
700
- """把 records 的 model 字段替换为归一后的展示名(不改动原记录)。"""
701
- out = []
702
- for r in records:
703
- name = str(r.get("model") or "") or "unknown"
704
- r2 = dict(r)
705
- r2["model"] = display.get(name.casefold(), name)
706
- out.append(r2)
707
- return out
708
-
709
-
710
- def _dim_rows(records, key, normalize=False):
711
- rows = []
712
- if normalize:
713
- records = _remap_model(records, _model_display_map(records))
714
- for name, g in _group(records, key).items():
715
- rows.append({"key": name, **g})
716
- rows.sort(key=lambda x: -x["tokens"])
717
- return rows
718
-
719
-
720
- def _streaks(dayset):
721
- """连续活跃天数:current=从今天(今天没用量则从昨天)往回的连续天数;
722
- longest=历史上最长连续段。dayset 为 ISO 日期字符串集合。"""
723
- import datetime
724
- ds = set()
725
- for s in dayset:
726
- try:
727
- ds.add(datetime.date.fromisoformat(str(s)))
728
- except (ValueError, TypeError):
729
- continue
730
- if not ds:
731
- return 0, 0
732
- today = datetime.date.today()
733
- anchor = today if today in ds else today - datetime.timedelta(days=1)
734
- current = 0
735
- d = anchor
736
- while d in ds:
737
- current += 1
738
- d -= datetime.timedelta(days=1)
739
- longest = run = 0
740
- prev = None
741
- for d in sorted(ds):
742
- run = run + 1 if (prev is not None and (d - prev).days == 1) else 1
743
- longest = max(longest, run)
744
- prev = d
745
- return current, longest
746
-
747
-
748
- def summary(days=30, recent_limit=30):
749
- """多维聚合。days=0 表示全部历史。by_day 连续补零,方便前端直接画趋势。
750
-
751
- 一次全量扫描后在内存里按范围过滤(_iter_records 本就逐文件全读,
752
- 这样热力图/连续天数所需的全历史数据不再二次扫描):
753
- - all_by_day:全历史按日 tokens(热力图、连续天数);
754
- - by_day_model:范围内按日按模型 tokens(多模型趋势折线,模型名已归一);
755
- - totals 增 peak_tokens / max_duration_s / streak_current / streak_longest。
756
- """
757
- import datetime
758
- with LOCK:
759
- all_records = _iter_records(0)
760
- now = datetime.date.today()
761
- span = int(days) if days else 0
762
- since = (now - datetime.timedelta(days=max(span, 1) - 1)) if span else None
763
- if span:
764
- since_s = since.isoformat()
765
- records = [r for r in all_records if str(r.get("day") or "") >= since_s]
766
- else:
767
- records = all_records
768
-
769
- # 模型名归一:维度行与按日趋势共用同一展示名映射
770
- model_display = _model_display_map(records)
771
- model_records = _remap_model(records, model_display)
772
-
773
- day_groups = _group(records, "day")
774
- by_day = []
775
- if span:
776
- for i in range(span):
777
- d = (now - datetime.timedelta(days=span - 1 - i)).isoformat()
778
- g = day_groups.get(d, {})
779
- by_day.append({"day": d, "calls": g.get("calls", 0),
780
- "ok": g.get("ok", 0),
781
- "tokens": g.get("tokens", 0),
782
- "input": g.get("input", 0), "output": g.get("output", 0),
783
- "cached": g.get("cached", 0),
784
- "cache_rate": g.get("cache_rate", 0.0),
785
- "duration_s": g.get("duration_s", 0.0),
786
- "saved": g.get("saved", 0),
787
- "cost_usd": round(g.get("cost_usd", 0.0), 4)})
788
- else:
789
- for d in sorted(day_groups):
790
- g = day_groups[d]
791
- by_day.append({"day": d, "calls": g["calls"], "ok": g["ok"],
792
- "tokens": g["tokens"],
793
- "input": g["input"], "output": g["output"],
794
- "cached": g.get("cached", 0),
795
- "cache_rate": g.get("cache_rate", 0.0),
796
- "duration_s": g.get("duration_s", 0.0),
797
- "saved": g.get("saved", 0),
798
- "cost_usd": g["cost_usd"]})
799
-
800
- # 按日按模型 tokens(范围内;与 by_day 同一天序列,无数据天给空表)
801
- day_model = {}
802
- for r in model_records:
803
- d = str(r.get("day") or "")
804
- if not d:
805
- continue
806
- m = day_model.setdefault(d, {})
807
- name = str(r.get("model") or "unknown")
808
- m[name] = m.get(name, 0) + _num(r, "total")
809
- by_day_model = [{"day": bd["day"], "models": day_model.get(bd["day"], {})}
810
- for bd in by_day]
811
-
812
- # 全历史按日 tokens(热力图 / 连续天数;不补零,按日期排序)
813
- all_day_groups = _group(all_records, "day")
814
- all_by_day = [{"day": d, "tokens": all_day_groups[d]["tokens"]}
815
- for d in sorted(all_day_groups)]
816
-
817
- totals = {
818
- "calls": len(records),
819
- "ok": sum(1 for r in records if r.get("ok")),
820
- "tokens": sum(_num(r, "total") for r in records),
821
- "input": sum(_num(r, "input") for r in records),
822
- "output": sum(_num(r, "output") for r in records),
823
- "cached": sum(_num(r, "cached") for r in records),
824
- "reasoning": sum(_num(r, "reasoning") for r in records),
825
- "cost_usd": round(sum(float(r.get("cost_usd") or 0.0) for r in records), 4),
826
- "duration_s": round(sum(float(r.get("duration_s") or 0.0) for r in records), 1),
827
- }
828
- totals["failed"] = totals["calls"] - totals["ok"]
829
- totals["days_active"] = len(day_groups)
830
- totals["avg_tokens_per_call"] = int(totals["tokens"] / totals["calls"]) if totals["calls"] else 0
831
- denom = totals["input"] + totals["cached"]
832
- totals["cache_rate"] = round(totals["cached"] * 100.0 / denom, 1) if denom else 0.0
833
- totals["peak_tokens"] = max((bd["tokens"] for bd in by_day), default=0)
834
- totals["max_duration_s"] = round(max((float(r.get("duration_s") or 0.0) for r in records), default=0.0), 1)
835
- # 压缩省量(source=compaction 记录的 saved 合计):与消耗并列的第七维,
836
- # 台账从此能答「省了多少」;无压缩记录时为 0,老前端不读不受影响
837
- totals["compaction_saved"] = sum(_num(r, "saved") for r in records)
838
- # 体验指标:只统计可测记录(缺失与 0 都表示"这条没测到",不参与均值),
839
- # 否则 CLI 路径会把首字延迟平均数拉到 0,读数即假数。
840
- ft_list = sorted(_parse_float(r.get("first_token_ms")) for r in records
841
- if _parse_float(r.get("first_token_ms")) > 0)
842
- tps_list = [_parse_float(r.get("tokens_per_sec")) for r in records
843
- if _parse_float(r.get("tokens_per_sec")) > 0]
844
- totals["perf_samples"] = len(ft_list)
845
- totals["avg_first_token_ms"] = (round(sum(ft_list) / len(ft_list), 1)
846
- if ft_list else 0.0)
847
- totals["p95_first_token_ms"] = _percentile(ft_list, 0.95) if ft_list else 0.0
848
- totals["avg_tokens_per_sec"] = (round(sum(tps_list) / len(tps_list), 2)
849
- if tps_list else 0.0)
850
- cur, lng = _streaks(str(r.get("day") or "") for r in all_records)
851
- totals["streak_current"] = cur
852
- totals["streak_longest"] = lng
853
-
854
- recent = sorted(records, key=lambda r: str(r.get("ts", "")), reverse=True)[:recent_limit]
855
- return {
856
- "generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
857
- "range": {"days": span, "since": since.isoformat() if since else ""},
858
- "totals": totals,
859
- "by_day": by_day,
860
- "by_day_model": by_day_model,
861
- "all_by_day": all_by_day,
862
- "by_tool": _dim_rows(records, "tool"),
863
- "by_agent": _dim_rows(records, "agent"),
864
- "by_model": _dim_rows(records, "model", normalize=True),
865
- "by_role": _dim_rows(records, "role"),
866
- "by_task_type": _dim_rows(records, "task_type"),
867
- "recent": recent,
868
- }
869
-
870
-
871
- THIN_SAMPLES = 10 # 低于此样本数即「薄样本」,口径见执行标准第 6 条
872
-
873
- _DURATION_BASELINES = {
874
- "direct": 90, "rank_scan": 240, "email": 180, "weekly_report": 240,
875
- "translation": 240, "speech": 360, "video_script": 360, "article": 480,
876
- "doc": 480, "resume": 420, "code": 600, "tech_proposal": 720,
877
- "research": 900, "novel": 720, "serial_novel": 2400,
878
- "presentation": 480, "bid_doc": 720, "defect_retro": 240,
879
- }
880
-
881
-
882
- def estimate(task_type="", days=90, mode="auto", thinking="standard", rounds=None):
883
- """同类任务开跑前成本与耗时预估(数据源为本地用量台账)。
884
-
885
- 同一 run 的多条步骤记录加总为一个样本,优先用成功 run,给中位数/平均/P90。
886
- 无历史样本时使用流程级保守基线;模式与思考程度只做透明倍率修正。"""
887
- # days<=0(UI 的「全部」档)不能退化成「只扫今天」:基线会跟着窗口一起坍缩,
888
- # 于是全历史被估成 480 秒常数。按保守年限扫全量。
889
- span = 3650 if days is not None and int(days) <= 0 else max(1, min(3650, int(days or 90)))
890
- with LOCK:
891
- records = _iter_records(span)
892
- runs = {}
893
- for r in records:
894
- tt = str(r.get("task_type") or "unknown")
895
- if task_type and tt != task_type:
896
- continue
897
- rid = str(r.get("run_id") or "")
898
- if not rid:
899
- continue
900
- g = runs.setdefault(rid, {"tokens": 0, "cost": 0.0,
901
- "duration": 0.0, "ok": False})
902
- g["tokens"] += _num(r, "total")
903
- g["cost"] += _parse_float(r.get("cost_usd"))
904
- g["duration"] += max(0.0, _parse_float(r.get("duration_s")))
905
- if r.get("ok"):
906
- g["ok"] = True
907
- ok_runs = [g for g in runs.values() if g["ok"]]
908
- basis = ok_runs or list(runs.values())
909
- mode_factor = {"fast": 0.60, "auto": 1.0, "expert": 1.65,
910
- "manual": 1.0}.get(str(mode or "auto"), 1.0)
911
- thinking_factor = {"low": 0.78, "standard": 1.0, "high": 1.45,
912
- "auto": 1.0}.get(str(thinking or "standard"), 1.0)
913
- factor = mode_factor * thinking_factor
914
- try:
915
- if rounds is not None and int(rounds) > 2:
916
- factor *= 1.0 + min(3, int(rounds) - 2) * 0.18
917
- except (TypeError, ValueError):
918
- pass
919
- if not basis:
920
- # 全历史里一条同类样本都没有(article/doc 等低频类型)时才用基线,
921
- # 并在 duration_source 上如实标 baseline,UI 必须点名它不是实测。
922
- baseline = _DURATION_BASELINES.get(task_type, 480)
923
- median_s = max(30, int(baseline * factor))
924
- return {"task_type": task_type or "", "days": span, "samples": 0,
925
- "estimated_duration_s": median_s,
926
- "p90_duration_s": max(median_s + 30, int(median_s * 1.7)),
927
- # 标准「成本与时延预算」第 6 条:样本 <10 即薄样本,UI 必须点名,
928
- # 不能把"仅此一例"的历史当实测承诺。
929
- "thin": True, "min_samples_for_estimate": THIN_SAMPLES,
930
- "duration_source": "baseline"}
931
- toks = sorted(g["tokens"] for g in basis)
932
- costs = sorted(g["cost"] for g in basis)
933
- durations = sorted(g["duration"] for g in basis if g["duration"] > 0)
934
- n = len(toks)
935
- mid = n // 2
936
- med = toks[mid] if n % 2 else (toks[mid - 1] + toks[mid]) / 2.0
937
- med_c = costs[mid] if n % 2 else (costs[mid - 1] + costs[mid]) / 2.0
938
- import datetime
939
- import math
940
- if durations:
941
- dn = len(durations)
942
- dmid = dn // 2
943
- dmed = (durations[dmid] if dn % 2
944
- else (durations[dmid - 1] + durations[dmid]) / 2.0)
945
- dp90 = durations[max(0, min(dn - 1, math.ceil(0.9 * dn) - 1))]
946
- duration_source = "history"
947
- else:
948
- dmed = _DURATION_BASELINES.get(task_type, 480)
949
- dp90 = dmed * 1.7
950
- duration_source = "baseline"
951
- return {
952
- "task_type": task_type or "",
953
- "days": span,
954
- "samples": n,
955
- "ok_samples": len(ok_runs),
956
- "avg_tokens": int(sum(toks) / n),
957
- "median_tokens": int(med),
958
- "p90_tokens": toks[max(0, min(n - 1, math.ceil(0.9 * n) - 1))],
959
- "avg_cost_usd": round(sum(costs) / n, 4),
960
- "median_cost_usd": round(med_c, 4),
961
- "estimated_duration_s": max(30, int(dmed * factor)),
962
- "p90_duration_s": max(60, int(dp90 * factor)),
963
- "duration_samples": len(durations),
964
- "thin": n < THIN_SAMPLES, "min_samples_for_estimate": THIN_SAMPLES,
965
- "duration_source": duration_source,
966
- "since": (datetime.date.today() - datetime.timedelta(days=span - 1)).isoformat(),
967
- }
968
-
969
-
970
- def _audit_file(day):
971
- return paths.USAGE_DIR / ("estimate-audit-%s.jsonl" % day[:7].replace("-", ""))
972
-
973
-
974
- _AUDITED = {} # run_id -> 落盘路径;幂等去重,上限后整体清空防无界增长
975
- _AUDIT_LIMIT = 4000
976
- TERMINAL_STATUSES = ("done", "failed", "cancelled", "timeout")
977
-
978
-
979
- def audit_run(run):
980
- """事后对账:开跑前的预估快照 vs 本次真实用量,一条 run 一行。
981
-
982
- 预估值只取 run 上的快照(store.create_run 落的 estimated_duration_s 等),
983
- 绝不回读当前 estimate()——那会把本次刚写入的消耗算进"预估",比值永远偏低。
984
- 快照缺失(回填前的老 run、非编排类)时如实标 basis=none,不猜一个数充数。
985
- """
986
- try:
987
- run_id = str((run or {}).get("id") or "")
988
- if not run_id or run_id in _AUDITED:
989
- return None
990
- status = str(run.get("status") or "")
991
- if status not in TERMINAL_STATUSES:
992
- return None
993
- task_type = str(run.get("task_type") or (run.get("op") or {}).get("type") or "")
994
- est_s = _parse_float(run.get("estimated_duration_s"))
995
- est_p90 = _parse_float(run.get("estimated_p90_s"))
996
- basis = "stored"
997
- if est_s <= 0:
998
- # 快照缺失时退而用同类型历史口径,但必须落 basis=recomputed 标出来源
999
- snap = estimate(task_type=task_type, days=(run.get("estimate_days") or 90))
1000
- est_s = _parse_float(snap.get("estimated_duration_s"))
1001
- est_p90 = _parse_float(snap.get("p90_duration_s"))
1002
- basis = "recomputed" if est_s > 0 else "none"
1003
- actual_s = 0.0
1004
- actual_tokens = 0
1005
- for r in _iter_records(366):
1006
- if str(r.get("run_id") or "") != run_id:
1007
- continue
1008
- actual_s += max(0.0, _parse_float(r.get("duration_s")))
1009
- actual_tokens += _parse_int(r.get("total"))
1010
- rec = {
1011
- "ts": time.strftime("%Y-%m-%d %H:%M:%S"),
1012
- "day": time.strftime("%Y-%m-%d"),
1013
- "run_id": run_id[:64],
1014
- "task_id": str(run.get("task_id") or "")[:64],
1015
- "task_type": task_type[:32] or "unknown",
1016
- "status": status[:16],
1017
- "est_duration_s": round(est_s, 1),
1018
- "est_p90_s": round(est_p90, 1),
1019
- "est_source": str(run.get("estimate_source") or "")[:16],
1020
- "est_samples": _parse_int(run.get("estimate_samples")),
1021
- "est_basis": basis[:16],
1022
- "actual_duration_s": round(actual_s, 1),
1023
- "actual_tokens": actual_tokens,
1024
- "ratio": round(actual_s / est_s, 3) if est_s > 0 else 0.0,
1025
- "over_p90": bool(est_p90 > 0 and actual_s > est_p90),
1026
- }
1027
- line = json.dumps(rec, ensure_ascii=False)
1028
- path = _audit_file(rec["day"])
1029
- with LOCK:
1030
- path.parent.mkdir(parents=True, exist_ok=True)
1031
- with open(path, "a", encoding="utf-8") as f:
1032
- f.write(line + "\n")
1033
- if len(_AUDITED) >= _AUDIT_LIMIT:
1034
- _AUDITED.clear()
1035
- _AUDITED[run_id] = str(path)
1036
- return rec
1037
- except Exception:
1038
- 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
+ # ---------------------------------------------------------------- 用量版本(SSE 轻量事件)
23
+ # 台账每落一条记录就 bump 一次版本号。SSE 循环看到变化只推一个几十字节的
24
+ # usage 事件让前端刷新侧栏用量条,绝不为此构建/推送全量状态——运行中记账
25
+ # 是秒级频率,全量 payload 会被放大成推送风暴。版本号自带合并:两次读取
26
+ # 之间落多少条记录都只算一次变化。
27
+ _VER_CV = threading.Condition()
28
+ _USAGE_VER = 1
29
+
30
+
31
+ def bump_usage():
32
+ global _USAGE_VER
33
+ with _VER_CV:
34
+ _USAGE_VER += 1
35
+ _VER_CV.notify_all()
36
+
37
+
38
+ def usage_version():
39
+ return _USAGE_VER
40
+
41
+
42
+ # 维度 → 显示名(聚合接口与前端共用)
43
+ DIMENSIONS = {
44
+ "tool": "工具(CLI)",
45
+ "agent": "智能体",
46
+ "model": "模型",
47
+ "role": "步骤角色",
48
+ "task_type": "任务类型",
49
+ }
50
+
51
+ FIELDS = ("ts", "day", "run_id", "step", "task_id", "task_type", "role", "agent", "agent_label",
52
+ "tool", "model", "provider", "ok", "duration_s",
53
+ "input", "output", "cached", "reasoning", "total", "cost_usd", "source",
54
+ "first_token_ms", "tokens_per_sec")
55
+
56
+
57
+ def _month_file(day):
58
+ return paths.USAGE_DIR / ("usage-%s.jsonl" % day[:7].replace("-", ""))
59
+
60
+
61
+ def _quality_file(day):
62
+ return paths.USAGE_DIR / ("routing-quality-%s.jsonl" % day[:7].replace("-", ""))
63
+
64
+
65
+ def _parse_int(v):
66
+ try:
67
+ return int(v or 0)
68
+ except (TypeError, ValueError):
69
+ return 0
70
+
71
+
72
+ def _parse_float(v):
73
+ try:
74
+ return float(v or 0.0)
75
+ except (TypeError, ValueError):
76
+ return 0.0
77
+
78
+
79
+ def _parse_bool(v):
80
+ if v is None:
81
+ return False
82
+ return bool(v)
83
+
84
+
85
+ def record(source="", run_id="", task_id="", task_type="", role="", step=0,
86
+ agent="", agent_label="", tool="", model="", provider="",
87
+ ok=True, duration_s=0.0, cost_usd=0.0, usage=None,
88
+ first_token_ms=0.0, tokens_per_sec=0.0):
89
+ """追加一条用量记录。usage 为细分 dict:{input, output, cached, reasoning, total};
90
+ 缺省字段按 0 处理。任何异常都吞掉——统计永远不能拖垮业务调用方。
91
+
92
+ step 为该运行内的步骤号:与 backfill_from_runs 的去重键一致,缺了会导致
93
+ 启动回填把同一步骤重复入账。
94
+ """
95
+ try:
96
+ u = usage or {}
97
+ rec = {
98
+ "ts": time.strftime("%Y-%m-%d %H:%M:%S"),
99
+ "day": time.strftime("%Y-%m-%d"),
100
+ "source": str(source or "pipeline")[:24],
101
+ "run_id": str(run_id or "")[:64],
102
+ "step": _parse_int(step),
103
+ "task_id": str(task_id or "")[:64],
104
+ "task_type": str(task_type or "")[:32] or "unknown",
105
+ "role": str(role or "")[:40] or "unknown",
106
+ "agent": str(agent or "")[:40] or "unknown",
107
+ "agent_label": str(agent_label or "")[:60],
108
+ "tool": str(tool or "")[:24] or "unknown",
109
+ "model": str(model or "")[:80] or "(默认)",
110
+ "provider": str(provider or "")[:60],
111
+ "ok": _parse_bool(ok),
112
+ "duration_s": round(_parse_float(duration_s), 1),
113
+ "cost_usd": round(_parse_float(cost_usd), 4),
114
+ }
115
+ inp = max(0, _parse_int(u.get("input")))
116
+ out = max(0, _parse_int(u.get("output")))
117
+ cach = max(0, _parse_int(u.get("cached")))
118
+ reas = max(0, _parse_int(u.get("reasoning")))
119
+ total = _parse_int(u.get("total")) or (inp + out + cach)
120
+ rec.update({"input": inp, "output": out, "cached": cach,
121
+ "reasoning": reas, "total": total})
122
+ # 金额闭环:调用方没给费用(CLI/直连/评测普遍如此)→ 按标定单价折算
123
+ # (¥/百万 tokens;手标优先,回落导入价表;未标价 = 不记钱,宁缺毋滥)。
124
+ # 列名 cost_usd 沿用历史,语义 = 费用(¥)。
125
+ if _parse_float(rec.get("cost_usd")) <= 0 and (inp or out) \
126
+ and rec.get("model") not in ("", "(默认)"):
127
+ try:
128
+ from . import modelhub
129
+ pr = modelhub.model_price(str(rec["model"]), provider=str(rec["provider"]))
130
+ if pr:
131
+ rec["cost_usd"] = round((pr["in"] * inp + pr["out"] * out) / 1e6, 4)
132
+ except Exception:
133
+ pass
134
+ # 压缩省量注记(source=compaction 专用):上下文折叠净省的估算 token,
135
+ # 与消耗并列成账——省了多少不再是黑箱。零值不落字段防老记录膨胀。
136
+ saved = max(0, _parse_int(u.get("saved")))
137
+ if saved:
138
+ rec["saved"] = saved
139
+ # 体验指标(§07):只有内置直连的流式路径可测,CLI 事件流没有逐 token
140
+ # 时刻。零值不落字段——"缺字段"表示不可测,不能读成"首字 0 毫秒"。
141
+ ft = round(_parse_float(first_token_ms), 1)
142
+ if ft > 0:
143
+ rec["first_token_ms"] = ft
144
+ tps = round(_parse_float(tokens_per_sec), 2)
145
+ if tps > 0:
146
+ rec["tokens_per_sec"] = tps
147
+ line = json.dumps(rec, ensure_ascii=False)
148
+ with LOCK:
149
+ paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
150
+ with open(_month_file(rec["day"]), "a", encoding="utf-8") as f:
151
+ f.write(line + "\n")
152
+ _ROUTING_CACHE["data"].clear()
153
+ _ROUTING_RECORDS_CACHE.clear()
154
+ _HOURLY_CACHE["ts"] = 0.0
155
+ bump_usage() # 台账变了:SSE 推 usage 事件,侧栏用量条实时跟进
156
+ if _budget_caps_on():
157
+ # 花费预警(硬顶的事前半环):只在配了上限时起线程,绝不拖慢记账
158
+ threading.Thread(target=_budget_alert_check, daemon=True,
159
+ args=(rec["day"],)).start()
160
+ except Exception:
161
+ pass
162
+
163
+
164
+ def _budget_caps_on():
165
+ try:
166
+ from .settings_schema import get as ss_get, register_default_namespaces
167
+ register_default_namespaces()
168
+ return (float(ss_get("budget", "daily_cost_yuan") or 0) > 0
169
+ or float(ss_get("budget", "monthly_cost_yuan") or 0) > 0)
170
+ except Exception:
171
+ return False
172
+
173
+
174
+ _ALERT_PUSH = None # main.py 启动注入 notify.push_text——usage(L0) 不反向
175
+ # 依赖 notify(L1),静态图不成环(同 set_run_estimator 模式)
176
+
177
+
178
+ def set_alert_push(fn):
179
+ """注入花费预警的推送函数;未注入则预警静默跳过。"""
180
+ global _ALERT_PUSH
181
+ _ALERT_PUSH = fn if callable(fn) else None
182
+
183
+
184
+ def _budget_alert_check(day):
185
+ """花费预警推送(50%/80%,日/月各自去重一次):预警是硬顶的事前提醒,
186
+ 触发阈值状态落 data/budget_alerts.json(按天重置)。失败静默。"""
187
+ try:
188
+ from .settings_schema import get as ss_get, register_default_namespaces
189
+ register_default_namespaces()
190
+ dcap = float(ss_get("budget", "daily_cost_yuan") or 0)
191
+ mcap = float(ss_get("budget", "monthly_cost_yuan") or 0)
192
+ today, month = cost_snapshot()
193
+ marks = []
194
+ for scope, spent, cap in (("d", today, dcap), ("m", month, mcap)):
195
+ if cap <= 0 or spent <= 0:
196
+ continue
197
+ pct = spent / cap
198
+ for th in (0.8, 0.5):
199
+ if pct >= th:
200
+ marks.append((scope, int(th * 100), spent, cap))
201
+ break
202
+ if not marks:
203
+ return
204
+ sp = paths.DATA_DIR / "budget_alerts.json"
205
+ try:
206
+ st = json.loads(sp.read_text(encoding="utf-8"))
207
+ except Exception:
208
+ st = {}
209
+ if st.get("day") != day or not isinstance(st.get("done"), list):
210
+ st = {"day": day, "done": []}
211
+ done = st["done"]
212
+ new = [m for m in marks if ("%s%d" % (m[0], m[1])) not in done]
213
+ if not new:
214
+ return
215
+ st["done"] = done + ["%s%d" % (m[0], m[1]) for m in new]
216
+ sp.parent.mkdir(parents=True, exist_ok=True)
217
+ tmp = sp.with_suffix(".tmp")
218
+ tmp.write_text(json.dumps(st, ensure_ascii=False), encoding="utf-8")
219
+ tmp.replace(sp)
220
+ if not _ALERT_PUSH:
221
+ return
222
+ lines = ["💸 CodeBee 花费预警"]
223
+ for scope, pct, spent, cap in new:
224
+ lines.append("%s花费 ¥%.2f,已达%s上限 ¥%.2f 的 %d%%" % (
225
+ "今日" if scope == "d" else "本月", spent,
226
+ "每日" if scope == "d" else "每月", cap, pct))
227
+ lines.append("(达 100% 后新步骤将停止,可在设置→编排设置→预算调整)")
228
+ _ALERT_PUSH("\n".join(lines))
229
+ except Exception:
230
+ pass
231
+
232
+
233
+ EXPORT_COLUMNS = ("ts", "day", "source", "task_type", "role", "agent", "model",
234
+ "provider", "ok", "input", "output", "cached", "reasoning",
235
+ "total", "cost_usd")
236
+
237
+
238
+ def export_csv(days=30, max_rows=10000):
239
+ """台账明细导出 CSV(UTF-8 BOM,Excel 直开;行数封顶防炸)。返回 bytes。"""
240
+ import csv as _csv
241
+ import io as _io
242
+ buf = _io.StringIO()
243
+ writer = _csv.writer(buf)
244
+ writer.writerow(EXPORT_COLUMNS)
245
+ n = 0
246
+ for r in _iter_records(max(0, int(days or 0))):
247
+ writer.writerow([r.get(c, "") for c in EXPORT_COLUMNS])
248
+ n += 1
249
+ if n >= max_rows:
250
+ break
251
+ return b"\xef\xbb\xbf" + buf.getvalue().encode("utf-8")
252
+
253
+
254
+ def backfill_from_runs():
255
+ """把历史 run.json 里已记录的 token 用量回填进台账(启动时调用,幂等)。
256
+ 埋点是后加的:此前的运行只在 run.json 的步骤里存了 tokens/cost_usd 总量,
257
+ 没有输入/输出/缓存细分,所以回填记录 input/output/cached 记 0、只有 total,
258
+ 并以 source="backfill" 标记便于区分。已回填的 (run_id, 步骤号) 会跳过,
259
+ 重复启动不会产生重复记录。
260
+ """
261
+ try:
262
+ runs_dir = paths.RUNS_DIR
263
+ if not runs_dir.is_dir():
264
+ return 0
265
+ # 已入账的 (run_id, role, 步骤号) 集合——含真实埋点与既往回填
266
+ seen = set()
267
+ for r in _iter_records(0):
268
+ seen.add((str(r.get("run_id") or ""), str(r.get("role") or ""),
269
+ _parse_int(r.get("step"))))
270
+ added = 0
271
+ # 旧 run 未存任务类型,从 tasks/*.json 补齐(缺了就记 unknown)
272
+ task_types = {}
273
+ try:
274
+ for tp in paths.TASKS_DIR.glob("*.json"):
275
+ try:
276
+ t = json.loads(tp.read_text(encoding="utf-8"))
277
+ task_types[t.get("id")] = t.get("type") or ""
278
+ except Exception:
279
+ continue
280
+ except Exception:
281
+ pass
282
+ for p in sorted(runs_dir.glob("*/run.json")):
283
+ try:
284
+ run = json.loads(p.read_text(encoding="utf-8"))
285
+ except Exception:
286
+ continue
287
+ if not isinstance(run, dict):
288
+ continue
289
+ run_id = str(run.get("id") or p.parent.name)
290
+ day = str(run.get("started_at") or run.get("created_at") or "")[:10]
291
+ if not day:
292
+ continue
293
+ for s in (run.get("steps") or []):
294
+ if not isinstance(s, dict):
295
+ continue
296
+ total = _parse_int(s.get("tokens"))
297
+ if total <= 0:
298
+ continue # 无 token 的步骤(验证/合并/mock)不入账
299
+ n = _parse_int(s.get("n"))
300
+ key = (run_id, str(s.get("role") or ""), n)
301
+ if key in seen:
302
+ continue
303
+ seen.add(key)
304
+ rec = {
305
+ "ts": "%s %s" % (day, str(s.get("started_at") or "00:00:00")[:8]),
306
+ "day": day, "source": "backfill", "run_id": run_id,
307
+ "step": n,
308
+ "task_id": str(run.get("task_id") or ""),
309
+ "task_type": task_types.get(str(run.get("task_id") or "")) or "unknown",
310
+ "role": str(s.get("role") or "")[:40] or "unknown",
311
+ "agent": str(s.get("agent") or "")[:40] or "unknown",
312
+ "agent_label": str(s.get("agent_label") or "")[:60],
313
+ "tool": _tool_of(str(s.get("agent") or "")),
314
+ "model": str(s.get("model") or "")[:80] or "(历史未记录)",
315
+ "provider": "",
316
+ "ok": str(s.get("status") or "") == "done",
317
+ "duration_s": round(_parse_float(s.get("duration_s")), 1),
318
+ "cost_usd": round(_parse_float(s.get("cost_usd")), 4),
319
+ "input": 0, "output": 0, "cached": 0, "reasoning": 0,
320
+ "total": total,
321
+ }
322
+ with LOCK:
323
+ paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
324
+ with open(_month_file(day), "a", encoding="utf-8") as f:
325
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
326
+ added += 1
327
+ return added
328
+ except Exception:
329
+ return 0
330
+
331
+
332
+ # 智能体 id → 工具 kind(回填时旧记录没有 tool 字段,只能从 id 推断)
333
+ _AGENT_TOOL = {"codex-cli": "codex", "claude-code": "claude", "qwen-cli": "qwen",
334
+ "qwencode": "qwen", "opencode": "opencode", "aider": "aider",
335
+ "orchestrator": "orchestrator"}
336
+
337
+
338
+ def _tool_of(agent_id):
339
+ a = str(agent_id or "").lower()
340
+ if a in _AGENT_TOOL:
341
+ return _AGENT_TOOL[a]
342
+ for k, v in _AGENT_TOOL.items():
343
+ if a.startswith(k):
344
+ return v
345
+ return a or "unknown"
346
+
347
+
348
+ _HOURLY_CACHE = {"ts": 0.0, "val": {}}
349
+ _HOURLY_TTL = 60.0 # 秒:路由调用频繁但台账追加低频,60s 缓存足够新鲜
350
+ _ROUTING_CACHE = {"data": {}}
351
+ _ROUTING_RECORDS_CACHE = {}
352
+ _ROUTING_TTL = 15.0
353
+
354
+
355
+ def agent_tokens_recent(agent, hours=1):
356
+ """该智能体近 N 小时的 token 总量(路由配额惩罚用)。按 ts 前缀过滤,
357
+ 60 秒 TTL 进程内缓存——运行中每次路由都查也只扫两天的台账。"""
358
+ try:
359
+ agent = str(agent or "")
360
+ now = time.time()
361
+ if now - _HOURLY_CACHE["ts"] > _HOURLY_TTL:
362
+ bound = time.strftime("%Y-%m-%d %H:%M:%S",
363
+ time.localtime(now - hours * 3600.0))
364
+ total = {}
365
+ for r in _iter_records(2):
366
+ if str(r.get("ts") or "") < bound:
367
+ continue
368
+ a = str(r.get("agent") or "")
369
+ # record() 将规范化后的 token 总数落在顶层 ``total``;旧实现
370
+ # 误读不存在的嵌套 usage 字段,导致配额路由永远认为本小时
371
+ # 用量为 0,超过供应商额度也不会降权。
372
+ total[a] = total.get(a, 0) + max(0, _parse_int(r.get("total")))
373
+ _HOURLY_CACHE.update(ts=now, val=total)
374
+ return int(_HOURLY_CACHE["val"].get(agent) or 0)
375
+ except Exception:
376
+ return 0
377
+
378
+
379
+ # 文件级解析缓存:台账 append-only(写入只增不改前缀),按 (mtime_ns, size)
380
+ # 命中就复用已解析记录。用量条实时化后 summary 会被按秒级反复调用,缓存让
381
+ # 每次只重解析当天新落的小文件,历史月份不再逐次全量 json.loads。缓存的是
382
+ # 未过滤的全量记录,days 过滤在读出后做,同一份缓存可服务不同取数窗口。
383
+ # 读+stat 全程持 LOCK(RLock 可重入,summary 外层已持也不死锁),杜绝
384
+ # 「读到旧内容、却按新 mtime 入缓存」的竞态把新记录吞到下次追加才可见。
385
+ _FILE_CACHE = {}
386
+
387
+
388
+ def _iter_records(days):
389
+ """按时间范围读取台账(days=0 表示全部)。返回按写入顺序的记录列表。"""
390
+ out = []
391
+ try:
392
+ files = sorted(paths.USAGE_DIR.glob("usage-*.jsonl")) if paths.USAGE_DIR.is_dir() else []
393
+ except Exception:
394
+ return out
395
+ since_day = ""
396
+ if days:
397
+ import datetime
398
+ d = (datetime.date.today() - datetime.timedelta(days=int(days) - 1)).isoformat()
399
+ since_day = d
400
+ since_month = since_day[:7].replace("-", "") if since_day else ""
401
+ for p in files:
402
+ if since_month and p.stem.rsplit("-", 1)[-1] < since_month:
403
+ continue
404
+ try:
405
+ with LOCK:
406
+ st = p.stat()
407
+ key = str(p)
408
+ hit = _FILE_CACHE.get(key)
409
+ if hit and hit[0] == (st.st_mtime_ns, st.st_size):
410
+ recs = hit[1]
411
+ else:
412
+ recs = []
413
+ for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
414
+ line = line.strip()
415
+ if not line.startswith("{"):
416
+ continue
417
+ try:
418
+ r = json.loads(line)
419
+ except Exception:
420
+ continue
421
+ if not isinstance(r, dict):
422
+ continue
423
+ recs.append(r)
424
+ _FILE_CACHE[key] = ((st.st_mtime_ns, st.st_size), recs)
425
+ except Exception:
426
+ continue
427
+ for r in recs:
428
+ if since_day and str(r.get("day", "")) < since_day:
429
+ continue
430
+ out.append(r)
431
+ return out
432
+
433
+
434
+ def cost_snapshot():
435
+ """(今日实付, 当月实付) 美元快照——花费硬顶闸(pipeline 预算检查)用。
436
+
437
+ 复用 _iter_records 的按文件缓存;窗口取 31 天恒覆盖整个当月,
438
+ 单月文件量级下每次调用开销可忽略。任何异常返回 (0.0, 0.0)(闸放行,
439
+ 统计层故障不能反过来把业务锁死)。
440
+ """
441
+ try:
442
+ import datetime
443
+ today = datetime.date.today().isoformat()
444
+ month = today[:7]
445
+ t = m = 0.0
446
+ for r in _iter_records(31):
447
+ c = _parse_float(r.get("cost_usd"))
448
+ if c <= 0:
449
+ continue
450
+ d = str(r.get("day") or "")
451
+ if d == today:
452
+ t += c
453
+ m += c
454
+ elif d.startswith(month):
455
+ m += c
456
+ return round(t, 4), round(m, 4)
457
+ except Exception:
458
+ return 0.0, 0.0
459
+
460
+
461
+ def _iter_quality_records(days):
462
+ """读取独立质量台账;不混入用量统计的调用数、token 和成本。"""
463
+ out = []
464
+ try:
465
+ files = sorted(paths.USAGE_DIR.glob("routing-quality-*.jsonl")) \
466
+ if paths.USAGE_DIR.is_dir() else []
467
+ except Exception:
468
+ return out
469
+ since_day = ""
470
+ if days:
471
+ import datetime
472
+ since_day = (datetime.date.today()
473
+ - datetime.timedelta(days=int(days) - 1)).isoformat()
474
+ since_month = since_day[:7].replace("-", "") if since_day else ""
475
+ for path in files:
476
+ if since_month and path.stem.rsplit("-", 1)[-1] < since_month:
477
+ continue
478
+ try:
479
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
480
+ try:
481
+ row = json.loads(line)
482
+ except Exception:
483
+ continue
484
+ if isinstance(row, dict) and (not since_day or row.get("day", "") >= since_day):
485
+ out.append(row)
486
+ except Exception:
487
+ continue
488
+ return out
489
+
490
+
491
+ def _routing_records(days):
492
+ """同一轮候选评分共享一次台账解析,避免每个候选重复扫全盘。"""
493
+ key = (str(paths.USAGE_DIR), int(days))
494
+ now = time.time()
495
+ with LOCK:
496
+ cached = _ROUTING_RECORDS_CACHE.get(key)
497
+ if cached and now - cached[0] <= _ROUTING_TTL:
498
+ return list(cached[1]), list(cached[2])
499
+ calls, quality = _iter_records(days), _iter_quality_records(days)
500
+ with LOCK:
501
+ _ROUTING_RECORDS_CACHE[key] = (now, list(calls), list(quality))
502
+ return calls, quality
503
+
504
+
505
+ def record_quality_for_run(run_id, quality_ok, agent=""):
506
+ """把验收结论归因到最终实际产出者,不重复计入用量。"""
507
+ try:
508
+ run_id = str(run_id or "")[:64]
509
+ if not run_id:
510
+ return 0
511
+ calls = [r for r in _iter_records(2)
512
+ if r.get("run_id") == run_id and r.get("ok") is True]
513
+ prefixes = ("implement", "fix", "draft", "revise", "polish", "direct", "author")
514
+ calls = [r for r in calls if str(r.get("role") or "").lower().startswith(prefixes)]
515
+ agent = str(agent or "")[:40]
516
+ unique = {}
517
+ for row in calls:
518
+ key = tuple(str(row.get(k) or "") for k in
519
+ ("task_id", "task_type", "role", "agent", "model", "provider"))
520
+ unique[key] = row
521
+ if not unique:
522
+ return 0
523
+ existing = {(r.get("run_id"), r.get("role"), r.get("agent"),
524
+ r.get("model"), r.get("provider"))
525
+ for r in _iter_quality_records(2)}
526
+ day = time.strftime("%Y-%m-%d")
527
+ rows = []
528
+ for row in unique.values():
529
+ identity = (run_id, row.get("role"), row.get("agent"),
530
+ row.get("model"), row.get("provider"))
531
+ if identity in existing:
532
+ continue
533
+ # 成功调用不等于产出通过。换将前的实现者即使传输成功,也已被质量
534
+ # 门淘汰,必须留下负样本;最终实际产出者才继承本次验收结论。
535
+ row_quality_ok = bool(quality_ok)
536
+ if agent and str(row.get("agent") or "") != agent:
537
+ row_quality_ok = False
538
+ rows.append({"ts": time.strftime("%Y-%m-%d %H:%M:%S"), "day": day,
539
+ "run_id": run_id, "task_id": row.get("task_id") or "",
540
+ "task_type": row.get("task_type") or "unknown",
541
+ "role": row.get("role") or "unknown",
542
+ "agent": row.get("agent") or "unknown",
543
+ "model": row.get("model") or "(默认)",
544
+ "provider": row.get("provider") or "",
545
+ "quality_ok": row_quality_ok})
546
+ if not rows:
547
+ return 0
548
+ with LOCK:
549
+ paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
550
+ with open(_quality_file(day), "a", encoding="utf-8") as handle:
551
+ for row in rows:
552
+ handle.write(json.dumps(row, ensure_ascii=False) + "\n")
553
+ _ROUTING_CACHE["data"].clear()
554
+ _ROUTING_RECORDS_CACHE.clear()
555
+ return len(rows)
556
+ except Exception:
557
+ return 0
558
+
559
+
560
+ def _num(r, key):
561
+ return _parse_int(r.get(key))
562
+
563
+
564
+ def _percentile(values, percentile):
565
+ """线性插值百分位,空样本返回 0。"""
566
+ if not values:
567
+ return 0.0
568
+ ordered = sorted(float(v) for v in values)
569
+ if len(ordered) == 1:
570
+ return round(ordered[0], 2)
571
+ pos = (len(ordered) - 1) * float(percentile)
572
+ lo = int(math.floor(pos))
573
+ hi = int(math.ceil(pos))
574
+ if lo == hi:
575
+ return round(ordered[lo], 2)
576
+ return round(ordered[lo] + (ordered[hi] - ordered[lo]) * (pos - lo), 2)
577
+
578
+
579
+ def routing_stats(task_type="", role="", agent="", model="", provider="",
580
+ days=90, min_samples=3):
581
+ """返回路由用的近期真实运行指标。
582
+
583
+ 只读取脱敏用量台账;精确维度样本不足时依次回退到任务+角色、任务、
584
+ 全局,成功率使用 Beta(3, 1) 平滑,避免单次失败永久压低新候选。
585
+ """
586
+ try:
587
+ task_type = str(task_type or "")[:32]
588
+ role = str(role or "")[:40]
589
+ agent = str(agent or "")[:40]
590
+ model = str(model or "")[:80]
591
+ provider = str(provider or "")[:60]
592
+ days = max(0, int(days))
593
+ min_samples = max(1, int(min_samples))
594
+ except (TypeError, ValueError):
595
+ days, min_samples = 90, 3
596
+ # DATA_DIR 可在测试、多实例或运行时配置切换;纳入缓存键,避免跨目录
597
+ # 复用另一套台账的线上指标。
598
+ key = (str(paths.DATA_DIR), task_type, role, agent, model, provider,
599
+ days, min_samples)
600
+ now = time.time()
601
+ with LOCK:
602
+ cached = _ROUTING_CACHE["data"].get(key)
603
+ if cached and now - cached[0] <= _ROUTING_TTL:
604
+ return dict(cached[1])
605
+ records, quality_records = _routing_records(days)
606
+
607
+ def matches(record, filters):
608
+ return all(str(record.get(field) or "") == value
609
+ for field, value in filters.items() if value)
610
+
611
+ # 先保留所有已提供的维度;后续逐层放宽,确保模型和 CLI 都能共享一套查询。
612
+ exact = {"task_type": task_type, "role": role, "agent": agent,
613
+ "model": model, "provider": provider}
614
+ fallbacks = [(exact, "exact")]
615
+ task_role = {"task_type": task_type, "role": role}
616
+ if task_role != exact:
617
+ fallbacks.append((task_role, "task-role"))
618
+ if task_type:
619
+ fallbacks.append(({"task_type": task_type}, "task"))
620
+ fallbacks.append(({}, "global"))
621
+ selected, label = [], "global"
622
+ for filters, candidate_label in fallbacks:
623
+ candidate = [r for r in records if matches(r, filters)]
624
+ if len(candidate) >= min_samples or (candidate_label == "global" and candidate):
625
+ selected, label = candidate, candidate_label
626
+ break
627
+ chosen_filters = next((filters for filters, candidate_label in fallbacks
628
+ if candidate_label == label), {})
629
+ quality_selected = [r for r in quality_records if matches(r, chosen_filters)]
630
+ success_rows = quality_selected or selected
631
+ success_key = "quality_ok" if quality_selected else "ok"
632
+ successes = sum(1 for r in success_rows if bool(r.get(success_key)))
633
+ durations = [max(0.0, _parse_float(r.get("duration_s"))) for r in selected]
634
+ costs = [max(0.0, _parse_float(r.get("cost_usd"))) for r in selected]
635
+ samples = len(selected)
636
+ success_samples = len(success_rows)
637
+ result = {
638
+ "samples": samples,
639
+ "quality_samples": len(quality_selected),
640
+ "success_samples": success_samples,
641
+ "successes": successes,
642
+ "success_rate": round((successes + 3.0) / (success_samples + 4.0), 4),
643
+ "p50_duration_s": _percentile(durations, 0.50),
644
+ "p95_duration_s": _percentile(durations, 0.95),
645
+ "avg_cost_usd": round(sum(costs) / samples, 6) if samples else 0.0,
646
+ "fallback": label,
647
+ }
648
+ with LOCK:
649
+ _ROUTING_CACHE["data"][key] = (now, dict(result))
650
+ return result
651
+
652
+
653
+ def _group(records, key):
654
+ """按 key 聚合:{name: {calls, ok, tokens, input, output, cached, cache_rate, cost_usd, duration_s, saved}}。"""
655
+ groups = {}
656
+ for r in records:
657
+ name = str(r.get(key) or "") or "unknown"
658
+ g = groups.setdefault(name, {"calls": 0, "ok": 0, "tokens": 0,
659
+ "input": 0, "output": 0, "cached": 0,
660
+ "cost_usd": 0.0, "duration_s": 0.0,
661
+ "saved": 0})
662
+ g["calls"] += 1
663
+ if r.get("ok"):
664
+ g["ok"] += 1
665
+ g["tokens"] += _num(r, "total")
666
+ g["input"] += _num(r, "input")
667
+ g["output"] += _num(r, "output")
668
+ g["cached"] += _num(r, "cached")
669
+ g["saved"] += _num(r, "saved")
670
+ g["cost_usd"] = round(g["cost_usd"] + float(r.get("cost_usd") or 0.0), 4)
671
+ g["duration_s"] = round(g["duration_s"] + float(r.get("duration_s") or 0.0), 1)
672
+ # §07 验收指标:缓存命中率(cached / (input + cached)),与 totals.cache_rate 同口径
673
+ for g in groups.values():
674
+ denom = g["input"] + g["cached"]
675
+ g["cache_rate"] = round(g["cached"] * 100.0 / denom, 1) if denom else 0.0
676
+ return groups
677
+
678
+
679
+ def _model_display_map(records):
680
+ """模型名归一映射 {casefold: 展示名}:同一模型不同大小写(GLM-5.3-Flash 与
681
+ glm-5.3-flash)会被拆成两行统计,这里把 casefold 相同的归为一组,
682
+ 展示名取组内出现次数最多的原始写法(平局取更长写法,再平局取大写字母
683
+ 更多的规范形——台账倒序读入时 2:2 平局的裁断不再随记录顺序漂移)。"""
684
+ canon = {}
685
+ for r in records:
686
+ name = str(r.get("model") or "") or "unknown"
687
+ c = canon.setdefault(name.casefold(), {})
688
+ c["total"] = c.get("total", 0) + 1
689
+ c["names"] = c.get("names") or {}
690
+ c["names"][name] = (c["names"].get(name) or 0) + 1
691
+ display = {}
692
+ for k, c in canon.items():
693
+ display[k] = max(c["names"].items(),
694
+ key=lambda kv: (kv[1], len(kv[0]),
695
+ sum(ch.isupper() for ch in kv[0])))[0]
696
+ return display
697
+
698
+
699
+ def _remap_model(records, display):
700
+ """把 records 的 model 字段替换为归一后的展示名(不改动原记录)。"""
701
+ out = []
702
+ for r in records:
703
+ name = str(r.get("model") or "") or "unknown"
704
+ r2 = dict(r)
705
+ r2["model"] = display.get(name.casefold(), name)
706
+ out.append(r2)
707
+ return out
708
+
709
+
710
+ def _dim_rows(records, key, normalize=False):
711
+ rows = []
712
+ if normalize:
713
+ records = _remap_model(records, _model_display_map(records))
714
+ for name, g in _group(records, key).items():
715
+ rows.append({"key": name, **g})
716
+ rows.sort(key=lambda x: -x["tokens"])
717
+ return rows
718
+
719
+
720
+ def _streaks(dayset):
721
+ """连续活跃天数:current=从今天(今天没用量则从昨天)往回的连续天数;
722
+ longest=历史上最长连续段。dayset 为 ISO 日期字符串集合。"""
723
+ import datetime
724
+ ds = set()
725
+ for s in dayset:
726
+ try:
727
+ ds.add(datetime.date.fromisoformat(str(s)))
728
+ except (ValueError, TypeError):
729
+ continue
730
+ if not ds:
731
+ return 0, 0
732
+ today = datetime.date.today()
733
+ anchor = today if today in ds else today - datetime.timedelta(days=1)
734
+ current = 0
735
+ d = anchor
736
+ while d in ds:
737
+ current += 1
738
+ d -= datetime.timedelta(days=1)
739
+ longest = run = 0
740
+ prev = None
741
+ for d in sorted(ds):
742
+ run = run + 1 if (prev is not None and (d - prev).days == 1) else 1
743
+ longest = max(longest, run)
744
+ prev = d
745
+ return current, longest
746
+
747
+
748
+ def summary(days=30, recent_limit=30):
749
+ """多维聚合。days=0 表示全部历史。by_day 连续补零,方便前端直接画趋势。
750
+
751
+ 一次全量扫描后在内存里按范围过滤(_iter_records 本就逐文件全读,
752
+ 这样热力图/连续天数所需的全历史数据不再二次扫描):
753
+ - all_by_day:全历史按日 tokens(热力图、连续天数);
754
+ - by_day_model:范围内按日按模型 tokens(多模型趋势折线,模型名已归一);
755
+ - totals 增 peak_tokens / max_duration_s / streak_current / streak_longest。
756
+ """
757
+ import datetime
758
+ with LOCK:
759
+ all_records = _iter_records(0)
760
+ now = datetime.date.today()
761
+ span = int(days) if days else 0
762
+ since = (now - datetime.timedelta(days=max(span, 1) - 1)) if span else None
763
+ if span:
764
+ since_s = since.isoformat()
765
+ records = [r for r in all_records if str(r.get("day") or "") >= since_s]
766
+ else:
767
+ records = all_records
768
+
769
+ # 模型名归一:维度行与按日趋势共用同一展示名映射
770
+ model_display = _model_display_map(records)
771
+ model_records = _remap_model(records, model_display)
772
+
773
+ day_groups = _group(records, "day")
774
+ by_day = []
775
+ if span:
776
+ for i in range(span):
777
+ d = (now - datetime.timedelta(days=span - 1 - i)).isoformat()
778
+ g = day_groups.get(d, {})
779
+ by_day.append({"day": d, "calls": g.get("calls", 0),
780
+ "ok": g.get("ok", 0),
781
+ "tokens": g.get("tokens", 0),
782
+ "input": g.get("input", 0), "output": g.get("output", 0),
783
+ "cached": g.get("cached", 0),
784
+ "cache_rate": g.get("cache_rate", 0.0),
785
+ "duration_s": g.get("duration_s", 0.0),
786
+ "saved": g.get("saved", 0),
787
+ "cost_usd": round(g.get("cost_usd", 0.0), 4)})
788
+ else:
789
+ for d in sorted(day_groups):
790
+ g = day_groups[d]
791
+ by_day.append({"day": d, "calls": g["calls"], "ok": g["ok"],
792
+ "tokens": g["tokens"],
793
+ "input": g["input"], "output": g["output"],
794
+ "cached": g.get("cached", 0),
795
+ "cache_rate": g.get("cache_rate", 0.0),
796
+ "duration_s": g.get("duration_s", 0.0),
797
+ "saved": g.get("saved", 0),
798
+ "cost_usd": g["cost_usd"]})
799
+
800
+ # 按日按模型 tokens(范围内;与 by_day 同一天序列,无数据天给空表)
801
+ day_model = {}
802
+ for r in model_records:
803
+ d = str(r.get("day") or "")
804
+ if not d:
805
+ continue
806
+ m = day_model.setdefault(d, {})
807
+ name = str(r.get("model") or "unknown")
808
+ m[name] = m.get(name, 0) + _num(r, "total")
809
+ by_day_model = [{"day": bd["day"], "models": day_model.get(bd["day"], {})}
810
+ for bd in by_day]
811
+
812
+ # 全历史按日 tokens(热力图 / 连续天数;不补零,按日期排序)
813
+ all_day_groups = _group(all_records, "day")
814
+ all_by_day = [{"day": d, "tokens": all_day_groups[d]["tokens"]}
815
+ for d in sorted(all_day_groups)]
816
+
817
+ totals = {
818
+ "calls": len(records),
819
+ "ok": sum(1 for r in records if r.get("ok")),
820
+ "tokens": sum(_num(r, "total") for r in records),
821
+ "input": sum(_num(r, "input") for r in records),
822
+ "output": sum(_num(r, "output") for r in records),
823
+ "cached": sum(_num(r, "cached") for r in records),
824
+ "reasoning": sum(_num(r, "reasoning") for r in records),
825
+ "cost_usd": round(sum(float(r.get("cost_usd") or 0.0) for r in records), 4),
826
+ "duration_s": round(sum(float(r.get("duration_s") or 0.0) for r in records), 1),
827
+ }
828
+ totals["failed"] = totals["calls"] - totals["ok"]
829
+ totals["days_active"] = len(day_groups)
830
+ totals["avg_tokens_per_call"] = int(totals["tokens"] / totals["calls"]) if totals["calls"] else 0
831
+ denom = totals["input"] + totals["cached"]
832
+ totals["cache_rate"] = round(totals["cached"] * 100.0 / denom, 1) if denom else 0.0
833
+ totals["peak_tokens"] = max((bd["tokens"] for bd in by_day), default=0)
834
+ totals["max_duration_s"] = round(max((float(r.get("duration_s") or 0.0) for r in records), default=0.0), 1)
835
+ # 压缩省量(source=compaction 记录的 saved 合计):与消耗并列的第七维,
836
+ # 台账从此能答「省了多少」;无压缩记录时为 0,老前端不读不受影响
837
+ totals["compaction_saved"] = sum(_num(r, "saved") for r in records)
838
+ # 体验指标:只统计可测记录(缺失与 0 都表示"这条没测到",不参与均值),
839
+ # 否则 CLI 路径会把首字延迟平均数拉到 0,读数即假数。
840
+ ft_list = sorted(_parse_float(r.get("first_token_ms")) for r in records
841
+ if _parse_float(r.get("first_token_ms")) > 0)
842
+ tps_list = [_parse_float(r.get("tokens_per_sec")) for r in records
843
+ if _parse_float(r.get("tokens_per_sec")) > 0]
844
+ totals["perf_samples"] = len(ft_list)
845
+ totals["avg_first_token_ms"] = (round(sum(ft_list) / len(ft_list), 1)
846
+ if ft_list else 0.0)
847
+ totals["p95_first_token_ms"] = _percentile(ft_list, 0.95) if ft_list else 0.0
848
+ totals["avg_tokens_per_sec"] = (round(sum(tps_list) / len(tps_list), 2)
849
+ if tps_list else 0.0)
850
+ cur, lng = _streaks(str(r.get("day") or "") for r in all_records)
851
+ totals["streak_current"] = cur
852
+ totals["streak_longest"] = lng
853
+
854
+ recent = sorted(records, key=lambda r: str(r.get("ts", "")), reverse=True)[:recent_limit]
855
+ return {
856
+ "generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
857
+ "range": {"days": span, "since": since.isoformat() if since else ""},
858
+ "totals": totals,
859
+ "by_day": by_day,
860
+ "by_day_model": by_day_model,
861
+ "all_by_day": all_by_day,
862
+ "by_tool": _dim_rows(records, "tool"),
863
+ "by_agent": _dim_rows(records, "agent"),
864
+ "by_model": _dim_rows(records, "model", normalize=True),
865
+ "by_role": _dim_rows(records, "role"),
866
+ "by_task_type": _dim_rows(records, "task_type"),
867
+ "recent": recent,
868
+ }
869
+
870
+
871
+ THIN_SAMPLES = 10 # 低于此样本数即「薄样本」,口径见执行标准第 6 条
872
+
873
+ _DURATION_BASELINES = {
874
+ "direct": 90, "rank_scan": 240, "email": 180, "weekly_report": 240,
875
+ "translation": 240, "speech": 360, "video_script": 360, "article": 480,
876
+ "doc": 480, "resume": 420, "code": 600, "tech_proposal": 720,
877
+ "research": 900, "novel": 720, "serial_novel": 2400,
878
+ "presentation": 480, "bid_doc": 720, "defect_retro": 240,
879
+ }
880
+
881
+
882
+ def estimate(task_type="", days=90, mode="auto", thinking="standard", rounds=None):
883
+ """同类任务开跑前成本与耗时预估(数据源为本地用量台账)。
884
+
885
+ 同一 run 的多条步骤记录加总为一个样本,优先用成功 run,给中位数/平均/P90。
886
+ 无历史样本时使用流程级保守基线;模式与思考程度只做透明倍率修正。"""
887
+ # days<=0(UI 的「全部」档)不能退化成「只扫今天」:基线会跟着窗口一起坍缩,
888
+ # 于是全历史被估成 480 秒常数。按保守年限扫全量。
889
+ span = 3650 if days is not None and int(days) <= 0 else max(1, min(3650, int(days or 90)))
890
+ with LOCK:
891
+ records = _iter_records(span)
892
+ runs = {}
893
+ for r in records:
894
+ tt = str(r.get("task_type") or "unknown")
895
+ if task_type and tt != task_type:
896
+ continue
897
+ rid = str(r.get("run_id") or "")
898
+ if not rid:
899
+ continue
900
+ g = runs.setdefault(rid, {"tokens": 0, "cost": 0.0,
901
+ "duration": 0.0, "ok": False})
902
+ g["tokens"] += _num(r, "total")
903
+ g["cost"] += _parse_float(r.get("cost_usd"))
904
+ g["duration"] += max(0.0, _parse_float(r.get("duration_s")))
905
+ if r.get("ok"):
906
+ g["ok"] = True
907
+ ok_runs = [g for g in runs.values() if g["ok"]]
908
+ basis = ok_runs or list(runs.values())
909
+ mode_factor = {"fast": 0.60, "auto": 1.0, "expert": 1.65,
910
+ "manual": 1.0}.get(str(mode or "auto"), 1.0)
911
+ thinking_factor = {"low": 0.78, "standard": 1.0, "high": 1.45,
912
+ "auto": 1.0}.get(str(thinking or "standard"), 1.0)
913
+ factor = mode_factor * thinking_factor
914
+ try:
915
+ if rounds is not None and int(rounds) > 2:
916
+ factor *= 1.0 + min(3, int(rounds) - 2) * 0.18
917
+ except (TypeError, ValueError):
918
+ pass
919
+ if not basis:
920
+ # 全历史里一条同类样本都没有(article/doc 等低频类型)时才用基线,
921
+ # 并在 duration_source 上如实标 baseline,UI 必须点名它不是实测。
922
+ baseline = _DURATION_BASELINES.get(task_type, 480)
923
+ median_s = max(30, int(baseline * factor))
924
+ return {"task_type": task_type or "", "days": span, "samples": 0,
925
+ "estimated_duration_s": median_s,
926
+ "p90_duration_s": max(median_s + 30, int(median_s * 1.7)),
927
+ # 标准「成本与时延预算」第 6 条:样本 <10 即薄样本,UI 必须点名,
928
+ # 不能把"仅此一例"的历史当实测承诺。
929
+ "thin": True, "min_samples_for_estimate": THIN_SAMPLES,
930
+ "duration_source": "baseline"}
931
+ toks = sorted(g["tokens"] for g in basis)
932
+ costs = sorted(g["cost"] for g in basis)
933
+ durations = sorted(g["duration"] for g in basis if g["duration"] > 0)
934
+ n = len(toks)
935
+ mid = n // 2
936
+ med = toks[mid] if n % 2 else (toks[mid - 1] + toks[mid]) / 2.0
937
+ med_c = costs[mid] if n % 2 else (costs[mid - 1] + costs[mid]) / 2.0
938
+ import datetime
939
+ import math
940
+ if durations:
941
+ dn = len(durations)
942
+ dmid = dn // 2
943
+ dmed = (durations[dmid] if dn % 2
944
+ else (durations[dmid - 1] + durations[dmid]) / 2.0)
945
+ dp90 = durations[max(0, min(dn - 1, math.ceil(0.9 * dn) - 1))]
946
+ duration_source = "history"
947
+ else:
948
+ dmed = _DURATION_BASELINES.get(task_type, 480)
949
+ dp90 = dmed * 1.7
950
+ duration_source = "baseline"
951
+ return {
952
+ "task_type": task_type or "",
953
+ "days": span,
954
+ "samples": n,
955
+ "ok_samples": len(ok_runs),
956
+ "avg_tokens": int(sum(toks) / n),
957
+ "median_tokens": int(med),
958
+ "p90_tokens": toks[max(0, min(n - 1, math.ceil(0.9 * n) - 1))],
959
+ "avg_cost_usd": round(sum(costs) / n, 4),
960
+ "median_cost_usd": round(med_c, 4),
961
+ "estimated_duration_s": max(30, int(dmed * factor)),
962
+ "p90_duration_s": max(60, int(dp90 * factor)),
963
+ "duration_samples": len(durations),
964
+ "thin": n < THIN_SAMPLES, "min_samples_for_estimate": THIN_SAMPLES,
965
+ "duration_source": duration_source,
966
+ "since": (datetime.date.today() - datetime.timedelta(days=span - 1)).isoformat(),
967
+ }
968
+
969
+
970
+ def _audit_file(day):
971
+ return paths.USAGE_DIR / ("estimate-audit-%s.jsonl" % day[:7].replace("-", ""))
972
+
973
+
974
+ _AUDITED = {} # run_id -> 落盘路径;幂等去重,上限后整体清空防无界增长
975
+ _AUDIT_LIMIT = 4000
976
+ TERMINAL_STATUSES = ("done", "failed", "cancelled", "timeout")
977
+
978
+
979
+ def audit_run(run):
980
+ """事后对账:开跑前的预估快照 vs 本次真实用量,一条 run 一行。
981
+
982
+ 预估值只取 run 上的快照(store.create_run 落的 estimated_duration_s 等),
983
+ 绝不回读当前 estimate()——那会把本次刚写入的消耗算进"预估",比值永远偏低。
984
+ 快照缺失(回填前的老 run、非编排类)时如实标 basis=none,不猜一个数充数。
985
+ """
986
+ try:
987
+ run_id = str((run or {}).get("id") or "")
988
+ if not run_id or run_id in _AUDITED:
989
+ return None
990
+ status = str(run.get("status") or "")
991
+ if status not in TERMINAL_STATUSES:
992
+ return None
993
+ task_type = str(run.get("task_type") or (run.get("op") or {}).get("type") or "")
994
+ est_s = _parse_float(run.get("estimated_duration_s"))
995
+ est_p90 = _parse_float(run.get("estimated_p90_s"))
996
+ basis = "stored"
997
+ if est_s <= 0:
998
+ # 快照缺失时退而用同类型历史口径,但必须落 basis=recomputed 标出来源
999
+ snap = estimate(task_type=task_type, days=(run.get("estimate_days") or 90))
1000
+ est_s = _parse_float(snap.get("estimated_duration_s"))
1001
+ est_p90 = _parse_float(snap.get("p90_duration_s"))
1002
+ basis = "recomputed" if est_s > 0 else "none"
1003
+ actual_s = 0.0
1004
+ actual_tokens = 0
1005
+ for r in _iter_records(366):
1006
+ if str(r.get("run_id") or "") != run_id:
1007
+ continue
1008
+ actual_s += max(0.0, _parse_float(r.get("duration_s")))
1009
+ actual_tokens += _parse_int(r.get("total"))
1010
+ rec = {
1011
+ "ts": time.strftime("%Y-%m-%d %H:%M:%S"),
1012
+ "day": time.strftime("%Y-%m-%d"),
1013
+ "run_id": run_id[:64],
1014
+ "task_id": str(run.get("task_id") or "")[:64],
1015
+ "task_type": task_type[:32] or "unknown",
1016
+ "status": status[:16],
1017
+ "est_duration_s": round(est_s, 1),
1018
+ "est_p90_s": round(est_p90, 1),
1019
+ "est_source": str(run.get("estimate_source") or "")[:16],
1020
+ "est_samples": _parse_int(run.get("estimate_samples")),
1021
+ "est_basis": basis[:16],
1022
+ "actual_duration_s": round(actual_s, 1),
1023
+ "actual_tokens": actual_tokens,
1024
+ "ratio": round(actual_s / est_s, 3) if est_s > 0 else 0.0,
1025
+ "over_p90": bool(est_p90 > 0 and actual_s > est_p90),
1026
+ }
1027
+ line = json.dumps(rec, ensure_ascii=False)
1028
+ path = _audit_file(rec["day"])
1029
+ with LOCK:
1030
+ path.parent.mkdir(parents=True, exist_ok=True)
1031
+ with open(path, "a", encoding="utf-8") as f:
1032
+ f.write(line + "\n")
1033
+ if len(_AUDITED) >= _AUDIT_LIMIT:
1034
+ _AUDITED.clear()
1035
+ _AUDITED[run_id] = str(path)
1036
+ return rec
1037
+ except Exception:
1038
+ return None