codebee 0.1.0

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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/app/__init__.py +0 -0
  4. package/app/core/__init__.py +0 -0
  5. package/app/core/attachments.py +322 -0
  6. package/app/core/automation.py +585 -0
  7. package/app/core/bookmeta.py +296 -0
  8. package/app/core/capability.py +130 -0
  9. package/app/core/catalog.py +319 -0
  10. package/app/core/compaction.py +186 -0
  11. package/app/core/diagnostics.py +115 -0
  12. package/app/core/env_scrub.py +84 -0
  13. package/app/core/error_codes.py +65 -0
  14. package/app/core/flows.py +328 -0
  15. package/app/core/gitmod.py +949 -0
  16. package/app/core/goal_service.py +159 -0
  17. package/app/core/health.py +294 -0
  18. package/app/core/history.py +32 -0
  19. package/app/core/jobs.py +424 -0
  20. package/app/core/manager.py +1415 -0
  21. package/app/core/market.py +299 -0
  22. package/app/core/market_remote.py +896 -0
  23. package/app/core/mocks.py +64 -0
  24. package/app/core/modelhub.py +2750 -0
  25. package/app/core/paths.py +60 -0
  26. package/app/core/pipeline.py +2161 -0
  27. package/app/core/planner.py +493 -0
  28. package/app/core/registry.py +105 -0
  29. package/app/core/remote.py +303 -0
  30. package/app/core/repeat_guard.py +124 -0
  31. package/app/core/router.py +120 -0
  32. package/app/core/runner.py +856 -0
  33. package/app/core/selfupdate.py +170 -0
  34. package/app/core/session_log.py +162 -0
  35. package/app/core/sessions.py +312 -0
  36. package/app/core/settings.py +85 -0
  37. package/app/core/settings_schema.py +250 -0
  38. package/app/core/skillpacks/fanqie-novel.md +80 -0
  39. package/app/core/skillpacks/market/character-bible.md +66 -0
  40. package/app/core/skillpacks/market/code-risk-checklist.md +58 -0
  41. package/app/core/skillpacks/market/git-workflow.md +57 -0
  42. package/app/core/skillpacks/market/release-notes.md +72 -0
  43. package/app/core/skillpacks/market/weekly-report.md +71 -0
  44. package/app/core/skillpacks/market/worldview-consistency.md +70 -0
  45. package/app/core/skillpacks/qimao-signing.md +105 -0
  46. package/app/core/skills.py +649 -0
  47. package/app/core/step_runner.py +61 -0
  48. package/app/core/store.py +1321 -0
  49. package/app/core/token_meter.py +130 -0
  50. package/app/core/usage.py +450 -0
  51. package/app/main.py +1448 -0
  52. package/app/ui/app.js +8021 -0
  53. package/app/ui/i18n.js +1709 -0
  54. package/app/ui/icons/brand-horizontal.png +0 -0
  55. package/app/ui/icons/brand-square.png +0 -0
  56. package/app/ui/icons/icon-192.png +0 -0
  57. package/app/ui/icons/icon-512.png +0 -0
  58. package/app/ui/icons/logo-horizontal.png +0 -0
  59. package/app/ui/icons/logo-mark.png +0 -0
  60. package/app/ui/index.html +864 -0
  61. package/app/ui/manifest.json +16 -0
  62. package/app/ui/qrcode.js +2297 -0
  63. package/app/ui/style.css +2733 -0
  64. package/bin/tutti.js +121 -0
  65. package/package.json +39 -0
@@ -0,0 +1,130 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Token 压力估算:按 run 累计 usage,给出「已用/容量」压力比。
3
+
4
+ 设计稿:docs/migration/02-context-compaction.md §1C。
5
+ 参考 dsh packages/llm/token-meter/src/index.ts:滑动窗口 + usage 锚点 + 失败重定价。
6
+
7
+ Tutti 简化实现:每 run 一个累计窗口(deque 有上限),usage 来自
8
+ runner._parse_codex_jsonl / _parse_claude_json 已解析的字段;
9
+ 容量查 DEFAULT_CAPACITY 表(可被 data/model_capacity.json 覆盖)。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import threading
16
+ import time
17
+ from collections import deque
18
+ from pathlib import Path
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ # 默认模型容量(token)。未命中 key 时用 "" 兜底行。
23
+ DEFAULT_CAPACITY = {
24
+ "": 128_000,
25
+ "gpt-5": 400_000,
26
+ "gpt-5.5": 400_000,
27
+ "deepseek-chat": 128_000,
28
+ "deepseek-reasoner": 64_000,
29
+ "qwen3-max": 256_000,
30
+ "qwen3.8-flash": 256_000,
31
+ }
32
+
33
+ # 压缩触发阈值(设计稿 §1B 默认 0.8,可被 orchestration.json 覆盖)
34
+ DEFAULT_PRESSURE_THRESHOLD = 0.8
35
+
36
+ # 容量覆盖文件(热改立即生效)
37
+ _CAPACITY_FILE = Path(__file__).resolve().parents[2] / "data" / "model_capacity.json"
38
+
39
+
40
+ def _load_capacity():
41
+ """默认表 + 可选覆盖文件合并。文件缺失/坏 JSON 静默用默认。"""
42
+ cap = dict(DEFAULT_CAPACITY)
43
+ try:
44
+ if _CAPACITY_FILE.is_file():
45
+ data = json.loads(_CAPACITY_FILE.read_text(encoding="utf-8"))
46
+ if isinstance(data, dict):
47
+ cap.update({str(k): int(v) for k, v in data.items()})
48
+ except Exception:
49
+ pass
50
+ return cap
51
+
52
+
53
+ class TokenMeter:
54
+ """每 run 独立累计窗口;线程安全。"""
55
+
56
+ def __init__(self, capacity=None, *, window_size=200):
57
+ self._capacity_override = capacity
58
+ self._window_size = window_size
59
+ self._lock = threading.Lock()
60
+ # run_id -> deque[(ts, input, output, reasoning, cached)]
61
+ self._windows: dict = {}
62
+ # run_id -> 最后一次见到的 model 名(pressure_ratio 未显式传 model 时用)
63
+ self._last_model: dict = {}
64
+ self._cap_cache = None
65
+ self._cap_mtime = 0.0
66
+
67
+ def _capacity(self):
68
+ """容量表(带 mtime 缓存,热改 data/model_capacity.json 立即生效)。"""
69
+ try:
70
+ mtime = _CAPACITY_FILE.stat().st_mtime
71
+ except OSError:
72
+ mtime = 0.0
73
+ if self._cap_cache is None or mtime != self._cap_mtime:
74
+ if self._capacity_override is not None:
75
+ self._cap_cache = dict(self._capacity_override)
76
+ else:
77
+ self._cap_cache = _load_capacity()
78
+ self._cap_mtime = mtime
79
+ return self._cap_cache
80
+
81
+ def accumulate(self, run_id: str, usage, model: str = ""):
82
+ """记录一次调用的 usage。usage 字段缺失按 0 处理;usage=None 跳过。
83
+
84
+ cached 单独记录但不计入 used()——缓存读不占新一轮上下文压力
85
+ (与 runner._parse_claude_json 的口径一致:cached 是省钱的部分)。
86
+ """
87
+ if not usage:
88
+ return
89
+ with self._lock:
90
+ if model:
91
+ self._last_model[run_id] = model
92
+ window = self._windows.setdefault(run_id, deque(maxlen=self._window_size))
93
+ window.append((
94
+ time.time(),
95
+ int(usage.get("input") or 0),
96
+ int(usage.get("output") or 0),
97
+ int(usage.get("reasoning") or 0),
98
+ int(usage.get("cached") or 0),
99
+ ))
100
+
101
+ def used(self, run_id: str) -> int:
102
+ """窗口内累计(input + output + reasoning),不含 cached。"""
103
+ with self._lock:
104
+ window = self._windows.get(run_id)
105
+ if not window:
106
+ return 0
107
+ return sum(i + o + r for _, i, o, r, _ in window)
108
+
109
+ def cached(self, run_id: str) -> int:
110
+ with self._lock:
111
+ window = self._windows.get(run_id)
112
+ if not window:
113
+ return 0
114
+ return sum(c for _, _, _, _, c in window)
115
+
116
+ def pressure_ratio(self, run_id: str, model: str = "") -> float:
117
+ """已用 / 容量。model 未传时用该 run 最后一次见到的模型名。"""
118
+ cap_table = self._capacity()
119
+ with self._lock:
120
+ m = model or self._last_model.get(run_id, "")
121
+ cap = cap_table.get(m, cap_table.get("", 128_000))
122
+ return self.used(run_id) / max(cap, 1)
123
+
124
+ def reset(self, run_id: str):
125
+ with self._lock:
126
+ self._windows.pop(run_id, None)
127
+ self._last_model.pop(run_id, None)
128
+
129
+
130
+ token_meter = TokenMeter() # 单例
@@ -0,0 +1,450 @@
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 threading
15
+ import time
16
+
17
+ from . import paths
18
+
19
+ LOCK = threading.RLock()
20
+
21
+ # 维度 → 显示名(聚合接口与前端共用)
22
+ DIMENSIONS = {
23
+ "tool": "工具(CLI)",
24
+ "agent": "智能体",
25
+ "model": "模型",
26
+ "role": "步骤角色",
27
+ "task_type": "任务类型",
28
+ }
29
+
30
+ FIELDS = ("ts", "day", "run_id", "step", "task_id", "task_type", "role", "agent", "agent_label",
31
+ "tool", "model", "provider", "ok", "duration_s",
32
+ "input", "output", "cached", "reasoning", "total", "cost_usd", "source")
33
+
34
+
35
+ def _month_file(day):
36
+ return paths.USAGE_DIR / ("usage-%s.jsonl" % day[:7].replace("-", ""))
37
+
38
+
39
+ def _parse_int(v):
40
+ try:
41
+ return int(v or 0)
42
+ except (TypeError, ValueError):
43
+ return 0
44
+
45
+
46
+ def _parse_float(v):
47
+ try:
48
+ return float(v or 0.0)
49
+ except (TypeError, ValueError):
50
+ return 0.0
51
+
52
+
53
+ def _parse_bool(v):
54
+ if v is None:
55
+ return False
56
+ return bool(v)
57
+
58
+
59
+ def record(source="", run_id="", task_id="", task_type="", role="", step=0,
60
+ agent="", agent_label="", tool="", model="", provider="",
61
+ ok=True, duration_s=0.0, cost_usd=0.0, usage=None):
62
+ """追加一条用量记录。usage 为细分 dict:{input, output, cached, reasoning, total};
63
+ 缺省字段按 0 处理。任何异常都吞掉——统计永远不能拖垮业务调用方。
64
+
65
+ step 为该运行内的步骤号:与 backfill_from_runs 的去重键一致,缺了会导致
66
+ 启动回填把同一步骤重复入账。
67
+ """
68
+ try:
69
+ u = usage or {}
70
+ rec = {
71
+ "ts": time.strftime("%Y-%m-%d %H:%M:%S"),
72
+ "day": time.strftime("%Y-%m-%d"),
73
+ "source": str(source or "pipeline")[:24],
74
+ "run_id": str(run_id or "")[:64],
75
+ "step": _parse_int(step),
76
+ "task_id": str(task_id or "")[:64],
77
+ "task_type": str(task_type or "")[:32] or "unknown",
78
+ "role": str(role or "")[:40] or "unknown",
79
+ "agent": str(agent or "")[:40] or "unknown",
80
+ "agent_label": str(agent_label or "")[:60],
81
+ "tool": str(tool or "")[:24] or "unknown",
82
+ "model": str(model or "")[:80] or "(默认)",
83
+ "provider": str(provider or "")[:60],
84
+ "ok": _parse_bool(ok),
85
+ "duration_s": round(_parse_float(duration_s), 1),
86
+ "cost_usd": round(_parse_float(cost_usd), 4),
87
+ }
88
+ inp = max(0, _parse_int(u.get("input")))
89
+ out = max(0, _parse_int(u.get("output")))
90
+ cach = max(0, _parse_int(u.get("cached")))
91
+ reas = max(0, _parse_int(u.get("reasoning")))
92
+ total = _parse_int(u.get("total")) or (inp + out + cach)
93
+ rec.update({"input": inp, "output": out, "cached": cach,
94
+ "reasoning": reas, "total": total})
95
+ line = json.dumps(rec, ensure_ascii=False)
96
+ with LOCK:
97
+ paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
98
+ with open(_month_file(rec["day"]), "a", encoding="utf-8") as f:
99
+ f.write(line + "\n")
100
+ except Exception:
101
+ pass
102
+
103
+
104
+ def backfill_from_runs():
105
+ """把历史 run.json 里已记录的 token 用量回填进台账(启动时调用,幂等)。
106
+
107
+ 埋点是后加的:此前的运行只在 run.json 的步骤里存了 tokens/cost_usd 总量,
108
+ 没有输入/输出/缓存细分,所以回填记录 input/output/cached 记 0、只有 total,
109
+ 并以 source="backfill" 标记便于区分。已回填的 (run_id, 步骤号) 会跳过,
110
+ 重复启动不会产生重复记录。
111
+ """
112
+ try:
113
+ runs_dir = paths.RUNS_DIR
114
+ if not runs_dir.is_dir():
115
+ return 0
116
+ # 已入账的 (run_id, role, 步骤号) 集合——含真实埋点与既往回填
117
+ seen = set()
118
+ for r in _iter_records(0):
119
+ seen.add((str(r.get("run_id") or ""), str(r.get("role") or ""),
120
+ _parse_int(r.get("step"))))
121
+ added = 0
122
+ # 旧 run 未存任务类型,从 tasks/*.json 补齐(缺了就记 unknown)
123
+ task_types = {}
124
+ try:
125
+ for tp in paths.TASKS_DIR.glob("*.json"):
126
+ try:
127
+ t = json.loads(tp.read_text(encoding="utf-8"))
128
+ task_types[t.get("id")] = t.get("type") or ""
129
+ except Exception:
130
+ continue
131
+ except Exception:
132
+ pass
133
+ for p in sorted(runs_dir.glob("*/run.json")):
134
+ try:
135
+ run = json.loads(p.read_text(encoding="utf-8"))
136
+ except Exception:
137
+ continue
138
+ if not isinstance(run, dict):
139
+ continue
140
+ run_id = str(run.get("id") or p.parent.name)
141
+ day = str(run.get("started_at") or run.get("created_at") or "")[:10]
142
+ if not day:
143
+ continue
144
+ for s in (run.get("steps") or []):
145
+ if not isinstance(s, dict):
146
+ continue
147
+ total = _parse_int(s.get("tokens"))
148
+ if total <= 0:
149
+ continue # 无 token 的步骤(验证/合并/mock)不入账
150
+ n = _parse_int(s.get("n"))
151
+ key = (run_id, str(s.get("role") or ""), n)
152
+ if key in seen:
153
+ continue
154
+ seen.add(key)
155
+ rec = {
156
+ "ts": "%s %s" % (day, str(s.get("started_at") or "00:00:00")[:8]),
157
+ "day": day, "source": "backfill", "run_id": run_id,
158
+ "step": n,
159
+ "task_id": str(run.get("task_id") or ""),
160
+ "task_type": task_types.get(str(run.get("task_id") or "")) or "unknown",
161
+ "role": str(s.get("role") or "")[:40] or "unknown",
162
+ "agent": str(s.get("agent") or "")[:40] or "unknown",
163
+ "agent_label": str(s.get("agent_label") or "")[:60],
164
+ "tool": _tool_of(str(s.get("agent") or "")),
165
+ "model": str(s.get("model") or "")[:80] or "(历史未记录)",
166
+ "provider": "",
167
+ "ok": str(s.get("status") or "") == "done",
168
+ "duration_s": round(_parse_float(s.get("duration_s")), 1),
169
+ "cost_usd": round(_parse_float(s.get("cost_usd")), 4),
170
+ "input": 0, "output": 0, "cached": 0, "reasoning": 0,
171
+ "total": total,
172
+ }
173
+ with LOCK:
174
+ paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
175
+ with open(_month_file(day), "a", encoding="utf-8") as f:
176
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
177
+ added += 1
178
+ return added
179
+ except Exception:
180
+ return 0
181
+
182
+
183
+ # 智能体 id → 工具 kind(回填时旧记录没有 tool 字段,只能从 id 推断)
184
+ _AGENT_TOOL = {"codex-cli": "codex", "claude-code": "claude", "qwen-cli": "qwen",
185
+ "qwencode": "qwen", "opencode": "opencode", "aider": "aider",
186
+ "orchestrator": "orchestrator"}
187
+
188
+
189
+ def _tool_of(agent_id):
190
+ a = str(agent_id or "").lower()
191
+ if a in _AGENT_TOOL:
192
+ return _AGENT_TOOL[a]
193
+ for k, v in _AGENT_TOOL.items():
194
+ if a.startswith(k):
195
+ return v
196
+ return a or "unknown"
197
+
198
+
199
+ _HOURLY_CACHE = {"ts": 0.0, "val": {}}
200
+ _HOURLY_TTL = 60.0 # 秒:路由调用频繁但台账追加低频,60s 缓存足够新鲜
201
+
202
+
203
+ def agent_tokens_recent(agent, hours=1):
204
+ """该智能体近 N 小时的 token 总量(路由配额惩罚用)。按 ts 前缀过滤,
205
+ 60 秒 TTL 进程内缓存——运行中每次路由都查也只扫两天的台账。"""
206
+ try:
207
+ agent = str(agent or "")
208
+ now = time.time()
209
+ if now - _HOURLY_CACHE["ts"] > _HOURLY_TTL:
210
+ bound = time.strftime("%Y-%m-%d %H:%M:%S",
211
+ time.localtime(now - hours * 3600.0))
212
+ total = {}
213
+ for r in _iter_records(2):
214
+ if str(r.get("ts") or "") < bound:
215
+ continue
216
+ a = str(r.get("agent") or "")
217
+ total[a] = total.get(a, 0) + max(0, _parse_int((r.get("usage") or {}).get("total")))
218
+ _HOURLY_CACHE.update(ts=now, val=total)
219
+ return int(_HOURLY_CACHE["val"].get(agent) or 0)
220
+ except Exception:
221
+ return 0
222
+
223
+
224
+ def _iter_records(days):
225
+ """按时间范围读取台账(days=0 表示全部)。返回按写入顺序的记录列表。"""
226
+ out = []
227
+ try:
228
+ files = sorted(paths.USAGE_DIR.glob("usage-*.jsonl")) if paths.USAGE_DIR.is_dir() else []
229
+ except Exception:
230
+ return out
231
+ since_day = ""
232
+ if days:
233
+ import datetime
234
+ d = (datetime.date.today() - datetime.timedelta(days=int(days) - 1)).isoformat()
235
+ since_day = d
236
+ for p in files:
237
+ try:
238
+ for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
239
+ line = line.strip()
240
+ if not line.startswith("{"):
241
+ continue
242
+ try:
243
+ r = json.loads(line)
244
+ except Exception:
245
+ continue
246
+ if not isinstance(r, dict):
247
+ continue
248
+ if since_day and str(r.get("day", "")) < since_day:
249
+ continue
250
+ out.append(r)
251
+ except Exception:
252
+ continue
253
+ return out
254
+
255
+
256
+ def _num(r, key):
257
+ return _parse_int(r.get(key))
258
+
259
+
260
+ def _group(records, key):
261
+ """按 key 聚合:{name: {calls, ok, tokens, input, output, cached, cache_rate, cost_usd, duration_s}}。"""
262
+ groups = {}
263
+ for r in records:
264
+ name = str(r.get(key) or "") or "unknown"
265
+ g = groups.setdefault(name, {"calls": 0, "ok": 0, "tokens": 0,
266
+ "input": 0, "output": 0, "cached": 0,
267
+ "cost_usd": 0.0, "duration_s": 0.0})
268
+ g["calls"] += 1
269
+ if r.get("ok"):
270
+ g["ok"] += 1
271
+ g["tokens"] += _num(r, "total")
272
+ g["input"] += _num(r, "input")
273
+ g["output"] += _num(r, "output")
274
+ g["cached"] += _num(r, "cached")
275
+ g["cost_usd"] = round(g["cost_usd"] + float(r.get("cost_usd") or 0.0), 4)
276
+ g["duration_s"] = round(g["duration_s"] + float(r.get("duration_s") or 0.0), 1)
277
+ # §07 验收指标:缓存命中率(cached / (input + cached)),与 totals.cache_rate 同口径
278
+ for g in groups.values():
279
+ denom = g["input"] + g["cached"]
280
+ g["cache_rate"] = round(g["cached"] * 100.0 / denom, 1) if denom else 0.0
281
+ return groups
282
+
283
+
284
+ def _model_display_map(records):
285
+ """模型名归一映射 {casefold: 展示名}:同一模型不同大小写(GLM-5.3-Flash 与
286
+ glm-5.3-flash)会被拆成两行统计,这里把 casefold 相同的归为一组,
287
+ 展示名取组内出现次数最多的原始写法(平局取更长写法,信息更全)。"""
288
+ canon = {}
289
+ for r in records:
290
+ name = str(r.get("model") or "") or "unknown"
291
+ c = canon.setdefault(name.casefold(), {})
292
+ c["total"] = c.get("total", 0) + 1
293
+ c["names"] = c.get("names") or {}
294
+ c["names"][name] = (c["names"].get(name) or 0) + 1
295
+ display = {}
296
+ for k, c in canon.items():
297
+ display[k] = max(c["names"].items(),
298
+ key=lambda kv: (kv[1], len(kv[0])))[0]
299
+ return display
300
+
301
+
302
+ def _remap_model(records, display):
303
+ """把 records 的 model 字段替换为归一后的展示名(不改动原记录)。"""
304
+ out = []
305
+ for r in records:
306
+ name = str(r.get("model") or "") or "unknown"
307
+ r2 = dict(r)
308
+ r2["model"] = display.get(name.casefold(), name)
309
+ out.append(r2)
310
+ return out
311
+
312
+
313
+ def _dim_rows(records, key, normalize=False):
314
+ rows = []
315
+ if normalize:
316
+ records = _remap_model(records, _model_display_map(records))
317
+ for name, g in _group(records, key).items():
318
+ rows.append({"key": name, **g})
319
+ rows.sort(key=lambda x: -x["tokens"])
320
+ return rows
321
+
322
+
323
+ def _streaks(dayset):
324
+ """连续活跃天数:current=从今天(今天没用量则从昨天)往回的连续天数;
325
+ longest=历史上最长连续段。dayset 为 ISO 日期字符串集合。"""
326
+ import datetime
327
+ ds = set()
328
+ for s in dayset:
329
+ try:
330
+ ds.add(datetime.date.fromisoformat(str(s)))
331
+ except (ValueError, TypeError):
332
+ continue
333
+ if not ds:
334
+ return 0, 0
335
+ today = datetime.date.today()
336
+ anchor = today if today in ds else today - datetime.timedelta(days=1)
337
+ current = 0
338
+ d = anchor
339
+ while d in ds:
340
+ current += 1
341
+ d -= datetime.timedelta(days=1)
342
+ longest = run = 0
343
+ prev = None
344
+ for d in sorted(ds):
345
+ run = run + 1 if (prev is not None and (d - prev).days == 1) else 1
346
+ longest = max(longest, run)
347
+ prev = d
348
+ return current, longest
349
+
350
+
351
+ def summary(days=30, recent_limit=30):
352
+ """多维聚合。days=0 表示全部历史。by_day 连续补零,方便前端直接画趋势。
353
+
354
+ 一次全量扫描后在内存里按范围过滤(_iter_records 本就逐文件全读,
355
+ 这样热力图/连续天数所需的全历史数据不再二次扫描):
356
+ - all_by_day:全历史按日 tokens(热力图、连续天数);
357
+ - by_day_model:范围内按日按模型 tokens(多模型趋势折线,模型名已归一);
358
+ - totals 增 peak_tokens / max_duration_s / streak_current / streak_longest。
359
+ """
360
+ import datetime
361
+ with LOCK:
362
+ all_records = _iter_records(0)
363
+ now = datetime.date.today()
364
+ span = int(days) if days else 0
365
+ since = (now - datetime.timedelta(days=max(span, 1) - 1)) if span else None
366
+ if span:
367
+ since_s = since.isoformat()
368
+ records = [r for r in all_records if str(r.get("day") or "") >= since_s]
369
+ else:
370
+ records = all_records
371
+
372
+ # 模型名归一:维度行与按日趋势共用同一展示名映射
373
+ model_display = _model_display_map(records)
374
+ model_records = _remap_model(records, model_display)
375
+
376
+ day_groups = _group(records, "day")
377
+ by_day = []
378
+ if span:
379
+ for i in range(span):
380
+ d = (now - datetime.timedelta(days=span - 1 - i)).isoformat()
381
+ g = day_groups.get(d, {})
382
+ by_day.append({"day": d, "calls": g.get("calls", 0),
383
+ "tokens": g.get("tokens", 0),
384
+ "input": g.get("input", 0), "output": g.get("output", 0),
385
+ "cached": g.get("cached", 0),
386
+ "cache_rate": g.get("cache_rate", 0.0),
387
+ "cost_usd": round(g.get("cost_usd", 0.0), 4)})
388
+ else:
389
+ for d in sorted(day_groups):
390
+ g = day_groups[d]
391
+ by_day.append({"day": d, "calls": g["calls"], "tokens": g["tokens"],
392
+ "input": g["input"], "output": g["output"],
393
+ "cached": g.get("cached", 0),
394
+ "cache_rate": g.get("cache_rate", 0.0),
395
+ "cost_usd": g["cost_usd"]})
396
+
397
+ # 按日按模型 tokens(范围内;与 by_day 同一天序列,无数据天给空表)
398
+ day_model = {}
399
+ for r in model_records:
400
+ d = str(r.get("day") or "")
401
+ if not d:
402
+ continue
403
+ m = day_model.setdefault(d, {})
404
+ name = str(r.get("model") or "unknown")
405
+ m[name] = m.get(name, 0) + _num(r, "total")
406
+ by_day_model = [{"day": bd["day"], "models": day_model.get(bd["day"], {})}
407
+ for bd in by_day]
408
+
409
+ # 全历史按日 tokens(热力图 / 连续天数;不补零,按日期排序)
410
+ all_day_groups = _group(all_records, "day")
411
+ all_by_day = [{"day": d, "tokens": all_day_groups[d]["tokens"]}
412
+ for d in sorted(all_day_groups)]
413
+
414
+ totals = {
415
+ "calls": len(records),
416
+ "ok": sum(1 for r in records if r.get("ok")),
417
+ "tokens": sum(_num(r, "total") for r in records),
418
+ "input": sum(_num(r, "input") for r in records),
419
+ "output": sum(_num(r, "output") for r in records),
420
+ "cached": sum(_num(r, "cached") for r in records),
421
+ "reasoning": sum(_num(r, "reasoning") for r in records),
422
+ "cost_usd": round(sum(float(r.get("cost_usd") or 0.0) for r in records), 4),
423
+ "duration_s": round(sum(float(r.get("duration_s") or 0.0) for r in records), 1),
424
+ }
425
+ totals["failed"] = totals["calls"] - totals["ok"]
426
+ totals["days_active"] = len(day_groups)
427
+ totals["avg_tokens_per_call"] = int(totals["tokens"] / totals["calls"]) if totals["calls"] else 0
428
+ denom = totals["input"] + totals["cached"]
429
+ totals["cache_rate"] = round(totals["cached"] * 100.0 / denom, 1) if denom else 0.0
430
+ totals["peak_tokens"] = max((bd["tokens"] for bd in by_day), default=0)
431
+ totals["max_duration_s"] = round(max((float(r.get("duration_s") or 0.0) for r in records), default=0.0), 1)
432
+ cur, lng = _streaks(str(r.get("day") or "") for r in all_records)
433
+ totals["streak_current"] = cur
434
+ totals["streak_longest"] = lng
435
+
436
+ recent = sorted(records, key=lambda r: str(r.get("ts", "")), reverse=True)[:recent_limit]
437
+ return {
438
+ "generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
439
+ "range": {"days": span, "since": since.isoformat() if since else ""},
440
+ "totals": totals,
441
+ "by_day": by_day,
442
+ "by_day_model": by_day_model,
443
+ "all_by_day": all_by_day,
444
+ "by_tool": _dim_rows(records, "tool"),
445
+ "by_agent": _dim_rows(records, "agent"),
446
+ "by_model": _dim_rows(records, "model", normalize=True),
447
+ "by_role": _dim_rows(records, "role"),
448
+ "by_task_type": _dim_rows(records, "task_type"),
449
+ "recent": recent,
450
+ }