codebee 0.1.68 → 0.1.70

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 (133) hide show
  1. package/CHANGELOG.md +580 -543
  2. package/LICENSE +21 -21
  3. package/README.md +11 -0
  4. package/app/browser-final-1440.png +0 -0
  5. package/app/browser-final-390.png +0 -0
  6. package/app/browser-google-fallback.png +0 -0
  7. package/app/browser-google-test.png +0 -0
  8. package/app/browser-workbench-1440.png +0 -0
  9. package/app/browser-workbench-390.png +0 -0
  10. package/app/browser-workspace-google.png +0 -0
  11. package/app/browser-workspace-smoke.png +0 -0
  12. package/app/browser-workspace.png +0 -0
  13. package/app/core/acceptance.py +100 -100
  14. package/app/core/aiflavor.py +197 -197
  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 -0
  19. package/app/core/automation.py +853 -758
  20. package/app/core/backup.py +470 -470
  21. package/app/core/beekeeper.py +183 -183
  22. package/app/core/benchstore.py +95 -0
  23. package/app/core/board.py +209 -209
  24. package/app/core/bookmeta.py +935 -930
  25. package/app/core/bookmeta_catalog.py +180 -180
  26. package/app/core/branching.py +137 -137
  27. package/app/core/builtin_agent.py +1673 -1234
  28. package/app/core/capability.py +144 -144
  29. package/app/core/catalog.py +7 -1
  30. package/app/core/chaptersafe.py +32 -32
  31. package/app/core/cleanup.py +303 -303
  32. package/app/core/compaction.py +201 -201
  33. package/app/core/covergen.py +356 -356
  34. package/app/core/defectretro.py +43 -43
  35. package/app/core/diagnostics.py +114 -114
  36. package/app/core/dispatch.py +190 -173
  37. package/app/core/dispatch_log.py +128 -128
  38. package/app/core/env_scrub.py +85 -85
  39. package/app/core/error_codes.py +33 -24
  40. package/app/core/errorlog.py +137 -137
  41. package/app/core/evalbench.py +720 -0
  42. package/app/core/evaluation.py +174 -174
  43. package/app/core/failure_notify.py +290 -290
  44. package/app/core/flows.py +228 -3
  45. package/app/core/goal_service.py +167 -167
  46. package/app/core/health.py +374 -374
  47. package/app/core/history.py +38 -38
  48. package/app/core/hooks.py +162 -162
  49. package/app/core/jobs.py +51 -30
  50. package/app/core/manager.py +2143 -2122
  51. package/app/core/market.py +362 -362
  52. package/app/core/market_remote.py +10 -0
  53. package/app/core/mcp_client.py +287 -0
  54. package/app/core/mocks.py +64 -64
  55. package/app/core/modelhub.py +193 -10
  56. package/app/core/notify.py +201 -106
  57. package/app/core/novel_quality.py +189 -0
  58. package/app/core/operations.py +228 -228
  59. package/app/core/paihang.py +138 -138
  60. package/app/core/paths.py +6 -2
  61. package/app/core/pipeline.py +426 -28
  62. package/app/core/planner.py +243 -22
  63. package/app/core/plugins.py +525 -0
  64. package/app/core/portguard.py +111 -111
  65. package/app/core/preview.py +293 -293
  66. package/app/core/publish/__init__.py +7 -7
  67. package/app/core/publish/auto.py +367 -367
  68. package/app/core/publish/browser.py +655 -539
  69. package/app/core/publish/fanqie.py +165 -159
  70. package/app/core/publish/flow.py +589 -440
  71. package/app/core/publish/flows-fanqie-calibrated.json +336 -332
  72. package/app/core/publish/ledger.py +229 -203
  73. package/app/core/publish/manager.py +824 -692
  74. package/app/core/publish/qimao.py +146 -141
  75. package/app/core/publish/ws.py +139 -139
  76. package/app/core/redact.py +38 -38
  77. package/app/core/remotesync.py +121 -0
  78. package/app/core/repeat_guard.py +129 -129
  79. package/app/core/router.py +398 -398
  80. package/app/core/runner.py +2142 -2067
  81. package/app/core/selfupdate.py +384 -384
  82. package/app/core/session_log.py +161 -161
  83. package/app/core/sessions.py +312 -312
  84. package/app/core/settings.py +186 -149
  85. package/app/core/settings_schema.py +270 -262
  86. package/app/core/share_page.py +114 -114
  87. package/app/core/skill_scan.py +122 -122
  88. package/app/core/skillpacks/fanqie-novel.md +80 -80
  89. package/app/core/skillpacks/market/character-bible.md +66 -66
  90. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  91. package/app/core/skillpacks/market/git-workflow.md +57 -57
  92. package/app/core/skillpacks/market/release-notes.md +72 -72
  93. package/app/core/skillpacks/market/weekly-report.md +71 -71
  94. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  95. package/app/core/step_runner.py +132 -132
  96. package/app/core/store.py +104 -82
  97. package/app/core/task_compile.py +199 -199
  98. package/app/core/telemetry.py +292 -292
  99. package/app/core/tlsctx.py +64 -64
  100. package/app/core/token_meter.py +147 -147
  101. package/app/core/tracing.py +144 -144
  102. package/app/core/upstream.py +41 -41
  103. package/app/core/usage.py +1035 -862
  104. package/app/core/volumes.py +389 -389
  105. package/app/core/wxdigest.py +858 -858
  106. package/app/core/zentao.py +2614 -2465
  107. package/app/current-browser.png +0 -0
  108. package/app/main.py +3245 -2981
  109. package/app/mcp_server.py +175 -0
  110. package/app/pet.py +82 -21
  111. package/app/pick_dialog.py +110 -110
  112. package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
  113. package/app/plugins/code-review-helper/skills/review.md +16 -0
  114. package/app/ui/app.js +15706 -14399
  115. package/app/ui/artdiff.js +74 -74
  116. package/app/ui/board.html +162 -162
  117. package/app/ui/board.js +305 -305
  118. package/app/ui/i18n.js +3012 -2770
  119. package/app/ui/icons/bee.svg +79 -79
  120. package/app/ui/index.html +277 -13
  121. package/app/ui/manifest.json +16 -16
  122. package/app/ui/qrcode.js +2297 -2297
  123. package/app/ui/style.css +802 -16
  124. package/bin/tutti.js +147 -147
  125. package/package.json +47 -46
  126. package/tools/wx_reader.py +261 -261
  127. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  128. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  129. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  130. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  131. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  132. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  133. package/app/.playwright-cli/run-detail-after.png +0 -0
@@ -0,0 +1,720 @@
1
+ # -*- coding: utf-8 -*-
2
+ """模型评测基准台(promptfoo / deepeval 借鉴):固定样题集 × 候选模型 × 固定
3
+ rubric → 实测能力榜。与 evaluation.py(供应商连通性,24h TTL)互补:这里评
4
+ 的是「任务做得好不好」。
5
+
6
+ - 样题内置定版(写作 / 修 bug / 摘要,BUILTIN_SAMPLES):同一道题所有模型
7
+ 都吃一模一样的输入,分数才可比;
8
+ - 修 bug 题带客观验证:候选返回的代码在子进程里跑断言(runner.run_process),
9
+ 主观评审之外给一道客观闸——谎报修好了会被直接戳穿;
10
+ - 评审 = 同一 rubric 的 LLM 裁判(编排者供应商);裁判与候选同供应商时如实
11
+ 标「同族评审」,绝不伪装成独立评审;
12
+ - 每条真实调用入用量台账(source=bench):花费硬顶/五维聚合照常记账;
13
+ cost_usd 记 0——价格表(CCSwitch 导入)单位口径未核实,宁可少记不错记;
14
+ - 结果落 data/eval_bench.json:每 (供应商, 模型, 样题) 留最新一版 + 运行
15
+ 历史尾巴,逐条落盘(跑到一半崩了也有有效数据)。
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import json
21
+ import re
22
+ import threading
23
+ import time
24
+
25
+ # L2:可 import L0(paths/usage)与 L1(modelhub);verify 子进程走 runner(L2 同层)
26
+ from . import paths
27
+
28
+ _LOCK = threading.RLock()
29
+ _RUN = None # 运行中态:{"total","done","current","cancel","started_ts"};None = 空闲
30
+
31
+ # ---------------------------------------------------------------- 内置样题集
32
+
33
+ _BUGGY_CODE = '''def sliding_window_max(nums, k):
34
+ out = []
35
+ for i in range(len(nums) - k + 1):
36
+ out.append(max(nums[i:i + k]))
37
+ return out'''
38
+
39
+ _WRITING_SPEC = ("题材:都市职场。写一章小说开篇,300 字左右:主角林晚是广告公司客户执行,"
40
+ "今天甲方把需求改到第八版,她还必须笑着说「没问题」。要求有画面感、有人物、"
41
+ "结尾留钩子。只输出正文,不要标题、不要解释。")
42
+
43
+ _MEETING_TEXT = (
44
+ "周三产品会,人齐。老张先说上周数据:新增掉了一成半,原因排查到渠道 H 的假量,"
45
+ "先暂停投放,财务那边的结算单先压着。然后是客服侧:工单积压四百多,主要是新版导出"
46
+ "功能崩,用户骂得凶,建议这周内出补丁并群发安抚券。设计部提了三套新首页,定不下,"
47
+ "要老板拍板,老板没来。运营说双十一的活动页下周必须启动,缺文案两个人手。最后散会前"
48
+ "补充:下周一重开复盘会,必须带数据来。")
49
+
50
+ BUILTIN_SAMPLES = [
51
+ {"id": "writing", "name": "章节写作",
52
+ "requirement": "按给定题材与人设写 300 字左右小说开篇,有画面感、有人物、结尾留钩子",
53
+ "dims": ["情节", "人物", "文笔", "吸引力"],
54
+ "prompt": _WRITING_SPEC},
55
+ {"id": "bugfix", "name": "小 bug 修复",
56
+ "requirement": "修复滑动窗口最大值函数的边界缺陷,使全部断言通过,并附一句修复说明",
57
+ "dims": ["正确性", "代码质量", "解释清晰"],
58
+ "prompt": (
59
+ "下面这个 Python 函数有边界缺陷:\n\n```python\n" + _BUGGY_CODE + "\n```\n\n"
60
+ "预期行为:返回长度 k 的滑动窗口最大值列表(长度 len(nums)-k+1);"
61
+ "k <= 0 或 k > len(nums) 时返回 [],绝不能崩溃或输出错值。\n"
62
+ "请只输出修复后的完整函数代码(```python 代码块),代码块后用一句话说明改了什么。"),
63
+ "verify": True},
64
+ {"id": "summary", "name": "结构化摘要",
65
+ "requirement": "把一段流水账会议记录提炼成 3 条要点 + 待办清单,不丢关键数字与责任归属",
66
+ "dims": ["准确性", "结构", "简洁"],
67
+ "prompt": ("把下面的会议记录提炼成「3 条要点」和「待办清单」两节,保留关键数字与责任归属,"
68
+ "每节各不超过 5 行:\n\n" + _MEETING_TEXT)},
69
+ ]
70
+ _SAMPLE_IDS = {s["id"] for s in BUILTIN_SAMPLES}
71
+
72
+
73
+ def _all_samples():
74
+ """内置 + 自定义样题全集(跑评测与对账的单一真源)。"""
75
+ return list(BUILTIN_SAMPLES) + custom_samples()
76
+
77
+ _MAX_CANDIDATES = 8 # 一次评测的候选上限:控成本(每候选 = 样题数 × 2 次调用)
78
+ _MAX_CAND_TEXT = 4000 # 喂给裁判的作答截断
79
+
80
+
81
+ def samples():
82
+ """给 UI 的样题清单(不带完整 prompt,页面展示用):内置 + 自定义。"""
83
+ out = [{"id": s["id"], "name": s["name"], "requirement": s["requirement"],
84
+ "dims": s["dims"], "verify": bool(s.get("verify")), "builtin": True}
85
+ for s in BUILTIN_SAMPLES]
86
+ out += [{"id": s["id"], "name": s["name"], "requirement": s["requirement"],
87
+ "dims": s["dims"], "verify": False, "builtin": False}
88
+ for s in custom_samples()]
89
+ return out
90
+
91
+
92
+ # ---- 自定义样题(评测台可扩展化):跑你自己的题,样题也能出分享码 ----
93
+ # 存 data/eval_samples.json;自定义样题一律无客观验证(verify 是内置修 bug 题
94
+ # 的专用通道)。分享码 CBSAMP1. 前缀,与流程分享码同一信封形态。
95
+
96
+ CUSTOM_MAX = 12
97
+ _SAMPLE_ID_RE = re.compile(r"^c-[a-z0-9_-]{1,24}$")
98
+
99
+
100
+ def _samples_path():
101
+ return paths.DATA_DIR / "eval_samples.json"
102
+
103
+
104
+ def custom_samples():
105
+ try:
106
+ data = json.loads(_samples_path().read_text(encoding="utf-8"))
107
+ arr = data.get("samples") if isinstance(data, dict) else None
108
+ return [s for s in arr if isinstance(s, dict) and s.get("id")] \
109
+ if isinstance(arr, list) else []
110
+ except Exception:
111
+ return []
112
+
113
+
114
+ def sample_op(op, sample=None, sid=""):
115
+ """自定义样题增删。返回 (样本或 None, 错误)。"""
116
+ if op == "add":
117
+ if not isinstance(sample, dict):
118
+ return None, "样题必须是对象"
119
+ if len(custom_samples()) >= CUSTOM_MAX:
120
+ return None, "自定义样题最多 %d 道" % CUSTOM_MAX
121
+ sid = str(sample.get("id") or "").strip() or \
122
+ ("c-" + format(int(time.time() * 1000) % 10**10, "x"))
123
+ if not _SAMPLE_ID_RE.match(sid):
124
+ return None, "样题 ID 只能是小写 c- 开头的字母/数字/-/_(≤24 位)"
125
+ name = str(sample.get("name") or "").strip()[:20]
126
+ prompt = str(sample.get("prompt") or "").strip()
127
+ requirement = str(sample.get("requirement") or "").strip()[:120]
128
+ dims = [str(d).strip()[:8] for d in sample.get("dims") or [] if str(d).strip()]
129
+ if not name:
130
+ return None, "样题名称不能为空"
131
+ if len(prompt) < 10:
132
+ return None, "题目内容太短(至少 10 字)"
133
+ if len(prompt) > 2000:
134
+ return None, "题目内容最长 2000 字"
135
+ if not (2 <= len(dims) <= 6):
136
+ return None, "评审维度需要 2-6 个"
137
+ entry = {"id": sid, "name": name, "requirement": requirement or name,
138
+ "dims": dims, "prompt": prompt, "builtin": False}
139
+ with _LOCK:
140
+ arr = custom_samples()
141
+ arr = [s for s in arr if s.get("id") != sid]
142
+ arr.append(entry)
143
+ _samples_path().parent.mkdir(parents=True, exist_ok=True)
144
+ _samples_path().write_text(
145
+ json.dumps({"samples": arr}, ensure_ascii=False, indent=2),
146
+ encoding="utf-8")
147
+ return entry, None
148
+ if op == "delete":
149
+ sid = str(sid or "").strip()
150
+ if not sid:
151
+ return None, "缺少样题 ID"
152
+ with _LOCK:
153
+ arr = [s for s in custom_samples() if s.get("id") != sid]
154
+ _samples_path().parent.mkdir(parents=True, exist_ok=True)
155
+ _samples_path().write_text(
156
+ json.dumps({"samples": arr}, ensure_ascii=False, indent=2),
157
+ encoding="utf-8")
158
+ return {"id": sid, "deleted": True}, None
159
+ return None, "未知操作 " + str(op)
160
+
161
+
162
+ _SAMPLE_SHARE_PREFIX = "CBSAMP1."
163
+ _SAMPLE_SHARE_KIND = "codebee-sample"
164
+
165
+
166
+ def export_sample_code(sid):
167
+ """样题导出为分享码。返回 (code, 错误)。"""
168
+ sid = str(sid or "").strip()
169
+ s = next((x for x in custom_samples() if x.get("id") == sid), None)
170
+ if not s:
171
+ return None, "自定义样题不存在(内置样题不可导出)"
172
+ payload = {"kind": _SAMPLE_SHARE_KIND, "v": 1,
173
+ "sample": {k: s.get(k) for k in
174
+ ("id", "name", "requirement", "dims", "prompt")}}
175
+ blob = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
176
+ return _SAMPLE_SHARE_PREFIX + base64.urlsafe_b64encode(
177
+ blob.encode("utf-8")).decode("ascii"), None
178
+
179
+
180
+ def import_sample_code(code):
181
+ """导入样题分享码:ID 冲突时自动换号(样题无覆盖语义,都是加题)。"""
182
+ code = str(code or "").strip()
183
+ if not code.startswith(_SAMPLE_SHARE_PREFIX):
184
+ return None, "不是有效的 CodeBee 样题分享码(应以 %s 开头)" % _SAMPLE_SHARE_PREFIX
185
+ raw = code[len(_SAMPLE_SHARE_PREFIX):].strip()
186
+ try:
187
+ payload = json.loads(base64.urlsafe_b64decode(
188
+ raw + "=" * (-len(raw) % 4)).decode("utf-8"))
189
+ except Exception:
190
+ return None, "分享码解析失败(可能被截断或篡改)"
191
+ if not isinstance(payload, dict) or payload.get("kind") != _SAMPLE_SHARE_KIND:
192
+ return None, "分享码内容不是 CodeBee 样题"
193
+ sample_in = dict(payload.get("sample") or {})
194
+ # ID 冲突自动换号(add 对同 id 是覆盖语义,会静默替换原题——不是想要的)
195
+ if any(x.get("id") == str(sample_in.get("id") or "") for x in custom_samples()):
196
+ sample_in.pop("id", None)
197
+ sample, err = sample_op("add", sample_in)
198
+ if err:
199
+ return None, err
200
+ return {"id": sample["id"], "name": sample["name"]}, None
201
+
202
+
203
+ # ---------------------------------------------------------------- 存储
204
+
205
+ def _path():
206
+ """兼容旧引用(单测/探针断言用);读写实际下沉 benchstore(L0 存储层,
207
+ dispatch 同层合法取实测分软信号)。"""
208
+ from . import benchstore
209
+ return benchstore._path()
210
+
211
+
212
+ def _read():
213
+ from . import benchstore
214
+ return benchstore.read_doc()
215
+
216
+
217
+ def _write(data):
218
+ from . import benchstore
219
+ benchstore.write_doc(data)
220
+
221
+
222
+ def _update(fn):
223
+ """锁内读-改-写:evalbench 的文档是多段共享(results/runs/top1),
224
+ 各写入点若各自 read→modify→write 会互相覆盖(同秒多写实测互踩)。
225
+ 全部走这里,一次 RMW 一个临界区。"""
226
+ with _LOCK:
227
+ data = _read()
228
+ fn(data)
229
+ _write(data)
230
+ return data
231
+
232
+
233
+ def _persist_result(row):
234
+ def _mut(data):
235
+ results = data.get("results") if isinstance(data.get("results"), list) else []
236
+ results = [r for r in results
237
+ if not (isinstance(r, dict) and r.get("sample_id") == row["sample_id"]
238
+ and r.get("provider_id") == row["provider_id"]
239
+ and r.get("model") == row["model"])]
240
+ results.append(row)
241
+ data["results"] = results[-400:] # 封顶:8 候选 × 3 样题 × 多轮历史也够用
242
+ _update(_mut)
243
+
244
+
245
+ # ---------------------------------------------------------------- 评审
246
+
247
+ _JUDGE_TMPL = (
248
+ "你是严格的作品评审员。请对下面这份「{name}」的作答,按维度各打 0-10 分"
249
+ "(可带一位小数):{dims}。\n\n题目要求:\n{requirement}\n\n"
250
+ "候选作答(可能被截断):\n{answer}\n\n{verify_note}\n"
251
+ "只输出一个 JSON 对象,不要输出任何其他文字:\n"
252
+ '{{"dims": {{"维度名": 分数}}, "overall": 0-10 的综合分, '
253
+ '"comment": "一句话评语"}}')
254
+
255
+
256
+ def _judge_prompt(sample, answer, verify_ok, verify_detail):
257
+ if sample.get("verify"):
258
+ verify_note = ("客观验证结果:%s%s" % (
259
+ "通过(代码在本地断言全部跑通)" if verify_ok
260
+ else "未通过——代码没有跑对,正确性维度必须给低分",
261
+ ("(%s)" % verify_detail) if verify_detail and not verify_ok else ""))
262
+ else:
263
+ verify_note = "客观验证:本题无客观验证。"
264
+ return _JUDGE_TMPL.format(name=sample["name"], dims="、".join(sample["dims"]),
265
+ requirement=sample["requirement"],
266
+ answer=str(answer or "")[:_MAX_CAND_TEXT],
267
+ verify_note=verify_note)
268
+
269
+
270
+ def _parse_judge_json(text):
271
+ """从裁判输出里抠出分数 JSON。兼容代码围栏与前后废话;解析失败返回 None。"""
272
+ if not isinstance(text, str) or "{" not in text or "}" not in text:
273
+ return None
274
+ try:
275
+ blob = text[text.index("{"): text.rindex("}") + 1]
276
+ data = json.loads(blob)
277
+ except Exception:
278
+ return None
279
+ if not isinstance(data, dict):
280
+ return None
281
+ dims = {}
282
+ for k, v in (data.get("dims") or {}).items():
283
+ try:
284
+ dims[str(k)] = max(0.0, min(10.0, float(v)))
285
+ except (TypeError, ValueError):
286
+ continue
287
+ if not dims:
288
+ return None
289
+ overall = None
290
+ try:
291
+ overall = float(data.get("overall"))
292
+ except (TypeError, ValueError):
293
+ overall = None
294
+ if overall is None:
295
+ overall = sum(dims.values()) / len(dims)
296
+ return {"dims": dims,
297
+ "overall": max(0.0, min(10.0, overall)),
298
+ "comment": str(data.get("comment") or "")[:200]}
299
+
300
+
301
+ # ---------------------------------------------------------------- 客观验证(修 bug 题)
302
+
303
+ def _extract_code(text):
304
+ """取第一个 ```python 代码块;没有围栏就取包含目标函数定义的裸代码段。"""
305
+ m = re.search(r"```(?:python|py)?\s*\n(.*?)```", str(text or ""), re.S)
306
+ if m:
307
+ return m.group(1)
308
+ return str(text or "")
309
+
310
+
311
+ def _verify_bugfix(answer):
312
+ """修 bug 题客观验证:候选代码 + 断言写成临时脚本真跑一遍。
313
+
314
+ 返回 (ok, detail)。断言覆盖正常路径与三个边界(k=1 / k>len / k<=0),
315
+ 「会算正常值但没修边界」的答案过不了。任何异常(写盘/起进程)都算未通过
316
+ 并带原因——验证层故障不能伪装成通过。
317
+ """
318
+ import tempfile, os # 局部导入:仅在 verify 时需要
319
+ code = _extract_code(answer)
320
+ if "def sliding_window_max" not in code:
321
+ return False, "未找到 sliding_window_max 函数定义"
322
+ harness = code + "\n\n" + "\n".join([
323
+ "assert sliding_window_max([1, 3, 2, 5], 2) == [3, 3, 5]",
324
+ "assert sliding_window_max([4, 4, 4], 1) == [4, 4, 4]",
325
+ "assert sliding_window_max([1, 2, 3], 5) == []",
326
+ "assert sliding_window_max([1, 2], 0) == []",
327
+ "assert sliding_window_max([1, 2], -1) == []",
328
+ 'print("PASS")',
329
+ ]) + "\n"
330
+ tmp = None
331
+ try:
332
+ fd, tmp = tempfile.mkstemp(prefix="cb-bench-", suffix=".py")
333
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
334
+ f.write(harness)
335
+ from . import runner
336
+ r = runner.run_process(argv=["python", "-X", "utf8", tmp], timeout=25)
337
+ out = str(r.get("stdout") or "")
338
+ if r.get("ok") and "PASS" in out:
339
+ return True, ""
340
+ tail = (out.strip().splitlines() or [""])[-1][:120]
341
+ return False, tail or ("exit=%s" % r.get("exit_code"))
342
+ except Exception as e:
343
+ return False, "验证层异常:%s" % e
344
+ finally:
345
+ try:
346
+ if tmp and os.path.isfile(tmp):
347
+ os.unlink(tmp)
348
+ except OSError:
349
+ pass
350
+
351
+
352
+ # ---------------------------------------------------------------- 运行链
353
+
354
+ def _record_usage(run_id, role, sample_id, prov_id, model, ok, dur_ms, usage_d, error=""):
355
+ """评测调用入台账(source=bench)。评测花费记在结果行 cost_yuan(性价比
356
+ 榜用),台账列保持 0——评测是横向对比不是生产消耗,别污染金额趋势。"""
357
+ try:
358
+ from . import usage
359
+ usage.record(source="bench", run_id=run_id, task_id="", task_type="bench",
360
+ role=role, step=0, agent=str(prov_id)[:40],
361
+ agent_label=str(prov_id)[:60], tool="evalbench",
362
+ model=str(model)[:80], provider=str(prov_id)[:60],
363
+ ok=bool(ok), duration_s=round((dur_ms or 0) / 1000.0, 1),
364
+ cost_usd=0.0, usage=usage_d or {})
365
+ except Exception:
366
+ pass
367
+
368
+
369
+ def _run_bench(run_id, candidates, sample_ids, judge_cfg, notify_done=False):
370
+ """同步跑完一轮评测(start 的线程体;单测直接调它)。
371
+
372
+ 逐条产出、逐条落盘:候选失败 / 裁判失败 / 解析失败都如实成行,
373
+ 绝不让一次坏调用毁掉整轮。notify_done=True 收尾推送摘要(定时回归:
374
+ 任务跑完了要主动找到人)。"""
375
+ global _RUN
376
+ sample_by_id = {s["id"]: s for s in _all_samples()}
377
+ for cand in candidates:
378
+ prov_id, model = cand.get("provider_id") or "", cand.get("model") or ""
379
+ if not prov_id or not model:
380
+ continue
381
+ for sid in sample_ids:
382
+ if _RUN and _RUN.get("cancel"):
383
+ break
384
+ sample = sample_by_id[sid]
385
+ with _LOCK:
386
+ if _RUN:
387
+ _RUN["current"] = "%s × %s" % (model, sample["name"])
388
+ gen = _gen(prov_id, model, sample["prompt"], 2048)
389
+ _record_usage(run_id, sid, sid, prov_id, model, gen.get("ok"),
390
+ gen.get("latency_ms"), gen.get("usage"), gen.get("error") or "")
391
+ if not gen.get("ok"):
392
+ _persist_result({"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
393
+ "sample_id": sid, "provider_id": prov_id, "model": model,
394
+ "ok": False, "scored": False, "verify_ok": None,
395
+ "error": str(gen.get("error") or "生成失败")[:200],
396
+ "overall": None, "scores": {}, "comment": "",
397
+ "cost_usd": 0.0, "duration_s": round(gen.get("latency_ms", 0) / 1000.0, 1),
398
+ "same_family": False})
399
+ with _LOCK:
400
+ if _RUN:
401
+ _RUN["done"] += 1
402
+ continue
403
+ answer = gen.get("text") or ""
404
+ verify_ok, verify_detail = (None, "")
405
+ if sample.get("verify"):
406
+ verify_ok, verify_detail = _verify_bugfix(answer)
407
+ jp = _gen(judge_cfg["provider_id"], judge_cfg["model"],
408
+ _judge_prompt(sample, answer, verify_ok, verify_detail), 800)
409
+ _record_usage(run_id, "judge", sid, judge_cfg["provider_id"],
410
+ judge_cfg["model"], jp.get("ok"), jp.get("latency_ms"),
411
+ jp.get("usage"), jp.get("error") or "")
412
+ # 本条结果的花费(¥,标定单价折算;未标价记 None=性价比未知)
413
+ def _cost(u, prov_id_, model_):
414
+ try:
415
+ if not u:
416
+ return None
417
+ pr = _model_price_cached(prov_id_, model_)
418
+ if not pr:
419
+ return None
420
+ return round((pr["in"] * int(u.get("input") or 0)
421
+ + pr["out"] * int(u.get("output") or 0)) / 1e6, 6)
422
+ except Exception:
423
+ return None
424
+ cost_yuan = _cost(gen.get("usage"), prov_id, model)
425
+ judge_cost = _cost(jp.get("usage"), judge_cfg["provider_id"],
426
+ judge_cfg["model"])
427
+ if cost_yuan is not None and judge_cost is not None:
428
+ cost_yuan = round(cost_yuan + judge_cost, 6)
429
+ parsed = _parse_judge_json(jp.get("text") or "") if jp.get("ok") else None
430
+ row = {"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
431
+ "sample_id": sid, "provider_id": prov_id, "model": model,
432
+ "ok": True, "scored": parsed is not None,
433
+ "verify_ok": verify_ok,
434
+ "error": "" if jp.get("ok") else str(jp.get("error") or "评审调用失败")[:200],
435
+ "overall": parsed["overall"] if parsed else None,
436
+ "scores": parsed["dims"] if parsed else {},
437
+ "comment": parsed["comment"] if parsed else "",
438
+ "cost_usd": 0.0, "cost_yuan": cost_yuan,
439
+ "duration_s": round((gen.get("latency_ms", 0) + jp.get("latency_ms", 0)) / 1000.0, 1),
440
+ "same_family": prov_id == judge_cfg.get("provider_id")}
441
+ _persist_result(row)
442
+ with _LOCK:
443
+ if _RUN:
444
+ _RUN["done"] += 1
445
+ with _LOCK:
446
+ cancelled = bool(_RUN and _RUN.get("cancel"))
447
+ if notify_done and not cancelled:
448
+ _notify_auto_done(len(candidates))
449
+ with _LOCK:
450
+ if _RUN and _RUN.get("cancel"):
451
+ _append_run_log(run_id, judge_cfg, cancelled=True)
452
+ else:
453
+ _append_run_log(run_id, judge_cfg, auto=notify_done)
454
+ _RUN = None
455
+
456
+
457
+ def _notify_auto_done(candidates_n):
458
+ """定时回归收尾推送(Top3 榜单 + 与上次定时回归的 Top1 对比);通知失败只记日志。"""
459
+ try:
460
+ from . import notify
461
+ board = _leaderboard(_read_results_list(), {})[:3]
462
+ lines = ["🧊 CodeBee 评测台定时回归完成",
463
+ "候选 %d 个,裁判为编排者供应商" % max(1, candidates_n)]
464
+ for r in board:
465
+ score = ("%.1f" % r["overall"]) if r.get("overall") is not None else "未得分"
466
+ lines.append("#%d %s:%s" % (r["rank"], r["model"], score))
467
+ # 与上次定时回归的 Top1 对比(防漂移一眼可见:换主了/掉了都点名)
468
+ prev, cur_top1 = None, None
469
+ if board and board[0].get("overall") is not None:
470
+ cur_top1 = "%s(%.1f)" % (board[0]["model"], board[0]["overall"])
471
+ prev = _read().get("last_auto_top1")
472
+ def _mut(data):
473
+ data["last_auto_top1"] = cur_top1
474
+ _update(_mut)
475
+ if prev and prev.split("(")[0] != cur_top1.split("(")[0]:
476
+ lines.append("⚠ 榜首易主:上次 %s → 本次 %s" % (prev, cur_top1))
477
+ notify.push_text("\n".join(lines))
478
+ except Exception:
479
+ pass
480
+
481
+
482
+ def _read_results_list():
483
+ results = _read().get("results")
484
+ return [r for r in results if isinstance(r, dict)] if isinstance(results, list) else []
485
+
486
+
487
+ def _gen(prov_id, model, prompt, max_tokens):
488
+ """thin wrapper:便于单测统一 mock 一处。"""
489
+ from . import modelhub
490
+ try:
491
+ return modelhub.generate_once(prov_id, model, prompt, max_tokens=max_tokens)
492
+ except Exception as e: # 防御:generate_once 承诺不抛,这里兜底
493
+ return {"ok": False, "text": "", "usage": None, "error": str(e)}
494
+
495
+
496
+ _PRICE_CACHE = {} # (prov_id, model) → {"in","out"} | None(None 也缓存:未标价不反复穿透)
497
+
498
+
499
+ def _model_price_cached(prov_id, model):
500
+ key = (str(prov_id or ""), str(model or ""))
501
+ if key not in _PRICE_CACHE:
502
+ try:
503
+ from . import modelhub
504
+ _PRICE_CACHE[key] = modelhub.model_price(key[1], provider=key[0])
505
+ except Exception:
506
+ _PRICE_CACHE[key] = None
507
+ return _PRICE_CACHE[key]
508
+
509
+
510
+ def _append_run_log(run_id, judge_cfg, cancelled=False, auto=False):
511
+ def _mut(data):
512
+ runs = data.get("runs") if isinstance(data.get("runs"), list) else []
513
+ runs.append({"ts": time.strftime("%Y-%m-%d %H:%M:%S"), "run_id": run_id,
514
+ "judge": "%s · %s" % (judge_cfg.get("provider_id") or "",
515
+ judge_cfg.get("model") or ""),
516
+ "cancelled": bool(cancelled), "auto": bool(auto)})
517
+ data["runs"] = runs[-20:]
518
+ _update(_mut)
519
+
520
+
521
+ def _auto_candidates():
522
+ """定时回归的候选:启用且有 KEY 的供应商里,各取启用模型(catalog 顺序),
523
+ 全局截到 _MAX_CANDIDATES。没有可用候选返回空表。"""
524
+ out = []
525
+ try:
526
+ from . import modelhub
527
+ for p in modelhub.providers():
528
+ if p.get("enabled", True) is False or not (p.get("api_key") or p.get("keys")):
529
+ continue
530
+ for m in p.get("models") or []:
531
+ if len(out) >= _MAX_CANDIDATES:
532
+ return out
533
+ if m.get("enabled", True) is False or m.get("hidden"):
534
+ continue
535
+ if isinstance(m.get("name"), str) and m.get("name"):
536
+ out.append({"provider_id": p.get("id") or "", "model": m["name"]})
537
+ except Exception:
538
+ return []
539
+ return out
540
+
541
+
542
+ def _last_auto_ts():
543
+ for r in reversed(_read().get("runs") or []):
544
+ if isinstance(r, dict) and r.get("auto") and not r.get("cancelled"):
545
+ return str(r.get("ts") or "")
546
+ return ""
547
+
548
+
549
+ def score_trend():
550
+ """全体已得分结果的按日综合分均值(近 14 天)——评测页 sparkline 用。"""
551
+ trend = {}
552
+ for r in _read_results_list():
553
+ if not (r.get("scored") and r.get("overall") is not None):
554
+ continue
555
+ day = str(r.get("ts") or "")[:10]
556
+ if day:
557
+ trend.setdefault(day, []).append(float(r["overall"]))
558
+ return [{"day": d, "overall": round(sum(trend[d]) / len(trend[d]), 2)}
559
+ for d in sorted(trend)[-14:]]
560
+
561
+
562
+ def fire_due():
563
+ """自动化 tick 钩子(同 cleanup/wxdigest.fire_due 模式):设置启用且距上次
564
+ 定时回归超过间隔天数时,自动起一轮评测并推送结果。自节流、绝不抛错。
565
+
566
+ 返回 {"skipped": True, "reason": str} 或 {"started": True, "total": n}。"""
567
+ try:
568
+ from . import settings as settings_mod
569
+ s = settings_mod.load()
570
+ if not s.get("bench_auto_enabled"):
571
+ return {"skipped": True, "reason": "disabled"}
572
+ days = max(1, int(s.get("bench_auto_days") or 7))
573
+ last = _last_auto_ts()
574
+ if last:
575
+ try:
576
+ elapsed = time.time() - time.mktime(
577
+ time.strptime(last, "%Y-%m-%d %H:%M:%S"))
578
+ if elapsed < days * 86400.0:
579
+ return {"skipped": True, "reason": "not-due"}
580
+ except ValueError:
581
+ pass # 时间戳坏 = 视作到期,重跑一轮
582
+ cands = _auto_candidates()
583
+ if not cands:
584
+ return {"skipped": True, "reason": "no-candidates"}
585
+ info, err = start(cands, notify_done=True)
586
+ if err:
587
+ return {"skipped": True, "reason": err[:80]}
588
+ return {"started": True, "total": info.get("total") or 0}
589
+ except Exception as e:
590
+ return {"skipped": True, "reason": str(e)[:80]}
591
+
592
+
593
+ def start(candidates, sample_ids=None, notify_done=False):
594
+ """发起一轮评测。返回 (运行描述, 错误)。裁判 = 编排者供应商(必须就绪)。
595
+
596
+ notify_done=True:跑完推送结果摘要(定时回归用)。"""
597
+ global _RUN
598
+ cands = []
599
+ for c in (candidates or [])[:_MAX_CANDIDATES]:
600
+ if isinstance(c, dict) and c.get("provider_id") and c.get("model"):
601
+ cands.append({"provider_id": str(c["provider_id"]).strip(),
602
+ "model": str(c["model"]).strip()})
603
+ if not cands:
604
+ return None, "请先勾选至少一个候选模型"
605
+ all_ids = [s["id"] for s in _all_samples()]
606
+ ids = [s for s in (sample_ids or all_ids) if s in all_ids] or \
607
+ [s["id"] for s in _all_samples()]
608
+ from . import modelhub
609
+ orch = modelhub.orchestrator_view()
610
+ if not orch.get("ready"):
611
+ return None, "评审需要编排者供应商:请先在「编排设置」配置并启用"
612
+ judge_cfg = {"provider_id": orch.get("provider_id") or "",
613
+ "model": orch.get("model") or orch.get("provider_model") or ""}
614
+ with _LOCK:
615
+ if _RUN:
616
+ return None, "已有一轮评测在跑(%d/%d)" % (_RUN.get("done", 0), _RUN.get("total", 0))
617
+ _RUN = {"total": len(cands) * len(ids), "done": 0, "current": "",
618
+ "cancel": False, "started_ts": time.time()}
619
+ run_id = "bench-" + time.strftime("%Y%m%d-%H%M%S")
620
+ total = len(cands) * len(ids)
621
+ threading.Thread(target=_run_bench, daemon=True, name=run_id,
622
+ args=(run_id, cands, ids, judge_cfg, notify_done)).start()
623
+ return {"run_id": run_id, "total": total}, None
624
+
625
+
626
+ def cancel():
627
+ """请求停止在跑的一轮(当前这一步做完即停,已产出的结果保留)。"""
628
+ with _LOCK:
629
+ if _RUN:
630
+ _RUN["cancel"] = True
631
+ return True
632
+ return False
633
+
634
+
635
+ def state():
636
+ """给 UI 的全量视图:运行态 + 裁判 + 榜单 + 样题清单。"""
637
+ from . import modelhub
638
+ orch = modelhub.orchestrator_view()
639
+ with _LOCK:
640
+ run = dict(_RUN) if _RUN else None
641
+ prov_names = {}
642
+ try:
643
+ prov_names = {p.get("id"): p.get("name") for p in modelhub.providers()}
644
+ except Exception:
645
+ pass
646
+ data = _read()
647
+ results = [r for r in (data.get("results") or []) if isinstance(r, dict)]
648
+ board = _leaderboard(results, prov_names)
649
+ return {"running": bool(run),
650
+ "progress": run,
651
+ "judge": {"provider_id": orch.get("provider_id") or "",
652
+ "model": orch.get("model") or "",
653
+ "ready": bool(orch.get("ready"))},
654
+ "samples": samples(),
655
+ "leaderboard": board,
656
+ "matrix": _matrix(results),
657
+ "trend": score_trend(),
658
+ "last_runs": (data.get("runs") or [])[-5:]}
659
+
660
+
661
+ def _matrix(results):
662
+ """逐题对比:样题 → {「provider_id|model」: 最新一次结果}。
663
+
664
+ 同键多轮只留最新(results 按写入顺序,后写覆盖前写);样题顺序跟
665
+ 样题全集定版(内置在前),空样题不占行。"""
666
+ order = [s["id"] for s in _all_samples()]
667
+ cells = {sid: {} for sid in order}
668
+ for r in results:
669
+ sid = str(r.get("sample_id") or "")
670
+ model = str(r.get("model") or "")
671
+ if sid not in cells or not model:
672
+ continue
673
+ cells[sid][str(r.get("provider_id") or "") + "|" + model] = {
674
+ "overall": r.get("overall"),
675
+ "scored": bool(r.get("scored")),
676
+ "verify_ok": r.get("verify_ok"),
677
+ "ok": bool(r.get("ok")),
678
+ "ts": str(r.get("ts") or "")}
679
+ return {"samples": order, "cells": cells}
680
+
681
+
682
+ def _leaderboard(results, prov_names):
683
+ """按 (供应商, 模型) 聚合:综合分均值、分维均值、客观验证通过数。"""
684
+ by_model = {}
685
+ for r in results:
686
+ key = (r.get("provider_id") or "", r.get("model") or "")
687
+ by_model.setdefault(key, []).append(r)
688
+ rows = []
689
+ for (prov_id, model), items in by_model.items():
690
+ scored = [r for r in items if r.get("scored") and r.get("overall") is not None]
691
+ failed = [r for r in items if not r.get("ok")]
692
+ dims, dim_n = {}, {}
693
+ for r in scored:
694
+ for d, v in (r.get("scores") or {}).items():
695
+ dims[d] = dims.get(d, 0.0) + float(v)
696
+ dim_n[d] = dim_n.get(d, 0) + 1
697
+ verified = [r for r in items if r.get("verify_ok") is not None]
698
+ # 性价比(¥):有标价且综合分>0 才有值 = 分/元;未标价 None
699
+ costs = [float(r.get("cost_yuan")) for r in scored
700
+ if r.get("cost_yuan") is not None]
701
+ total_cost = round(sum(costs), 4) if costs else None
702
+ overall = (sum(r["overall"] for r in scored) / len(scored)) if scored else None
703
+ value = round(overall / total_cost, 2) if (overall and total_cost) else None
704
+ rows.append({
705
+ "provider_id": prov_id, "model": model,
706
+ "provider_name": prov_names.get(prov_id) or prov_id,
707
+ "overall": round(overall, 2) if overall is not None else None,
708
+ "samples_n": len(items), "scored_n": len(scored),
709
+ "failed_n": len(failed),
710
+ "scores": {d: round(v / dim_n[d], 1) for d, v in dims.items() if dim_n[d]},
711
+ "verify_pass": sum(1 for r in verified if r.get("verify_ok")),
712
+ "verify_total": len(verified),
713
+ "cost_yuan": total_cost, "value_per_yuan": value,
714
+ "same_family": any(r.get("same_family") for r in scored),
715
+ "last_ts": max((r.get("ts") or "") for r in items),
716
+ })
717
+ rows.sort(key=lambda x: (x["overall"] is None, -(x["overall"] or 0)))
718
+ for i, r in enumerate(rows, 1):
719
+ r["rank"] = i
720
+ return rows