codebee 0.1.22 → 0.1.24
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.
- package/CHANGELOG.md +28 -0
- package/README.md +20 -3
- package/app/core/aiflavor.py +63 -9
- package/app/core/attachments.py +117 -4
- package/app/core/dispatch.py +34 -5
- package/app/core/dispatch_log.py +113 -0
- package/app/core/errorlog.py +1 -43
- package/app/core/jobs.py +30 -4
- package/app/core/modelhub.py +2 -0
- package/app/core/paths.py +2 -2
- package/app/core/pipeline.py +252 -12
- package/app/core/portguard.py +111 -0
- package/app/core/portscan.py +188 -0
- package/app/core/redact.py +38 -0
- package/app/core/router.py +58 -14
- package/app/core/runner.py +15 -8
- package/app/core/selfupdate.py +86 -27
- package/app/core/skills.py +37 -19
- package/app/core/store.py +2 -4
- package/app/core/task_compile.py +5 -1
- package/app/core/usage.py +226 -24
- package/app/main.py +82 -9
- package/app/pet.py +34 -11
- package/app/ui/app.js +177 -1
- package/app/ui/i18n.js +36 -0
- package/app/ui/index.html +1144 -1137
- package/app/ui/style.css +34 -0
- package/package.json +1 -1
package/app/core/skills.py
CHANGED
|
@@ -38,6 +38,7 @@ def _user_pack_dir():
|
|
|
38
38
|
|
|
39
39
|
MAX_INJECT_CHARS = 9000 # 单次注入上限(防止提示词爆炸;七猫+番茄双平台包并存后上调)
|
|
40
40
|
MAX_LESSONS_INJECT = 8 # 注入的自动教训条数上限
|
|
41
|
+
WILDCARD_PACK_CHAR_CAP = 2400 # wildcard(scope=*)包单包注入预算:市场通配技能动辄数万字,全文注入会挤掉项目教训
|
|
41
42
|
|
|
42
43
|
# 自动教训的问题分类:闭集枚举,对齐评审维度。沉淀时由复盘官归类(兜底路径按评审
|
|
43
44
|
# 维度关键词映射),UI 据此分类过滤查看。刻意保持小而稳,避免类别爆炸让过滤失去意义。
|
|
@@ -390,14 +391,14 @@ def relevance_top(lessons, task, limit):
|
|
|
390
391
|
if not probe:
|
|
391
392
|
return lessons[:limit]
|
|
392
393
|
|
|
393
|
-
# 使用反馈闭环(pmb「量化记忆真实帮助」借鉴):被选中次数多的教训排前
|
|
394
|
-
def rank(x):
|
|
395
|
-
grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
|
|
396
|
-
overlap = -len(probe & grams)
|
|
397
|
-
lid = x.get("id") or ""
|
|
398
|
-
return (overlap, -int(x.get("hits") or 0), lid)
|
|
399
|
-
|
|
400
|
-
return sorted(lessons, key=rank)[:limit]
|
|
394
|
+
# 使用反馈闭环(pmb「量化记忆真实帮助」借鉴):被选中次数多的教训排前
|
|
395
|
+
def rank(x):
|
|
396
|
+
grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
|
|
397
|
+
overlap = -len(probe & grams)
|
|
398
|
+
lid = x.get("id") or ""
|
|
399
|
+
return (overlap, -int(x.get("hits") or 0), lid)
|
|
400
|
+
|
|
401
|
+
return sorted(lessons, key=rank)[:limit]
|
|
401
402
|
|
|
402
403
|
|
|
403
404
|
def block_for(task, scope_override=None, *, stable_order=False):
|
|
@@ -408,9 +409,14 @@ def block_for(task, scope_override=None, *, stable_order=False):
|
|
|
408
409
|
stable_order=True(docs/migration/07-token-cost.md T1.2'):教训按 id 排序
|
|
409
410
|
而非 hits——hits 在任务中途变化会让技能块字节级不稳定,打碎供应商的
|
|
410
411
|
前缀缓存(同一任务 8 章应看到完全相同的技能块)。内容不变,只稳排序。
|
|
412
|
+
|
|
413
|
+
预算纪律(2026-09-21 巡检实锤引入):wildcard(scope=*)包动辄数万字,
|
|
414
|
+
39 个全文注入会先把 9000 字全局上限吃光,项目教训排在末尾被整段截掉。
|
|
415
|
+
两道预算:①wildcard 包单包限额(定向命中的包不受限);②教训保底——
|
|
416
|
+
包区最多吃到「全局上限 − 教训长度」,教训永远完整注入。
|
|
411
417
|
"""
|
|
412
418
|
scope = scope_override or task.get("type") or "*"
|
|
413
|
-
parts, used, lesson_ids = [], [], []
|
|
419
|
+
parts, used, lesson_ids = [], [], []
|
|
414
420
|
|
|
415
421
|
for p in all_packs():
|
|
416
422
|
if scope not in p["scopes"] and "*" not in p["scopes"]:
|
|
@@ -420,6 +426,9 @@ def block_for(task, scope_override=None, *, stable_order=False):
|
|
|
420
426
|
txt = pack_text(p).strip()
|
|
421
427
|
if not txt and not p.get("persona"):
|
|
422
428
|
continue
|
|
429
|
+
# wildcard 包(仅靠 * 命中,非定向)单包限预算;定向命中不限,走全局
|
|
430
|
+
if scope not in p["scopes"] and len(txt) > WILDCARD_PACK_CHAR_CAP:
|
|
431
|
+
txt = txt[:WILDCARD_PACK_CHAR_CAP] + "\n…(本包超出通配注入预算已截断,完整内容见技能库)"
|
|
423
432
|
# 3B:persona 独立成块(角色设定与规范正文分开,模型更易区分 obey 层级)
|
|
424
433
|
if p.get("persona"):
|
|
425
434
|
parts.append("### 【角色设定:%s】\n%s" % (p["name"], str(p["persona"]).strip()))
|
|
@@ -429,23 +438,32 @@ def block_for(task, scope_override=None, *, stable_order=False):
|
|
|
429
438
|
|
|
430
439
|
lessons = relevance_top(list_lessons(scope, only_enabled=True), task,
|
|
431
440
|
MAX_LESSONS_INJECT)
|
|
441
|
+
lesson_part = ""
|
|
432
442
|
if lessons:
|
|
433
443
|
if stable_order:
|
|
434
444
|
lessons.sort(key=lambda x: x.get("id") or "")
|
|
435
445
|
lines = []
|
|
436
|
-
for x in lessons:
|
|
437
|
-
lines.append("- **%s**:%s" % (x["title"], x["content"]))
|
|
438
|
-
used.append(x["id"])
|
|
439
|
-
lesson_ids.append(x["id"])
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
446
|
+
for x in lessons:
|
|
447
|
+
lines.append("- **%s**:%s" % (x["title"], x["content"]))
|
|
448
|
+
used.append(x["id"])
|
|
449
|
+
lesson_ids.append(x["id"])
|
|
450
|
+
lesson_part = ("### 【本项目已沉淀的教训(历史评审反复出现,务必规避)】\n"
|
|
451
|
+
+ "\n".join(lines))
|
|
452
|
+
|
|
453
|
+
if not parts and not lesson_part:
|
|
443
454
|
return "", []
|
|
444
|
-
|
|
455
|
+
|
|
456
|
+
header = "## 经验库(写作/工程规范 + 历史教训,必须遵守)\n\n"
|
|
457
|
+
budget = max(600, MAX_INJECT_CHARS - len(lesson_part))
|
|
458
|
+
body = "\n\n".join(parts)
|
|
459
|
+
if len(body) > budget:
|
|
460
|
+
body = body[:budget] + "\n…(包区已按预算截断,优先保住项目教训)"
|
|
461
|
+
body = (body + "\n\n" + lesson_part) if (body and lesson_part) else (body or lesson_part)
|
|
462
|
+
text = header + body
|
|
445
463
|
if len(text) > MAX_INJECT_CHARS:
|
|
446
464
|
text = text[:MAX_INJECT_CHARS] + "\n…(已截断)"
|
|
447
|
-
if lesson_ids:
|
|
448
|
-
bump_hits(lesson_ids) # 包 id 不参与教训热度,命中数据只保留一份真源
|
|
465
|
+
if lesson_ids:
|
|
466
|
+
bump_hits(lesson_ids) # 包 id 不参与教训热度,命中数据只保留一份真源
|
|
449
467
|
return text, used
|
|
450
468
|
|
|
451
469
|
|
package/app/core/store.py
CHANGED
|
@@ -246,10 +246,8 @@ def create_task(payload):
|
|
|
246
246
|
raise ValueError("附件落盘失败: %s" % e)
|
|
247
247
|
if items:
|
|
248
248
|
task["attachments"] = items
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if blk and "## 附件材料" not in task["context"]:
|
|
252
|
-
task["context"] = (task["context"] + blk).strip()
|
|
249
|
+
task["context"] = att_mod.merge_context(
|
|
250
|
+
task["context"], items, workdir=str(wd))
|
|
253
251
|
with LOCK:
|
|
254
252
|
_TASKS[task["id"]] = task
|
|
255
253
|
_save_json(paths.TASKS_DIR / (task["id"] + ".json"), task)
|
package/app/core/task_compile.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
"""任务编译器:把用户任务和预置流程编译成统一、可审计的运行规格。"""
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
5
7
|
from . import dispatch, flows
|
|
6
8
|
|
|
7
9
|
SCHEMA_VERSION = 1
|
|
@@ -41,7 +43,7 @@ def _capabilities(task, dimension, engine):
|
|
|
41
43
|
|
|
42
44
|
def compile_task(task):
|
|
43
45
|
"""返回统一任务规格;输入缺失或字段异常时保持可编排的安全兜底。"""
|
|
44
|
-
raw = dict(task
|
|
46
|
+
raw = dict(task) if isinstance(task, Mapping) else {}
|
|
45
47
|
ttype = str(raw.get("type") or "direct").strip().lower()
|
|
46
48
|
flow = flows.get_flow(ttype) or {}
|
|
47
49
|
engine = str(raw.get("engine") or flow.get("engine") or
|
|
@@ -53,6 +55,8 @@ def compile_task(task):
|
|
|
53
55
|
rubric = raw.get("rubric") or flow.get("rubric") or []
|
|
54
56
|
if isinstance(rubric, str):
|
|
55
57
|
rubric = [x.strip() for x in rubric.replace(",", ",").split(",") if x.strip()]
|
|
58
|
+
elif not isinstance(rubric, (list, tuple)):
|
|
59
|
+
rubric = flow.get("rubric") or []
|
|
56
60
|
rubric = [str(x).strip() for x in rubric if str(x).strip()][:8]
|
|
57
61
|
deliverable = str(raw.get("manuscript") or flow.get("manuscript") or "").strip()
|
|
58
62
|
serial = raw.get("serial") if isinstance(raw.get("serial"), dict) else None
|
package/app/core/usage.py
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
"""
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
-
import json
|
|
14
|
-
import
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import threading
|
|
15
16
|
import time
|
|
16
17
|
|
|
17
18
|
from . import paths
|
|
@@ -32,8 +33,12 @@ FIELDS = ("ts", "day", "run_id", "step", "task_id", "task_type", "role", "agent"
|
|
|
32
33
|
"input", "output", "cached", "reasoning", "total", "cost_usd", "source")
|
|
33
34
|
|
|
34
35
|
|
|
35
|
-
def _month_file(day):
|
|
36
|
-
return paths.USAGE_DIR / ("usage-%s.jsonl" % day[:7].replace("-", ""))
|
|
36
|
+
def _month_file(day):
|
|
37
|
+
return paths.USAGE_DIR / ("usage-%s.jsonl" % day[:7].replace("-", ""))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _quality_file(day):
|
|
41
|
+
return paths.USAGE_DIR / ("routing-quality-%s.jsonl" % day[:7].replace("-", ""))
|
|
37
42
|
|
|
38
43
|
|
|
39
44
|
def _parse_int(v):
|
|
@@ -93,10 +98,13 @@ def record(source="", run_id="", task_id="", task_type="", role="", step=0,
|
|
|
93
98
|
rec.update({"input": inp, "output": out, "cached": cach,
|
|
94
99
|
"reasoning": reas, "total": total})
|
|
95
100
|
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")
|
|
101
|
+
with LOCK:
|
|
102
|
+
paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
with open(_month_file(rec["day"]), "a", encoding="utf-8") as f:
|
|
104
|
+
f.write(line + "\n")
|
|
105
|
+
_ROUTING_CACHE["data"].clear()
|
|
106
|
+
_ROUTING_RECORDS_CACHE.clear()
|
|
107
|
+
_HOURLY_CACHE["ts"] = 0.0
|
|
100
108
|
except Exception:
|
|
101
109
|
pass
|
|
102
110
|
|
|
@@ -196,8 +204,11 @@ def _tool_of(agent_id):
|
|
|
196
204
|
return a or "unknown"
|
|
197
205
|
|
|
198
206
|
|
|
199
|
-
_HOURLY_CACHE = {"ts": 0.0, "val": {}}
|
|
200
|
-
_HOURLY_TTL = 60.0 # 秒:路由调用频繁但台账追加低频,60s 缓存足够新鲜
|
|
207
|
+
_HOURLY_CACHE = {"ts": 0.0, "val": {}}
|
|
208
|
+
_HOURLY_TTL = 60.0 # 秒:路由调用频繁但台账追加低频,60s 缓存足够新鲜
|
|
209
|
+
_ROUTING_CACHE = {"data": {}}
|
|
210
|
+
_ROUTING_RECORDS_CACHE = {}
|
|
211
|
+
_ROUTING_TTL = 15.0
|
|
201
212
|
|
|
202
213
|
|
|
203
214
|
def agent_tokens_recent(agent, hours=1):
|
|
@@ -224,7 +235,7 @@ def agent_tokens_recent(agent, hours=1):
|
|
|
224
235
|
return 0
|
|
225
236
|
|
|
226
237
|
|
|
227
|
-
def _iter_records(days):
|
|
238
|
+
def _iter_records(days):
|
|
228
239
|
"""按时间范围读取台账(days=0 表示全部)。返回按写入顺序的记录列表。"""
|
|
229
240
|
out = []
|
|
230
241
|
try:
|
|
@@ -232,11 +243,14 @@ def _iter_records(days):
|
|
|
232
243
|
except Exception:
|
|
233
244
|
return out
|
|
234
245
|
since_day = ""
|
|
235
|
-
if days:
|
|
236
|
-
import datetime
|
|
237
|
-
d = (datetime.date.today() - datetime.timedelta(days=int(days) - 1)).isoformat()
|
|
238
|
-
since_day = d
|
|
239
|
-
|
|
246
|
+
if days:
|
|
247
|
+
import datetime
|
|
248
|
+
d = (datetime.date.today() - datetime.timedelta(days=int(days) - 1)).isoformat()
|
|
249
|
+
since_day = d
|
|
250
|
+
since_month = since_day[:7].replace("-", "") if since_day else ""
|
|
251
|
+
for p in files:
|
|
252
|
+
if since_month and p.stem.rsplit("-", 1)[-1] < since_month:
|
|
253
|
+
continue
|
|
240
254
|
try:
|
|
241
255
|
for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
242
256
|
line = line.strip()
|
|
@@ -253,14 +267,202 @@ def _iter_records(days):
|
|
|
253
267
|
out.append(r)
|
|
254
268
|
except Exception:
|
|
255
269
|
continue
|
|
256
|
-
return out
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
def
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
270
|
+
return out
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _iter_quality_records(days):
|
|
274
|
+
"""读取独立质量台账;不混入用量统计的调用数、token 和成本。"""
|
|
275
|
+
out = []
|
|
276
|
+
try:
|
|
277
|
+
files = sorted(paths.USAGE_DIR.glob("routing-quality-*.jsonl")) \
|
|
278
|
+
if paths.USAGE_DIR.is_dir() else []
|
|
279
|
+
except Exception:
|
|
280
|
+
return out
|
|
281
|
+
since_day = ""
|
|
282
|
+
if days:
|
|
283
|
+
import datetime
|
|
284
|
+
since_day = (datetime.date.today()
|
|
285
|
+
- datetime.timedelta(days=int(days) - 1)).isoformat()
|
|
286
|
+
since_month = since_day[:7].replace("-", "") if since_day else ""
|
|
287
|
+
for path in files:
|
|
288
|
+
if since_month and path.stem.rsplit("-", 1)[-1] < since_month:
|
|
289
|
+
continue
|
|
290
|
+
try:
|
|
291
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
292
|
+
try:
|
|
293
|
+
row = json.loads(line)
|
|
294
|
+
except Exception:
|
|
295
|
+
continue
|
|
296
|
+
if isinstance(row, dict) and (not since_day or row.get("day", "") >= since_day):
|
|
297
|
+
out.append(row)
|
|
298
|
+
except Exception:
|
|
299
|
+
continue
|
|
300
|
+
return out
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _routing_records(days):
|
|
304
|
+
"""同一轮候选评分共享一次台账解析,避免每个候选重复扫全盘。"""
|
|
305
|
+
key = (str(paths.USAGE_DIR), int(days))
|
|
306
|
+
now = time.time()
|
|
307
|
+
with LOCK:
|
|
308
|
+
cached = _ROUTING_RECORDS_CACHE.get(key)
|
|
309
|
+
if cached and now - cached[0] <= _ROUTING_TTL:
|
|
310
|
+
return list(cached[1]), list(cached[2])
|
|
311
|
+
calls, quality = _iter_records(days), _iter_quality_records(days)
|
|
312
|
+
with LOCK:
|
|
313
|
+
_ROUTING_RECORDS_CACHE[key] = (now, list(calls), list(quality))
|
|
314
|
+
return calls, quality
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def record_quality_for_run(run_id, quality_ok, agent=""):
|
|
318
|
+
"""把验收结论归因到最终实际产出者,不重复计入用量。"""
|
|
319
|
+
try:
|
|
320
|
+
run_id = str(run_id or "")[:64]
|
|
321
|
+
if not run_id:
|
|
322
|
+
return 0
|
|
323
|
+
calls = [r for r in _iter_records(2)
|
|
324
|
+
if r.get("run_id") == run_id and r.get("ok") is True]
|
|
325
|
+
prefixes = ("implement", "fix", "draft", "revise", "polish", "direct", "author")
|
|
326
|
+
calls = [r for r in calls if str(r.get("role") or "").lower().startswith(prefixes)]
|
|
327
|
+
agent = str(agent or "")[:40]
|
|
328
|
+
unique = {}
|
|
329
|
+
for row in calls:
|
|
330
|
+
key = tuple(str(row.get(k) or "") for k in
|
|
331
|
+
("task_id", "task_type", "role", "agent", "model", "provider"))
|
|
332
|
+
unique[key] = row
|
|
333
|
+
if not unique:
|
|
334
|
+
return 0
|
|
335
|
+
existing = {(r.get("run_id"), r.get("role"), r.get("agent"),
|
|
336
|
+
r.get("model"), r.get("provider"))
|
|
337
|
+
for r in _iter_quality_records(2)}
|
|
338
|
+
day = time.strftime("%Y-%m-%d")
|
|
339
|
+
rows = []
|
|
340
|
+
for row in unique.values():
|
|
341
|
+
identity = (run_id, row.get("role"), row.get("agent"),
|
|
342
|
+
row.get("model"), row.get("provider"))
|
|
343
|
+
if identity in existing:
|
|
344
|
+
continue
|
|
345
|
+
# 成功调用不等于产出通过。换将前的实现者即使传输成功,也已被质量
|
|
346
|
+
# 门淘汰,必须留下负样本;最终实际产出者才继承本次验收结论。
|
|
347
|
+
row_quality_ok = bool(quality_ok)
|
|
348
|
+
if agent and str(row.get("agent") or "") != agent:
|
|
349
|
+
row_quality_ok = False
|
|
350
|
+
rows.append({"ts": time.strftime("%Y-%m-%d %H:%M:%S"), "day": day,
|
|
351
|
+
"run_id": run_id, "task_id": row.get("task_id") or "",
|
|
352
|
+
"task_type": row.get("task_type") or "unknown",
|
|
353
|
+
"role": row.get("role") or "unknown",
|
|
354
|
+
"agent": row.get("agent") or "unknown",
|
|
355
|
+
"model": row.get("model") or "(默认)",
|
|
356
|
+
"provider": row.get("provider") or "",
|
|
357
|
+
"quality_ok": row_quality_ok})
|
|
358
|
+
if not rows:
|
|
359
|
+
return 0
|
|
360
|
+
with LOCK:
|
|
361
|
+
paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
|
|
362
|
+
with open(_quality_file(day), "a", encoding="utf-8") as handle:
|
|
363
|
+
for row in rows:
|
|
364
|
+
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
365
|
+
_ROUTING_CACHE["data"].clear()
|
|
366
|
+
_ROUTING_RECORDS_CACHE.clear()
|
|
367
|
+
return len(rows)
|
|
368
|
+
except Exception:
|
|
369
|
+
return 0
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _num(r, key):
|
|
373
|
+
return _parse_int(r.get(key))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _percentile(values, percentile):
|
|
377
|
+
"""线性插值百分位,空样本返回 0。"""
|
|
378
|
+
if not values:
|
|
379
|
+
return 0.0
|
|
380
|
+
ordered = sorted(float(v) for v in values)
|
|
381
|
+
if len(ordered) == 1:
|
|
382
|
+
return round(ordered[0], 2)
|
|
383
|
+
pos = (len(ordered) - 1) * float(percentile)
|
|
384
|
+
lo = int(math.floor(pos))
|
|
385
|
+
hi = int(math.ceil(pos))
|
|
386
|
+
if lo == hi:
|
|
387
|
+
return round(ordered[lo], 2)
|
|
388
|
+
return round(ordered[lo] + (ordered[hi] - ordered[lo]) * (pos - lo), 2)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def routing_stats(task_type="", role="", agent="", model="", provider="",
|
|
392
|
+
days=90, min_samples=3):
|
|
393
|
+
"""返回路由用的近期真实运行指标。
|
|
394
|
+
|
|
395
|
+
只读取脱敏用量台账;精确维度样本不足时依次回退到任务+角色、任务、
|
|
396
|
+
全局,成功率使用 Beta(3, 1) 平滑,避免单次失败永久压低新候选。
|
|
397
|
+
"""
|
|
398
|
+
try:
|
|
399
|
+
task_type = str(task_type or "")[:32]
|
|
400
|
+
role = str(role or "")[:40]
|
|
401
|
+
agent = str(agent or "")[:40]
|
|
402
|
+
model = str(model or "")[:80]
|
|
403
|
+
provider = str(provider or "")[:60]
|
|
404
|
+
days = max(0, int(days))
|
|
405
|
+
min_samples = max(1, int(min_samples))
|
|
406
|
+
except (TypeError, ValueError):
|
|
407
|
+
days, min_samples = 90, 3
|
|
408
|
+
# DATA_DIR 可在测试、多实例或运行时配置切换;纳入缓存键,避免跨目录
|
|
409
|
+
# 复用另一套台账的线上指标。
|
|
410
|
+
key = (str(paths.DATA_DIR), task_type, role, agent, model, provider,
|
|
411
|
+
days, min_samples)
|
|
412
|
+
now = time.time()
|
|
413
|
+
with LOCK:
|
|
414
|
+
cached = _ROUTING_CACHE["data"].get(key)
|
|
415
|
+
if cached and now - cached[0] <= _ROUTING_TTL:
|
|
416
|
+
return dict(cached[1])
|
|
417
|
+
records, quality_records = _routing_records(days)
|
|
418
|
+
|
|
419
|
+
def matches(record, filters):
|
|
420
|
+
return all(str(record.get(field) or "") == value
|
|
421
|
+
for field, value in filters.items() if value)
|
|
422
|
+
|
|
423
|
+
# 先保留所有已提供的维度;后续逐层放宽,确保模型和 CLI 都能共享一套查询。
|
|
424
|
+
exact = {"task_type": task_type, "role": role, "agent": agent,
|
|
425
|
+
"model": model, "provider": provider}
|
|
426
|
+
fallbacks = [(exact, "exact")]
|
|
427
|
+
task_role = {"task_type": task_type, "role": role}
|
|
428
|
+
if task_role != exact:
|
|
429
|
+
fallbacks.append((task_role, "task-role"))
|
|
430
|
+
if task_type:
|
|
431
|
+
fallbacks.append(({"task_type": task_type}, "task"))
|
|
432
|
+
fallbacks.append(({}, "global"))
|
|
433
|
+
selected, label = [], "global"
|
|
434
|
+
for filters, candidate_label in fallbacks:
|
|
435
|
+
candidate = [r for r in records if matches(r, filters)]
|
|
436
|
+
if len(candidate) >= min_samples or (candidate_label == "global" and candidate):
|
|
437
|
+
selected, label = candidate, candidate_label
|
|
438
|
+
break
|
|
439
|
+
chosen_filters = next((filters for filters, candidate_label in fallbacks
|
|
440
|
+
if candidate_label == label), {})
|
|
441
|
+
quality_selected = [r for r in quality_records if matches(r, chosen_filters)]
|
|
442
|
+
success_rows = quality_selected or selected
|
|
443
|
+
success_key = "quality_ok" if quality_selected else "ok"
|
|
444
|
+
successes = sum(1 for r in success_rows if bool(r.get(success_key)))
|
|
445
|
+
durations = [max(0.0, _parse_float(r.get("duration_s"))) for r in selected]
|
|
446
|
+
costs = [max(0.0, _parse_float(r.get("cost_usd"))) for r in selected]
|
|
447
|
+
samples = len(selected)
|
|
448
|
+
success_samples = len(success_rows)
|
|
449
|
+
result = {
|
|
450
|
+
"samples": samples,
|
|
451
|
+
"quality_samples": len(quality_selected),
|
|
452
|
+
"success_samples": success_samples,
|
|
453
|
+
"successes": successes,
|
|
454
|
+
"success_rate": round((successes + 3.0) / (success_samples + 4.0), 4),
|
|
455
|
+
"p50_duration_s": _percentile(durations, 0.50),
|
|
456
|
+
"p95_duration_s": _percentile(durations, 0.95),
|
|
457
|
+
"avg_cost_usd": round(sum(costs) / samples, 6) if samples else 0.0,
|
|
458
|
+
"fallback": label,
|
|
459
|
+
}
|
|
460
|
+
with LOCK:
|
|
461
|
+
_ROUTING_CACHE["data"][key] = (now, dict(result))
|
|
462
|
+
return result
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _group(records, key):
|
|
264
466
|
"""按 key 聚合:{name: {calls, ok, tokens, input, output, cached, cache_rate, cost_usd, duration_s}}。"""
|
|
265
467
|
groups = {}
|
|
266
468
|
for r in records:
|
package/app/main.py
CHANGED
|
@@ -284,6 +284,18 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
284
284
|
except ValueError:
|
|
285
285
|
edays = 90
|
|
286
286
|
return self._json(200, usage.estimate(task_type=ttype, days=edays))
|
|
287
|
+
if path == "/api/dispatch/replay":
|
|
288
|
+
from core import dispatch_log
|
|
289
|
+
q = parse_qs(urlparse(self.path).query)
|
|
290
|
+
try:
|
|
291
|
+
limit = max(1, min(1000, int((q.get("limit") or ["100"])[0])))
|
|
292
|
+
except (TypeError, ValueError):
|
|
293
|
+
limit = 100
|
|
294
|
+
events = dispatch_log.replay(
|
|
295
|
+
run_id=(q.get("run_id") or [""])[0],
|
|
296
|
+
task_type=(q.get("task_type") or [""])[0],
|
|
297
|
+
limit=limit)
|
|
298
|
+
return self._json(200, {"events": events, "count": len(events)})
|
|
287
299
|
if path == "/api/diagnostics/bundle":
|
|
288
300
|
# 诊断包(zip):脱敏错误台账 + 用量台账 + 环境元信息,供用户贴 Issue
|
|
289
301
|
from core import telemetry
|
|
@@ -296,6 +308,18 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
296
308
|
# 一键反馈 Issue 的预填摘要(标题+正文,全程脱敏,用户亲手提交)
|
|
297
309
|
from core import telemetry
|
|
298
310
|
return self._json(200, telemetry.issue_report(days=30))
|
|
311
|
+
if path == "/api/ports":
|
|
312
|
+
# 端口占用诊断(借鉴 leftopen):谁在听、PID/进程/项目归属、
|
|
313
|
+
# 是否仅本机。?port=N 只看单端口。只读,不碰任何进程。
|
|
314
|
+
from core import portscan
|
|
315
|
+
q = parse_qs(urlparse(self.path).query)
|
|
316
|
+
ports = portscan.listening_ports()
|
|
317
|
+
focus = (q.get("port") or [""])[0]
|
|
318
|
+
if focus.isdigit():
|
|
319
|
+
ports = [p for p in ports if p["port"] == int(focus)]
|
|
320
|
+
for p in ports:
|
|
321
|
+
p["self"] = p.get("pid") == os.getpid()
|
|
322
|
+
return self._json(200, {"ports": ports})
|
|
299
323
|
m = re.match(r"^/api/runs/([^/]+)$", path)
|
|
300
324
|
if m:
|
|
301
325
|
run = store.get_run(m.group(1))
|
|
@@ -560,6 +584,22 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
560
584
|
return self._json(200, {"ok": True, "health": health.snapshot()})
|
|
561
585
|
if path == "/api/attachments":
|
|
562
586
|
return self._api_add_attachment()
|
|
587
|
+
if path == "/api/ports/close":
|
|
588
|
+
# 温和关闭端口占用进程(借鉴 leftopen):SIGTERM only、关前重验
|
|
589
|
+
# PID 绑定;系统进程/自身服务在 portscan 内拒关。设备控制权守卫
|
|
590
|
+
# 已在上方统一生效(不在豁免清单里)。
|
|
591
|
+
from core import portscan
|
|
592
|
+
body = self._body()
|
|
593
|
+
try:
|
|
594
|
+
cport = int(body.get("port") or 0)
|
|
595
|
+
except (TypeError, ValueError):
|
|
596
|
+
return self._json(400, {"error": "port 必须是数字"})
|
|
597
|
+
if not (1 <= cport <= 65535):
|
|
598
|
+
return self._json(400, {"error": "port 越界"})
|
|
599
|
+
ok, msg = portscan.close_port(cport)
|
|
600
|
+
return self._json(200 if ok else 409,
|
|
601
|
+
{"ok": ok, "message": msg,
|
|
602
|
+
"error": None if ok else msg})
|
|
563
603
|
if path == "/api/dir/save":
|
|
564
604
|
# 「查看文件」弹窗编辑保存(本机 + 控制权 + 防穿越 + mtime 冲突检测)
|
|
565
605
|
return self._api_dir_save()
|
|
@@ -713,14 +753,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
713
753
|
if path == "/api/selfupdate/apply":
|
|
714
754
|
from core import selfupdate
|
|
715
755
|
try:
|
|
716
|
-
res = selfupdate.apply_upgrade()
|
|
756
|
+
res = selfupdate.apply_upgrade(PORT)
|
|
717
757
|
except Exception:
|
|
718
758
|
log.exception("自更新任务创建失败")
|
|
719
759
|
return self._json(503, {"error": "升级任务创建失败,请稍后重试"})
|
|
720
760
|
return self._json(400, res) if res.get("error") else self._json(200, dict(res, ok=True))
|
|
721
761
|
if path == "/api/selfupdate/restart":
|
|
722
762
|
from core import selfupdate
|
|
723
|
-
global PORT
|
|
724
763
|
if not selfupdate.relaunch(PORT):
|
|
725
764
|
return self._json(400, {"error": "重启参数非法"})
|
|
726
765
|
def _bye():
|
|
@@ -2407,13 +2446,47 @@ def main():
|
|
|
2407
2446
|
# 分发,表现为"时好时坏");加独占锁后双起在这里干净失败并指路。
|
|
2408
2447
|
# (此处不能局部 import os:会让 os 变 main() 的局部名,后面 2127 行
|
|
2409
2448
|
# 的 os.name 直接 UnboundLocalError——顶部已有全局导入,直接用)
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2449
|
+
# 端口占用自动清场(用户拍板 2026-09-21):升级/重启最常见的占用者是
|
|
2450
|
+
# 没退干净的 CodeBee 自家旧实例——先杀再起,别让用户手动 netstat+taskkill。
|
|
2451
|
+
# 判定(main.py 完整路径/npm 打包路径)与清场在 core.portguard。
|
|
2452
|
+
from core import portscan as _ps, portguard as _pg
|
|
2453
|
+
_hint = ""
|
|
2454
|
+
try:
|
|
2455
|
+
_cleared, _hint = _pg.clear_stale_port(
|
|
2456
|
+
args.port, paths.APP_DIR / "main.py")
|
|
2457
|
+
except Exception:
|
|
2458
|
+
_cleared, _hint = False, ""
|
|
2459
|
+
if _cleared:
|
|
2460
|
+
try:
|
|
2461
|
+
print("[CodeBee] 端口 %d 被旧实例占用,已自动清场,正在重新绑定…"
|
|
2462
|
+
% args.port, flush=True)
|
|
2463
|
+
time.sleep(1.0) # 让被清进程的监听 socket 完全释放
|
|
2464
|
+
httpd = ThreadedServer((args.host, args.port), Handler)
|
|
2465
|
+
except OSError:
|
|
2466
|
+
httpd = None
|
|
2467
|
+
else:
|
|
2468
|
+
httpd = None
|
|
2469
|
+
if httpd is None:
|
|
2470
|
+
# 清场没成:带占用者指认退出(识别不出回落手工排查提示)
|
|
2471
|
+
hint = ""
|
|
2472
|
+
if os.name == "nt":
|
|
2473
|
+
hint = ("(Windows 排查:netstat -ano | findstr :%d 找到 PID,"
|
|
2474
|
+
"tasklist /FI \"PID eq <PID>\" 看是谁;旧进程杀掉或换 --port)"
|
|
2475
|
+
% args.port)
|
|
2476
|
+
try:
|
|
2477
|
+
for _h in _ps.listening_ports():
|
|
2478
|
+
if _h.get("port") != args.port:
|
|
2479
|
+
continue
|
|
2480
|
+
_who = _h.get("process") or "未知进程"
|
|
2481
|
+
_proj = (",项目 %s" % _h["project"]) if _h.get("project") else ""
|
|
2482
|
+
hint = ("占用者:PID %d(%s%s)%s"
|
|
2483
|
+
% (_h.get("pid") or 0, _who, _proj,
|
|
2484
|
+
(";" + _hint) if _hint else ";旧进程杀掉或换 --port 重启"))
|
|
2485
|
+
break
|
|
2486
|
+
except Exception:
|
|
2487
|
+
pass
|
|
2488
|
+
raise SystemExit("[CodeBee] 端口 %d 已被占用,无法启动:%s %s"
|
|
2489
|
+
% (args.port, e, hint))
|
|
2417
2490
|
|
|
2418
2491
|
def _announce_public(url):
|
|
2419
2492
|
print("[CodeBee] 公网 %s/?token=%s" % (url, tok))
|
package/app/pet.py
CHANGED
|
@@ -1283,7 +1283,8 @@ class PetApp:
|
|
|
1283
1283
|
command=lambda: self._set_lang("en"),
|
|
1284
1284
|
variable=self._lang_var, value="en")
|
|
1285
1285
|
m.add_separator()
|
|
1286
|
-
m.add_command(label=self._L("close"),
|
|
1286
|
+
m.add_command(label=self._L("close"),
|
|
1287
|
+
command=lambda: self._bye(write_setting=True))
|
|
1287
1288
|
return m
|
|
1288
1289
|
|
|
1289
1290
|
def _set_mode(self, mode):
|
|
@@ -1308,6 +1309,8 @@ class PetApp:
|
|
|
1308
1309
|
cv = self.cv
|
|
1309
1310
|
self._press = None
|
|
1310
1311
|
self._moved = False
|
|
1312
|
+
self._drag_to = None
|
|
1313
|
+
self._drag_pending = False
|
|
1311
1314
|
cv.bind("<ButtonPress-1>", self._on_press)
|
|
1312
1315
|
cv.bind("<B1-Motion>", self._on_motion)
|
|
1313
1316
|
cv.bind("<ButtonRelease-1>", self._on_release)
|
|
@@ -1316,22 +1319,32 @@ class PetApp:
|
|
|
1316
1319
|
self.root.protocol("WM_DELETE_WINDOW", self._bye)
|
|
1317
1320
|
|
|
1318
1321
|
def _on_press(self, e):
|
|
1319
|
-
|
|
1322
|
+
# 窗口拖动起点:屏幕坐标 + 窗口原点一起记(scan_mark/scan_dragto 是
|
|
1323
|
+
# canvas 系 widget 的子命令,顶层窗口没有——此前绑定在这里的拖动
|
|
1324
|
+
# 实际全部抛 AttributeError 被 except 吞掉,表现为「拖不动」)
|
|
1325
|
+
self._press = (e.x_root, e.y_root, self.root.winfo_x(), self.root.winfo_y())
|
|
1320
1326
|
self._moved = False
|
|
1321
|
-
try:
|
|
1322
|
-
self.root.scan_mark(e.x_root, e.y_root) # Tk 内置 C 级拖动,跟手
|
|
1323
|
-
except Exception:
|
|
1324
|
-
pass
|
|
1325
1327
|
|
|
1326
1328
|
def _on_motion(self, e):
|
|
1327
1329
|
if not self._press:
|
|
1328
1330
|
return
|
|
1329
|
-
|
|
1331
|
+
px, py, wx, wy = self._press
|
|
1332
|
+
if (abs(e.x_root - px) + abs(e.y_root - py) > 4):
|
|
1330
1333
|
self._moved = True
|
|
1331
|
-
#
|
|
1332
|
-
#
|
|
1334
|
+
# geometry 逐事件会卡成 PPT;这里合并到 ~60fps 节流(事件风暴只在
|
|
1335
|
+
# 节流窗内记终点,一次 geometry 落位),实测跟手且不吃 CPU
|
|
1336
|
+
self._drag_to = (wx + (e.x_root - px), wy + (e.y_root - py))
|
|
1337
|
+
if self._drag_pending:
|
|
1338
|
+
return
|
|
1339
|
+
self._drag_pending = True
|
|
1340
|
+
self.root.after(16, self._apply_drag)
|
|
1341
|
+
|
|
1342
|
+
def _apply_drag(self):
|
|
1343
|
+
self._drag_pending = False
|
|
1344
|
+
if not self._drag_to:
|
|
1345
|
+
return
|
|
1333
1346
|
try:
|
|
1334
|
-
self.root.
|
|
1347
|
+
self.root.geometry("+%d+%d" % self._drag_to)
|
|
1335
1348
|
except Exception:
|
|
1336
1349
|
pass
|
|
1337
1350
|
|
|
@@ -1344,7 +1357,17 @@ class PetApp:
|
|
|
1344
1357
|
else:
|
|
1345
1358
|
self._open_ui()
|
|
1346
1359
|
|
|
1347
|
-
def _bye(self):
|
|
1360
|
+
def _bye(self, write_setting=False):
|
|
1361
|
+
"""退出。write_setting=True(右键菜单「关闭桌宠」)先把 pet_enabled=False
|
|
1362
|
+
写回服务端设置——否则看护/下次启动都会按设置里的 enabled 把蜜蜂复活,
|
|
1363
|
+
用户点关闭等于没关(2026-09-21 用户实测「宠物关不了」的根因:菜单
|
|
1364
|
+
关闭只销毁窗口不落设置,与注释宣称的契约相反)。服务端 pet_enabled
|
|
1365
|
+
已为 False 的自离路径(轮询发现/miss 超限)无需再写。"""
|
|
1366
|
+
if write_setting:
|
|
1367
|
+
try:
|
|
1368
|
+
self._post_settings({"pet_enabled": False})
|
|
1369
|
+
except Exception:
|
|
1370
|
+
pass
|
|
1348
1371
|
try:
|
|
1349
1372
|
self._save_cfg(x=self.root.winfo_x(), y=self.root.winfo_y())
|
|
1350
1373
|
except Exception:
|