codebee 0.1.28 → 0.1.30
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 +23 -0
- package/README.md +22 -0
- package/app/core/builtin_agent.py +36 -2
- package/app/core/dispatch.py +3 -1
- package/app/core/flows.py +1 -1
- package/app/core/jobs.py +23 -0
- package/app/core/modelhub.py +6 -0
- package/app/core/pipeline.py +118 -26
- package/app/core/planner.py +54 -17
- package/app/core/router.py +16 -9
- package/app/core/runner.py +64 -14
- package/app/core/selfupdate.py +26 -12
- package/app/core/store.py +61 -13
- package/app/core/task_compile.py +111 -0
- package/app/core/usage.py +55 -14
- package/app/core/wxdigest.py +140 -0
- package/app/main.py +28 -1
- package/app/pet.py +95 -58
- package/app/ui/app.js +209 -11
- package/app/ui/i18n.js +35 -0
- package/app/ui/index.html +28 -7
- package/app/ui/style.css +62 -0
- package/package.json +1 -1
package/app/core/router.py
CHANGED
|
@@ -11,6 +11,7 @@ CAPABILITY = {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
MAX_REPAIR_ROUNDS = 2 # 自动修复循环上限
|
|
14
|
+
AUTO_CRITIC_LIMIT = 2 # 自动内容评审只取综合分前两名;手工模式仍尊重完整名单
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def _task_type(ttype):
|
|
@@ -62,7 +63,9 @@ def _online_bonus(agent, role, ttype):
|
|
|
62
63
|
rate = float(metrics.get("success_rate") or 0.0)
|
|
63
64
|
success_score = max(-8.0, min(8.0, (rate - 0.75) * 24.0))
|
|
64
65
|
p95 = max(0.0, float(metrics.get("p95_duration_s") or 0.0))
|
|
65
|
-
|
|
66
|
+
# 旧上限 -6 抵不过 CLI 静态能力 10 分差,P95 两三分钟的模型仍会
|
|
67
|
+
# 压过十几秒的健康模型。加大惩罚,让真实延迟足以改变自动选择。
|
|
68
|
+
latency_score = -min(18.0, max(0.0, (p95 - 30.0) / 7.0))
|
|
66
69
|
cost = max(0.0, float(metrics.get("avg_cost_usd") or 0.0))
|
|
67
70
|
cost_score = -min(4.0, max(0.0, (cost - 0.01) / 0.01))
|
|
68
71
|
total = round(success_score + latency_score + cost_score, 1)
|
|
@@ -199,14 +202,18 @@ def pick_reviewer(agents, impl, ttype, stats=None):
|
|
|
199
202
|
return impl, "(自评:仅有实现者一个智能体可用)"
|
|
200
203
|
|
|
201
204
|
|
|
202
|
-
def pick_critics(agents, ttype, stats=None, impl=None):
|
|
203
|
-
"""
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
205
|
+
def pick_critics(agents, ttype, stats=None, impl=None):
|
|
206
|
+
"""自动内容评审组选综合分前两名(排除作者本人)。
|
|
207
|
+
|
|
208
|
+
旧逻辑让全部已启用 CLI 串行参与每章和全局评审;一条慢/失效链就会把
|
|
209
|
+
任务拖长数分钟。两名保留交叉判断并封顶默认调用数;手工模式不走这里。
|
|
210
|
+
"""
|
|
211
|
+
stats = stats or history.agent_stats()
|
|
212
|
+
real = [a for a in agents if a.get("mode") == "real" and (not impl or a["id"] != impl["id"])]
|
|
213
|
+
if real:
|
|
214
|
+
ranked = sorted(real, key=lambda a: score(a, "review", ttype, stats)[0], reverse=True)
|
|
215
|
+
real = ranked[:AUTO_CRITIC_LIMIT]
|
|
216
|
+
note = "按成功率与延迟选择前 %d 名评审(共 %d 名候选)" % (len(real), len(ranked))
|
|
210
217
|
if impl and impl.get("mode") == "real" \
|
|
211
218
|
and all(a.get("kind") == impl.get("kind") for a in real):
|
|
212
219
|
note += ";评审组与作者同为 %s,建议启用其他厂商 CLI 交叉评审" % impl.get("kind")
|
package/app/core/runner.py
CHANGED
|
@@ -58,13 +58,52 @@ def find_git_bash():
|
|
|
58
58
|
return None
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
def resolve_command(command):
|
|
61
|
+
def resolve_command(command):
|
|
62
62
|
"""命令名 → 可直接 spawn 的 argv 前缀(.cmd/.bat 垫片必须经 cmd /c)。"""
|
|
63
63
|
path = shutil.which(command) or command
|
|
64
64
|
low = path.lower()
|
|
65
65
|
if low.endswith(".cmd") or low.endswith(".bat"):
|
|
66
66
|
return ["cmd", "/c", path]
|
|
67
|
-
return [path]
|
|
67
|
+
return [path]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _git_repo_issue(workdir):
|
|
71
|
+
"""返回工作目录 Git 仓库的可读性问题;无 Git 仓库时返回空串。
|
|
72
|
+
|
|
73
|
+
Aider 会在启动后自行读取索引。索引包缺失时它通常先写入 .gitignore,
|
|
74
|
+
再在深层调用中才报错,用户会误以为任务卡住;提前做一次廉价预检能把
|
|
75
|
+
错误变成可操作的任务失败信息。
|
|
76
|
+
"""
|
|
77
|
+
if not workdir or not os.path.isdir(workdir):
|
|
78
|
+
return ""
|
|
79
|
+
git_dir = os.path.join(workdir, ".git")
|
|
80
|
+
if not os.path.exists(git_dir):
|
|
81
|
+
return ""
|
|
82
|
+
try:
|
|
83
|
+
probe = subprocess.run(
|
|
84
|
+
["git", "-C", workdir, "rev-parse", "--git-dir"],
|
|
85
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
86
|
+
text=True, timeout=5, check=False,
|
|
87
|
+
)
|
|
88
|
+
except (OSError, subprocess.SubprocessError):
|
|
89
|
+
return "Git 命令不可用,无法读取仓库;请安装 Git 或改用非 Aider 实现器"
|
|
90
|
+
if probe.returncode != 0:
|
|
91
|
+
detail = (probe.stderr or probe.stdout or "仓库元数据不可读").strip()
|
|
92
|
+
return "Git 仓库不可读:%s;请先执行 git fsck/恢复 .git 对象后重试" % detail[-300:]
|
|
93
|
+
try:
|
|
94
|
+
probe = subprocess.run(
|
|
95
|
+
["git", "-C", workdir, "status", "--porcelain", "--untracked-files=no"],
|
|
96
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
97
|
+
text=True, timeout=8, check=False,
|
|
98
|
+
)
|
|
99
|
+
except subprocess.TimeoutExpired:
|
|
100
|
+
return "Git 仓库检查超时;请先执行 git status/git fsck 修复后重试"
|
|
101
|
+
except OSError:
|
|
102
|
+
return "Git 命令不可用,无法读取仓库;请安装 Git 或改用非 Aider 实现器"
|
|
103
|
+
if probe.returncode != 0:
|
|
104
|
+
detail = (probe.stderr or probe.stdout or "仓库对象或索引不可读").strip()
|
|
105
|
+
return "Git 仓库损坏或对象缺失:%s;请先执行 git fsck 并恢复仓库后重试" % detail[-300:]
|
|
106
|
+
return ""
|
|
68
107
|
|
|
69
108
|
|
|
70
109
|
def _npm_shim_bypass(argv):
|
|
@@ -844,8 +883,9 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
|
|
|
844
883
|
stdin_text = None
|
|
845
884
|
tmp_files = []
|
|
846
885
|
imgs = [str(p) for p in (images or []) if p]
|
|
847
|
-
if kind == "codex":
|
|
848
|
-
cp = agent.get("codex_provider")
|
|
886
|
+
if kind == "codex":
|
|
887
|
+
cp = agent.get("codex_provider")
|
|
888
|
+
reasoning = str(agent.get("reasoning_effort") or "").strip().lower()
|
|
849
889
|
if sid:
|
|
850
890
|
# resume 子命令不支持 -s 也不支持 --full-auto(0.154 实测:
|
|
851
891
|
# "unexpected argument '--full-auto'")——读/写模式都用 -c sandbox_mode
|
|
@@ -855,16 +895,18 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
|
|
|
855
895
|
if model:
|
|
856
896
|
argv += ["-m", model]
|
|
857
897
|
argv += ["-c", 'sandbox_mode="%s"' % _codex_sandbox(readonly)]
|
|
858
|
-
if cp:
|
|
859
|
-
argv += _codex_provider_args(cp)
|
|
898
|
+
if cp:
|
|
899
|
+
argv += _codex_provider_args(cp)
|
|
860
900
|
else:
|
|
861
901
|
argv = resolve_command(agent["command"]) + [
|
|
862
902
|
"exec", "--skip-git-repo-check", "--json",
|
|
863
903
|
"-s", _codex_sandbox(readonly)]
|
|
864
904
|
if model:
|
|
865
905
|
argv += ["-m", model]
|
|
866
|
-
if cp:
|
|
867
|
-
argv += _codex_provider_args(cp)
|
|
906
|
+
if cp:
|
|
907
|
+
argv += _codex_provider_args(cp)
|
|
908
|
+
if reasoning in ("low", "medium", "high", "xhigh"):
|
|
909
|
+
argv += ["-c", 'model_reasoning_effort="%s"' % reasoning]
|
|
868
910
|
# codex exec 与 exec resume 都支持 -i:图片直接附到 prompt(exec resume 的
|
|
869
911
|
# -i 附在恢复后发送的首条消息上,即本次 stdin prompt)
|
|
870
912
|
for p in imgs:
|
|
@@ -912,10 +954,11 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
|
|
|
912
954
|
if model:
|
|
913
955
|
argv += ["-m", model]
|
|
914
956
|
stdin_text = prompt
|
|
915
|
-
elif kind == "aider":
|
|
916
|
-
argv = resolve_command(agent["command"]) + [
|
|
917
|
-
"--yes-always", "--no-auto-commits", "--no-check-update",
|
|
918
|
-
|
|
957
|
+
elif kind == "aider":
|
|
958
|
+
argv = resolve_command(agent["command"]) + [
|
|
959
|
+
"--yes-always", "--no-auto-commits", "--no-check-update",
|
|
960
|
+
"--no-gitignore", "--no-pretty", "--no-stream",
|
|
961
|
+
# 网关自定义模型名(glm-5.1 等)litellm 全都不认识,警告页+建议列表
|
|
919
962
|
# 纯属刷屏(2026-09-20 实测占满步骤日志头部)
|
|
920
963
|
"--no-show-model-warnings", "--message", prompt]
|
|
921
964
|
if model:
|
|
@@ -987,7 +1030,7 @@ def _stall_timeout(env_name, default):
|
|
|
987
1030
|
return default
|
|
988
1031
|
|
|
989
1032
|
|
|
990
|
-
def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
1033
|
+
def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
991
1034
|
timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None, resume=None,
|
|
992
1035
|
images=None, require_tools=False):
|
|
993
1036
|
"""执行一次智能体调用,返回统一结构
|
|
@@ -1010,7 +1053,14 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1010
1053
|
orch_timeout_ms = (agent.get("orch") or {}).get("timeout_ms")
|
|
1011
1054
|
if orch_timeout_ms:
|
|
1012
1055
|
timeout = float(orch_timeout_ms) / 1000.0
|
|
1013
|
-
kind = agent.get("kind", "generic")
|
|
1056
|
+
kind = agent.get("kind", "generic")
|
|
1057
|
+
if kind == "aider":
|
|
1058
|
+
repo_issue = _git_repo_issue(workdir)
|
|
1059
|
+
if repo_issue:
|
|
1060
|
+
return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
|
|
1061
|
+
"tokens": 0, "usage": None, "error": repo_issue,
|
|
1062
|
+
"error_code": ErrorCode.VENDOR_ERROR, "raw": None,
|
|
1063
|
+
"kind": kind, "model": agent.get("model")}
|
|
1014
1064
|
if kind == "codex":
|
|
1015
1065
|
stall_t = _stall_timeout("TUTTI_CODEX_STALL_TIMEOUT", 600)
|
|
1016
1066
|
elif kind == "claude":
|
package/app/core/selfupdate.py
CHANGED
|
@@ -176,10 +176,17 @@ def apply_upgrade(port=None):
|
|
|
176
176
|
_PENDING_PORT = int(port) if port else None
|
|
177
177
|
except (TypeError, ValueError):
|
|
178
178
|
_PENDING_PORT = None
|
|
179
|
-
if install_mode() != "npm":
|
|
180
|
-
return {"error": "当前安装方式不支持自动升级(见版本页说明)"}
|
|
181
|
-
from . import store, jobs
|
|
182
|
-
|
|
179
|
+
if install_mode() != "npm":
|
|
180
|
+
return {"error": "当前安装方式不支持自动升级(见版本页说明)"}
|
|
181
|
+
from . import store, jobs
|
|
182
|
+
capacity = jobs.capacity_status()
|
|
183
|
+
if not capacity["accepting"]:
|
|
184
|
+
if capacity["restarting"]:
|
|
185
|
+
return {"error": "服务正在完成升级重启,请稍后再试"}
|
|
186
|
+
return {"error": "当前有 %d 个任务运行,已达并发保护上限(%d);"
|
|
187
|
+
"升级未启动且不会排队,请在任务结束后重试"
|
|
188
|
+
% (capacity["active"], capacity["limit"])}
|
|
189
|
+
try:
|
|
183
190
|
run = store.create_run(
|
|
184
191
|
"mgmt", "升级 CodeBee 本体(npm install -g %s@latest)" % _PKG_NAME,
|
|
185
192
|
entry_id="__self__", op="selfupgrade")
|
|
@@ -188,16 +195,23 @@ def apply_upgrade(port=None):
|
|
|
188
195
|
return {"error": "升级任务创建失败,请稍后重试"}
|
|
189
196
|
try:
|
|
190
197
|
jobs.enqueue({"kind": "selfupgrade", "run_id": run["id"]})
|
|
191
|
-
except Exception:
|
|
192
|
-
# run 已持久化;启动失败时显式收口,版本页不能停在误导性的待启动状态。
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
# run 已持久化;启动失败时显式收口,版本页不能停在误导性的待启动状态。
|
|
200
|
+
if isinstance(exc, jobs.JobsBusyError):
|
|
201
|
+
message = "升级未启动:%s" % str(exc)
|
|
202
|
+
log.warning("selfupdate: 升级任务因并发满载未启动 run=%s: %s",
|
|
203
|
+
run["id"], exc)
|
|
204
|
+
else:
|
|
205
|
+
log.exception("selfupdate: 升级任务启动失败 run=%s", run["id"])
|
|
206
|
+
# 仅并发保护类错误可直接展示;其他内部异常继续隐藏实现细节。
|
|
207
|
+
message = "升级任务启动失败,本次未排队,请稍后重试"
|
|
208
|
+
try:
|
|
209
|
+
store.update_run(run["id"], status="failed",
|
|
210
|
+
error=message,
|
|
211
|
+
ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
198
212
|
except Exception:
|
|
199
213
|
log.exception("selfupdate: 升级运行失败收口失败 run=%s", run["id"])
|
|
200
|
-
return {"error":
|
|
214
|
+
return {"error": message, "run_id": run["id"]}
|
|
201
215
|
return {"run_id": run["id"]}
|
|
202
216
|
|
|
203
217
|
|
package/app/core/store.py
CHANGED
|
@@ -104,12 +104,12 @@ def create_task(payload):
|
|
|
104
104
|
if not wd.is_dir():
|
|
105
105
|
raise ValueError("工作目录不存在: %s" % workdir)
|
|
106
106
|
mode = payload.get("mode")
|
|
107
|
-
if mode not in ("auto", "manual"):
|
|
108
|
-
mode = "manual" if payload.get("implementer") else "auto"
|
|
107
|
+
if mode not in ("auto", "fast", "expert", "manual"):
|
|
108
|
+
mode = "manual" if payload.get("implementer") else "auto"
|
|
109
109
|
difficulty = payload.get("difficulty")
|
|
110
110
|
if difficulty not in ("auto", "easy", "hard", "default"):
|
|
111
111
|
difficulty = "auto"
|
|
112
|
-
task = {
|
|
112
|
+
task = {
|
|
113
113
|
"id": _new_id("t"), "type": flow["id"], "engine": flow["engine"],
|
|
114
114
|
"title": title, "goal": goal,
|
|
115
115
|
"context": _text(payload.get("context"), "context"),
|
|
@@ -119,8 +119,16 @@ def create_task(payload):
|
|
|
119
119
|
"implementer": payload.get("implementer") or "",
|
|
120
120
|
"attachments": [],
|
|
121
121
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
122
|
-
"status": "created",
|
|
123
|
-
}
|
|
122
|
+
"status": "created",
|
|
123
|
+
}
|
|
124
|
+
thinking = str(payload.get("thinking") or "standard").strip().lower()
|
|
125
|
+
task["thinking"] = thinking if thinking in ("auto", "low", "standard", "high") else "auto"
|
|
126
|
+
if flow["engine"] == "direct":
|
|
127
|
+
task["direct_provider_id"] = _text(payload.get("direct_provider_id"),
|
|
128
|
+
"direct_provider_id")[:80]
|
|
129
|
+
task["direct_model"] = _text(payload.get("direct_model"), "direct_model")[:160]
|
|
130
|
+
if task["direct_model"] and not task["direct_provider_id"]:
|
|
131
|
+
raise ValueError("指定对话模型时必须同时指定厂商")
|
|
124
132
|
# 代码版本:仅当引用合法才固化(流水线执行前据此检出任务分支)
|
|
125
133
|
from . import gitmod
|
|
126
134
|
git_rev = _text(payload.get("git_rev"), "git_rev")
|
|
@@ -133,8 +141,8 @@ def create_task(payload):
|
|
|
133
141
|
task["git_rev"] = git_rev
|
|
134
142
|
if flow["engine"] == "code":
|
|
135
143
|
task["verify_command"] = _text(payload.get("verify_command"), "verify_command")
|
|
136
|
-
elif flow["engine"] == "direct":
|
|
137
|
-
pass # 直连任务:无验证命令也无评审参数,目标+附件即全部输入
|
|
144
|
+
elif flow["engine"] == "direct":
|
|
145
|
+
pass # 直连任务:无验证命令也无评审参数,目标+附件即全部输入
|
|
138
146
|
else:
|
|
139
147
|
ms = _text(payload.get("manuscript") or flow.get("manuscript") or "manuscript.md",
|
|
140
148
|
"manuscript")
|
|
@@ -478,7 +486,7 @@ def load_all():
|
|
|
478
486
|
|
|
479
487
|
# ---------------------------------------------------------------- 运行(含管理操作)
|
|
480
488
|
|
|
481
|
-
def create_run(kind, title, task_id=None, entry_id=None, op=None):
|
|
489
|
+
def create_run(kind, title, task_id=None, entry_id=None, op=None):
|
|
482
490
|
run = {
|
|
483
491
|
"id": _new_id("r" if kind == "orchestration" else "m"),
|
|
484
492
|
"kind": kind, # orchestration | mgmt
|
|
@@ -488,8 +496,21 @@ def create_run(kind, title, task_id=None, entry_id=None, op=None):
|
|
|
488
496
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
489
497
|
"started_at": None, "ended_at": None,
|
|
490
498
|
"cost_usd": 0.0, "tokens": 0, "error": "",
|
|
491
|
-
"verdict": None, "summary": "",
|
|
492
|
-
}
|
|
499
|
+
"verdict": None, "summary": "",
|
|
500
|
+
}
|
|
501
|
+
if kind == "orchestration" and task_id:
|
|
502
|
+
task = get_task(task_id) or {}
|
|
503
|
+
try:
|
|
504
|
+
from . import usage
|
|
505
|
+
eta = usage.estimate(
|
|
506
|
+
task_type=task.get("type") or "", mode=task.get("mode") or "auto",
|
|
507
|
+
thinking=task.get("thinking") or "standard",
|
|
508
|
+
rounds=task.get("rounds"))
|
|
509
|
+
run["estimated_duration_s"] = eta.get("estimated_duration_s")
|
|
510
|
+
run["estimated_p90_s"] = eta.get("p90_duration_s")
|
|
511
|
+
run["estimate_source"] = eta.get("duration_source")
|
|
512
|
+
except Exception:
|
|
513
|
+
pass
|
|
493
514
|
rdir = paths.RUNS_DIR / run["id"]
|
|
494
515
|
(rdir / "steps").mkdir(parents=True, exist_ok=True)
|
|
495
516
|
with LOCK:
|
|
@@ -814,12 +835,36 @@ def run_workdir(run_id):
|
|
|
814
835
|
_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".idea", ".vscode"}
|
|
815
836
|
|
|
816
837
|
# 构建产物/依赖缓存目录:里面的文件是工具链再生成的,不是任务成品
|
|
817
|
-
_BUILD_DIRS = {
|
|
838
|
+
_BUILD_DIRS = {
|
|
818
839
|
"target", "build", "dist", "out", "bin", "obj", # 通用构建输出
|
|
819
840
|
"surefire", "failsafe-reports", "test-output", "reports", # 测试/报告输出
|
|
820
841
|
".next", ".nuxt", ".output", ".gradle", ".gradle-home", # 前端/Gradle
|
|
821
842
|
"__MACOSX",
|
|
822
|
-
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
# CLI 自身的历史/评审中间件不属于用户成果。它们通常落在工作目录根部,
|
|
846
|
+
# 只按隐藏目录过滤会漏掉 Aider 的 .aider.* 与流程生成的 *_review.json。
|
|
847
|
+
_PROCESS_ARTIFACT_NAMES = {
|
|
848
|
+
".aider.chat.history.md", ".aider.input.history", ".aider.input.history.md",
|
|
849
|
+
".aider.tags.cache.v4", ".aider.tags.cache.v3", ".aider.conf.yml",
|
|
850
|
+
}
|
|
851
|
+
_PROCESS_ARTIFACT_SUFFIXES = ("_review.json", ".review.json")
|
|
852
|
+
_PROCESS_ARTIFACT_PREFIXES = ("tutti_prompt_", "_tutti_prompt_")
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _is_process_artifact(rel_name):
|
|
856
|
+
"""判断工作目录文件是否为 CLI/评审过程产物,而非可交付成果。"""
|
|
857
|
+
name = str(rel_name or "").replace("\\", "/")
|
|
858
|
+
base = name.rsplit("/", 1)[-1].lower()
|
|
859
|
+
if base in _PROCESS_ARTIFACT_NAMES:
|
|
860
|
+
return True
|
|
861
|
+
if base.startswith(".") and base.startswith(".aider"):
|
|
862
|
+
return True
|
|
863
|
+
if base.endswith(_PROCESS_ARTIFACT_SUFFIXES):
|
|
864
|
+
return True
|
|
865
|
+
if base.startswith(_PROCESS_ARTIFACT_PREFIXES):
|
|
866
|
+
return True
|
|
867
|
+
return False
|
|
823
868
|
|
|
824
869
|
|
|
825
870
|
def task_first_start(task_id, fallback=""):
|
|
@@ -880,7 +925,10 @@ def run_artifacts(run_id, limit=200):
|
|
|
880
925
|
continue
|
|
881
926
|
if st.st_mtime < t0:
|
|
882
927
|
continue
|
|
883
|
-
|
|
928
|
+
rel_name = str(p.relative_to(root)).replace("\\", "/")
|
|
929
|
+
if _is_process_artifact(rel_name):
|
|
930
|
+
continue
|
|
931
|
+
files.append({"name": rel_name,
|
|
884
932
|
"size": st.st_size, "mtime": int(st.st_mtime)})
|
|
885
933
|
if len(files) >= 800: # 防超大目录拖垮接口;截断后再排序取最新
|
|
886
934
|
break
|
package/app/core/task_compile.py
CHANGED
|
@@ -69,6 +69,9 @@ def compile_task(task):
|
|
|
69
69
|
"dimension": dimension,
|
|
70
70
|
"difficulty": difficulty,
|
|
71
71
|
"difficulty_reason": difficulty_reason,
|
|
72
|
+
"thinking": (str(raw.get("thinking") or "standard").lower()
|
|
73
|
+
if str(raw.get("thinking") or "standard").lower()
|
|
74
|
+
in ("auto", "low", "standard", "high") else "auto"),
|
|
72
75
|
"capabilities": _capabilities(raw, dimension, engine),
|
|
73
76
|
"deliverable": deliverable,
|
|
74
77
|
"quality_dimensions": rubric,
|
|
@@ -85,3 +88,111 @@ def summary(spec):
|
|
|
85
88
|
spec.get("type") or "direct", spec.get("engine") or "direct",
|
|
86
89
|
spec.get("difficulty") or "default",
|
|
87
90
|
"、".join(spec.get("capabilities") or []) or "通用")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def code_workflow(task, difficulty, plan=None, mode="auto", planned=True):
|
|
94
|
+
"""把难度、验收能力和规划建议收敛成代码任务的实际步骤策略。
|
|
95
|
+
|
|
96
|
+
自动模式允许低风险任务走短链;手动模式与高风险任务保留完整质量门禁。
|
|
97
|
+
规划器只能在安全边界内减少步骤,不能让无确定性验证的任务跳过评审。
|
|
98
|
+
"""
|
|
99
|
+
task = task if isinstance(task, Mapping) else {}
|
|
100
|
+
rec = (plan or {}).get("workflow") if isinstance(plan, Mapping) else None
|
|
101
|
+
rec = rec if isinstance(rec, Mapping) else {}
|
|
102
|
+
has_verify = bool(str(task.get("verify_command") or "").strip())
|
|
103
|
+
risk_text = " ".join(str(task.get(k) or "") for k in ("title", "goal", "context")).lower()
|
|
104
|
+
high_risk = bool(any(k in risk_text for k in (
|
|
105
|
+
"安全", "权限", "鉴权", "认证", "加密", "密钥", "注入", "迁移", "数据库",
|
|
106
|
+
"并发", "竞态", "支付", "public api", "公共 api", "breaking change",
|
|
107
|
+
"security", "permission", "auth", "migration", "concurrency")))
|
|
108
|
+
fast_mode = mode == "fast"
|
|
109
|
+
expert_mode = mode == "expert"
|
|
110
|
+
easy_auto = (mode == "auto" and difficulty == "easy") or fast_mode
|
|
111
|
+
|
|
112
|
+
review_required = True
|
|
113
|
+
max_repairs = 2
|
|
114
|
+
allow_switch = True
|
|
115
|
+
reason = "复杂或手动任务使用完整质量门禁"
|
|
116
|
+
if easy_auto:
|
|
117
|
+
review_required = high_risk or not has_verify
|
|
118
|
+
max_repairs = 1
|
|
119
|
+
allow_switch = False
|
|
120
|
+
reason = ("低风险任务以确定性验证代替模型评审"
|
|
121
|
+
if has_verify else "低风险任务无验证命令,保留一次模型评审")
|
|
122
|
+
if isinstance(rec.get("review_required"), bool):
|
|
123
|
+
review_required = high_risk or rec["review_required"] or not has_verify
|
|
124
|
+
try:
|
|
125
|
+
if "max_repair_rounds" in rec:
|
|
126
|
+
max_repairs = max(1 if not has_verify else 0,
|
|
127
|
+
min(1, int(rec["max_repair_rounds"])))
|
|
128
|
+
except (TypeError, ValueError):
|
|
129
|
+
pass
|
|
130
|
+
elif expert_mode or (mode == "auto" and difficulty == "hard"):
|
|
131
|
+
review_required = True
|
|
132
|
+
max_repairs = 2
|
|
133
|
+
allow_switch = True
|
|
134
|
+
try:
|
|
135
|
+
if "max_repair_rounds" in rec:
|
|
136
|
+
max_repairs = max(1, min(2, int(rec["max_repair_rounds"])))
|
|
137
|
+
except (TypeError, ValueError):
|
|
138
|
+
pass
|
|
139
|
+
if isinstance(rec.get("allow_switch"), bool):
|
|
140
|
+
allow_switch = rec["allow_switch"]
|
|
141
|
+
reason = "复杂任务保留规划、验证与模型评审"
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
"planning": "llm" if planned else "inline",
|
|
145
|
+
"implementation_steps": len((plan or {}).get("steps") or []) or 1,
|
|
146
|
+
"verification": has_verify,
|
|
147
|
+
"review_required": review_required,
|
|
148
|
+
"reviewers": 1 if review_required else 0,
|
|
149
|
+
"max_repair_rounds": max_repairs,
|
|
150
|
+
"allow_switch": allow_switch,
|
|
151
|
+
"reason": reason,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def content_workflow(task, difficulty, plan=None, mode="auto"):
|
|
156
|
+
"""为非连载内容任务生成动态大纲/评审/修订策略。"""
|
|
157
|
+
task = task if isinstance(task, Mapping) else {}
|
|
158
|
+
ttype = str(task.get("type") or "doc")
|
|
159
|
+
requested_rounds = max(1, min(5, int(task.get("rounds") or 2)))
|
|
160
|
+
rec = (plan or {}).get("workflow") if isinstance(plan, Mapping) else None
|
|
161
|
+
rec = rec if isinstance(rec, Mapping) else {}
|
|
162
|
+
light_types = {"email", "weekly_report", "translation"}
|
|
163
|
+
deep_types = {"novel", "research", "tech_proposal"}
|
|
164
|
+
|
|
165
|
+
if mode == "fast":
|
|
166
|
+
return {"outline": False, "reviewers": 1, "review_rounds": 1,
|
|
167
|
+
"reason": "快速模式省略独立大纲,仅保留一次快速评审"}
|
|
168
|
+
if mode == "expert":
|
|
169
|
+
return {"outline": True, "reviewers": 2,
|
|
170
|
+
"review_rounds": min(requested_rounds, 3),
|
|
171
|
+
"reason": "专家模式启用完整大纲、多评审与修订门禁"}
|
|
172
|
+
if mode != "auto":
|
|
173
|
+
return {"outline": True, "reviewers": 0,
|
|
174
|
+
"review_rounds": requested_rounds,
|
|
175
|
+
"reason": "手动模式尊重用户指定的评审组与轮数"}
|
|
176
|
+
|
|
177
|
+
if ttype in light_types and difficulty != "hard":
|
|
178
|
+
return {"outline": False, "reviewers": 1, "review_rounds": 1,
|
|
179
|
+
"reason": "轻量内容省略独立大纲,只做一次快速评审"}
|
|
180
|
+
|
|
181
|
+
outline = ttype in deep_types or difficulty == "hard"
|
|
182
|
+
reviewers = 2 if outline else 1
|
|
183
|
+
review_rounds = min(requested_rounds, 2 if outline else 1)
|
|
184
|
+
try:
|
|
185
|
+
if "reviewers" in rec:
|
|
186
|
+
reviewers = max(1, min(2, int(rec["reviewers"])))
|
|
187
|
+
if "review_rounds" in rec:
|
|
188
|
+
review_rounds = max(1, min(requested_rounds, 3,
|
|
189
|
+
int(rec["review_rounds"])))
|
|
190
|
+
except (TypeError, ValueError):
|
|
191
|
+
pass
|
|
192
|
+
# 深度类型至少保留一轮评审,但允许规划器把双评审降成一名;高门槛强制双评审。
|
|
193
|
+
if float(task.get("threshold") or 7.0) >= 8.5:
|
|
194
|
+
reviewers = 2
|
|
195
|
+
return {"outline": outline, "reviewers": reviewers,
|
|
196
|
+
"review_rounds": review_rounds,
|
|
197
|
+
"reason": ("深度内容保留大纲和多轮质量门禁" if outline
|
|
198
|
+
else "标准内容使用单评审短链")}
|
package/app/core/usage.py
CHANGED
|
@@ -655,11 +655,19 @@ def summary(days=30, recent_limit=30):
|
|
|
655
655
|
}
|
|
656
656
|
|
|
657
657
|
|
|
658
|
-
|
|
659
|
-
"""
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
658
|
+
_DURATION_BASELINES = {
|
|
659
|
+
"direct": 90, "rank_scan": 240, "email": 180, "weekly_report": 240,
|
|
660
|
+
"translation": 240, "speech": 360, "video_script": 360, "article": 480,
|
|
661
|
+
"doc": 480, "resume": 420, "code": 600, "tech_proposal": 720,
|
|
662
|
+
"research": 900, "novel": 720, "serial_novel": 2400,
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def estimate(task_type="", days=90, mode="auto", thinking="standard", rounds=None):
|
|
667
|
+
"""同类任务开跑前成本与耗时预估(数据源为本地用量台账)。
|
|
668
|
+
|
|
669
|
+
同一 run 的多条步骤记录加总为一个样本,优先用成功 run,给中位数/平均/P90。
|
|
670
|
+
无历史样本时使用流程级保守基线;模式与思考程度只做透明倍率修正。"""
|
|
663
671
|
span = max(1, min(3650, int(days or 90)))
|
|
664
672
|
with LOCK:
|
|
665
673
|
records = _iter_records(span)
|
|
@@ -671,24 +679,53 @@ def estimate(task_type="", days=90):
|
|
|
671
679
|
rid = str(r.get("run_id") or "")
|
|
672
680
|
if not rid:
|
|
673
681
|
continue
|
|
674
|
-
g = runs.setdefault(rid, {"tokens": 0, "cost": 0.0,
|
|
675
|
-
|
|
676
|
-
g["
|
|
682
|
+
g = runs.setdefault(rid, {"tokens": 0, "cost": 0.0,
|
|
683
|
+
"duration": 0.0, "ok": False})
|
|
684
|
+
g["tokens"] += _num(r, "total")
|
|
685
|
+
g["cost"] += _parse_float(r.get("cost_usd"))
|
|
686
|
+
g["duration"] += max(0.0, _parse_float(r.get("duration_s")))
|
|
677
687
|
if r.get("ok"):
|
|
678
688
|
g["ok"] = True
|
|
679
689
|
ok_runs = [g for g in runs.values() if g["ok"]]
|
|
680
690
|
basis = ok_runs or list(runs.values())
|
|
681
|
-
|
|
682
|
-
|
|
691
|
+
mode_factor = {"fast": 0.60, "auto": 1.0, "expert": 1.65,
|
|
692
|
+
"manual": 1.0}.get(str(mode or "auto"), 1.0)
|
|
693
|
+
thinking_factor = {"low": 0.78, "standard": 1.0, "high": 1.45,
|
|
694
|
+
"auto": 1.0}.get(str(thinking or "standard"), 1.0)
|
|
695
|
+
factor = mode_factor * thinking_factor
|
|
696
|
+
try:
|
|
697
|
+
if rounds is not None and int(rounds) > 2:
|
|
698
|
+
factor *= 1.0 + min(3, int(rounds) - 2) * 0.18
|
|
699
|
+
except (TypeError, ValueError):
|
|
700
|
+
pass
|
|
701
|
+
if not basis:
|
|
702
|
+
baseline = _DURATION_BASELINES.get(task_type, 480)
|
|
703
|
+
median_s = max(30, int(baseline * factor))
|
|
704
|
+
return {"task_type": task_type or "", "days": span, "samples": 0,
|
|
705
|
+
"estimated_duration_s": median_s,
|
|
706
|
+
"p90_duration_s": max(median_s + 30, int(median_s * 1.7)),
|
|
707
|
+
"duration_source": "baseline"}
|
|
683
708
|
toks = sorted(g["tokens"] for g in basis)
|
|
684
|
-
costs = sorted(g["cost"] for g in basis)
|
|
709
|
+
costs = sorted(g["cost"] for g in basis)
|
|
710
|
+
durations = sorted(g["duration"] for g in basis if g["duration"] > 0)
|
|
685
711
|
n = len(toks)
|
|
686
712
|
mid = n // 2
|
|
687
713
|
med = toks[mid] if n % 2 else (toks[mid - 1] + toks[mid]) / 2.0
|
|
688
714
|
med_c = costs[mid] if n % 2 else (costs[mid - 1] + costs[mid]) / 2.0
|
|
689
715
|
import datetime
|
|
690
|
-
import math
|
|
691
|
-
|
|
716
|
+
import math
|
|
717
|
+
if durations:
|
|
718
|
+
dn = len(durations)
|
|
719
|
+
dmid = dn // 2
|
|
720
|
+
dmed = (durations[dmid] if dn % 2
|
|
721
|
+
else (durations[dmid - 1] + durations[dmid]) / 2.0)
|
|
722
|
+
dp90 = durations[max(0, min(dn - 1, math.ceil(0.9 * dn) - 1))]
|
|
723
|
+
duration_source = "history"
|
|
724
|
+
else:
|
|
725
|
+
dmed = _DURATION_BASELINES.get(task_type, 480)
|
|
726
|
+
dp90 = dmed * 1.7
|
|
727
|
+
duration_source = "baseline"
|
|
728
|
+
return {
|
|
692
729
|
"task_type": task_type or "",
|
|
693
730
|
"days": span,
|
|
694
731
|
"samples": n,
|
|
@@ -697,6 +734,10 @@ def estimate(task_type="", days=90):
|
|
|
697
734
|
"median_tokens": int(med),
|
|
698
735
|
"p90_tokens": toks[max(0, min(n - 1, math.ceil(0.9 * n) - 1))],
|
|
699
736
|
"avg_cost_usd": round(sum(costs) / n, 4),
|
|
700
|
-
"median_cost_usd": round(med_c, 4),
|
|
737
|
+
"median_cost_usd": round(med_c, 4),
|
|
738
|
+
"estimated_duration_s": max(30, int(dmed * factor)),
|
|
739
|
+
"p90_duration_s": max(60, int(dp90 * factor)),
|
|
740
|
+
"duration_samples": len(durations),
|
|
741
|
+
"duration_source": duration_source,
|
|
701
742
|
"since": (datetime.date.today() - datetime.timedelta(days=span - 1)).isoformat(),
|
|
702
743
|
}
|