codebee 0.1.4 → 0.1.5
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 +36 -0
- package/README.md +9 -0
- package/app/core/attachments.py +40 -0
- package/app/core/bookmeta.py +157 -52
- package/app/core/bookmeta_catalog.py +67 -90
- package/app/core/builtin_agent.py +190 -8
- package/app/core/flows.py +328 -328
- package/app/core/gitmod.py +82 -8
- package/app/core/history.py +8 -2
- package/app/core/jobs.py +9 -2
- package/app/core/manager.py +151 -1
- package/app/core/modelhub.py +86 -4
- package/app/core/pipeline.py +2326 -2267
- package/app/core/router.py +8 -3
- package/app/core/runner.py +185 -21
- package/app/core/selfupdate.py +54 -11
- package/app/core/store.py +4 -2
- package/app/main.py +101 -7
- package/app/ui/app.js +279 -49
- package/app/ui/i18n.js +19 -5
- package/app/ui/index.html +6 -2
- package/app/ui/style.css +3379 -2764
- package/package.json +2 -1
package/app/core/pipeline.py
CHANGED
|
@@ -1,2267 +1,2326 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""编排流水线 v2:智能模式(规划→路由→执行→验证→评审→自动修复→换将)+ 手动模式。
|
|
3
|
-
|
|
4
|
-
安全约束:稿件文件名在流水线内再次消毒(basename + 去分隔符),且每次
|
|
5
|
-
open 前都用 commonpath 校验路径必须落在任务工作目录内,防止目录穿越。
|
|
6
|
-
|
|
7
|
-
智能模式(mode=auto):
|
|
8
|
-
1. 规划器把目标拆成有序子任务(LLM 计划,失败退化为单步模板);
|
|
9
|
-
2. 路由器按能力基线 × 历史胜率选实现者/评审者(跨厂商评审约束);
|
|
10
|
-
3. 验证/评审不通过 → 自动把问题清单发回实现者修复(至多 router.MAX_REPAIR_ROUNDS 轮);
|
|
11
|
-
4. 仍不通过 → 自动换将重实现一次;
|
|
12
|
-
5. 全程记录"为什么选它"与每轮修复结果。
|
|
13
|
-
手动模式(mode=manual):用户显式指定实现者/评审组,行为同 v1。
|
|
14
|
-
"""
|
|
15
|
-
from __future__ import annotations
|
|
16
|
-
|
|
17
|
-
import os
|
|
18
|
-
import re
|
|
19
|
-
import threading
|
|
20
|
-
import time
|
|
21
|
-
|
|
22
|
-
from . import catalog, history, jobs, manager, modelhub, mocks, planner, registry, router, runner, skills, store, usage
|
|
23
|
-
from . import builtin_agent
|
|
24
|
-
from . import diagnostics
|
|
25
|
-
from . import paths as paths_mod
|
|
26
|
-
from . import session_log as session_log_mod
|
|
27
|
-
from . import step_runner as step_runner_mod
|
|
28
|
-
from .repeat_guard import guard as repeat_guard
|
|
29
|
-
|
|
30
|
-
DEFAULT_RUBRIC = ["情节", "人物", "文笔", "节奏", "吸引力"]
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class Cancelled(Exception):
|
|
34
|
-
pass
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _now():
|
|
38
|
-
return time.strftime("%Y-%m-%d %H:%M:%S")
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def _check_cancel(ev):
|
|
42
|
-
if ev is not None and ev.is_set():
|
|
43
|
-
raise Cancelled()
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def _inside(dirpath, target):
|
|
47
|
-
try:
|
|
48
|
-
return os.path.commonpath(
|
|
49
|
-
[os.path.abspath(dirpath), os.path.abspath(target)]) == os.path.abspath(dirpath)
|
|
50
|
-
except ValueError:
|
|
51
|
-
return False
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _task_images(task, workdir):
|
|
55
|
-
"""任务的图片附件绝对路径(仅 codex 原生 -i 用)。无附件/异常返回空列表。"""
|
|
56
|
-
try:
|
|
57
|
-
from . import attachments as att_mod
|
|
58
|
-
return att_mod.image_paths(task, workdir)
|
|
59
|
-
except Exception:
|
|
60
|
-
return []
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _ms_name(raw):
|
|
64
|
-
name = re.sub(r"[\\/\x00]+", "_", str(raw or "")).strip()
|
|
65
|
-
name = re.sub(r"\.{2,}", "_", name).lstrip(".")
|
|
66
|
-
return os.path.basename(name) or "manuscript.md"
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def _ms_io(workdir, raw_name, mode):
|
|
70
|
-
"""打开稿件文件;open 紧邻边界校验,路径越界直接拒绝。"""
|
|
71
|
-
name = _ms_name(raw_name)
|
|
72
|
-
p = os.path.abspath(os.path.join(workdir, name))
|
|
73
|
-
if not _inside(workdir, p):
|
|
74
|
-
raise ValueError("稿件路径越界,已拒绝: %r" % raw_name)
|
|
75
|
-
return open(p, mode, encoding="utf-8", errors="replace")
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def _agents():
|
|
79
|
-
return registry.effective_agents(catalog.load(), manager.detect_all())
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def _pick(agents, agent_id):
|
|
83
|
-
for a in agents:
|
|
84
|
-
if a["id"] == agent_id:
|
|
85
|
-
return a
|
|
86
|
-
return None
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def _real(agents):
|
|
90
|
-
return [a for a in agents if a.get("mode") == "real"]
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def _pick_implementer(agents, wanted):
|
|
94
|
-
real = _real(agents)
|
|
95
|
-
if wanted:
|
|
96
|
-
a = _pick(agents, wanted)
|
|
97
|
-
if a:
|
|
98
|
-
return a, ""
|
|
99
|
-
if real:
|
|
100
|
-
return real[0], ""
|
|
101
|
-
return (agents[0], "") if agents else (None, "")
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def _pick_critics_manual(agents, task):
|
|
105
|
-
wanted = task.get("critics") or []
|
|
106
|
-
if wanted:
|
|
107
|
-
picked = [a for a in agents if a["id"] in wanted]
|
|
108
|
-
if picked:
|
|
109
|
-
return picked
|
|
110
|
-
real = _real(agents)
|
|
111
|
-
if real:
|
|
112
|
-
return real
|
|
113
|
-
return [a for a in agents if a.get("mode") == "mock"] or agents[:2]
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _valid_resume(task, agents):
|
|
117
|
-
"""校验任务上的 resume 声明;返回 {agent, session, project, note} 或 None。"""
|
|
118
|
-
r = task.get("resume") or {}
|
|
119
|
-
if not (r.get("agent") and r.get("session")):
|
|
120
|
-
return None
|
|
121
|
-
a = _pick(agents, r.get("agent"))
|
|
122
|
-
if a is None or a.get("mode") != "real":
|
|
123
|
-
return None
|
|
124
|
-
return {"agent": a, "session": r["session"],
|
|
125
|
-
"project": (r.get("project") or "").strip(),
|
|
126
|
-
"note": "沿用已有会话 %s…(保留其上下文继续工作)" % str(r["session"])[:8]}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def _resume_workdir(resume_ctx, fallback):
|
|
130
|
-
"""续会话的工作目录:CLI 必须在会话所属项目目录下启动才能定位到会话
|
|
131
|
-
(opencode/qwen 实测按 cwd 查找,否则报找不到或直接挂起)。
|
|
132
|
-
目录已不存在时退回任务工作目录。"""
|
|
133
|
-
if not resume_ctx:
|
|
134
|
-
return fallback
|
|
135
|
-
proj = resume_ctx.get("project") or ""
|
|
136
|
-
if proj and os.path.isdir(proj):
|
|
137
|
-
return proj
|
|
138
|
-
return fallback
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def _compaction_enabled():
|
|
142
|
-
"""Phase 2 灰度开关:环境变量 TUTTI_COMPACTION=1 启用上下文压缩(默认关)。"""
|
|
143
|
-
return os.environ.get("TUTTI_COMPACTION") == "1"
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
_sessions_cache = {}
|
|
147
|
-
_sessions_lock = threading.Lock()
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def _get_session(run_id):
|
|
151
|
-
"""每 run 一个 surface 会话日志(data/runs/<id>/session.jsonl)。"""
|
|
152
|
-
with _sessions_lock:
|
|
153
|
-
s = _sessions_cache.get(run_id)
|
|
154
|
-
if s is None:
|
|
155
|
-
sdir = paths_mod.RUNS_DIR / run_id
|
|
156
|
-
sdir.mkdir(parents=True, exist_ok=True)
|
|
157
|
-
s = session_log_mod.Session(run_id, store_path=str(sdir / "session.jsonl"))
|
|
158
|
-
_sessions_cache[run_id] = s
|
|
159
|
-
return s
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def _make_llm_caller(agent, workdir):
|
|
163
|
-
"""压缩摘要用 LLM:直接复用当前 step 的 agent(同 CLI 同模型)。"""
|
|
164
|
-
def caller(messages):
|
|
165
|
-
prompt = "\n\n".join(m.get("content", "") for m in messages)
|
|
166
|
-
res = runner.run_agent(agent, prompt, workdir=workdir, readonly=True,
|
|
167
|
-
timeout=300)
|
|
168
|
-
return res.get("text") or ""
|
|
169
|
-
return caller
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def _resume_sid(agent, sid):
|
|
173
|
-
"""§07 T1.1:该 agent 是否可用会话 id 续会话;不可用返回 None(退回全新调用)。
|
|
174
|
-
|
|
175
|
-
codex/claude/opencode/qwen 原生支持 resume;generic 需 catalog 配了
|
|
176
|
-
resume_argv_template;mock/其余一律 None。避免 run_agent 对 generic 的
|
|
177
|
-
「未配置会话恢复」硬失败把修订流程打断。
|
|
178
|
-
"""
|
|
179
|
-
sid = (sid or "").strip()
|
|
180
|
-
if not sid or agent.get("mode") == "mock":
|
|
181
|
-
return None
|
|
182
|
-
kind = agent.get("kind", "generic")
|
|
183
|
-
if kind in ("codex", "claude", "opencode", "qwen"):
|
|
184
|
-
return sid
|
|
185
|
-
if kind == "generic" and agent.get("resume_argv_template"):
|
|
186
|
-
return sid
|
|
187
|
-
return None
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def _is_review_role(role):
|
|
191
|
-
"""评审类步骤:用户指令在此类步骤注入时升级为「评分依据」,不再是普通纠偏。"""
|
|
192
|
-
r = str(role or "")
|
|
193
|
-
return "critique" in r or r == "review"
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def _drain_directives(run_id, workdir, role=None, step_n=None):
|
|
197
|
-
"""取出运行中积压的用户指令(store.drain_messages),拼成注入块 + 收集图片附件。
|
|
198
|
-
|
|
199
|
-
无头 CLI 没有交互 stdin,插不进正在跑的进程——指令在下一个步骤开始前
|
|
200
|
-
生效(轮间干预),所以 drain 放在 _run_step 的真实调用分支。
|
|
201
|
-
role/step_n 仅作送达回执(consumed_by);评审类步骤额外追加「评分依据」
|
|
202
|
-
框架文案,把用户意见变成评审判定的正式输入(插话进评审门)。
|
|
203
|
-
返回 (注入文本块 或 "", 图片绝对路径列表);消费即标记,不会重复注入。
|
|
204
|
-
"""
|
|
205
|
-
try:
|
|
206
|
-
msgs = store.drain_messages(run_id, consumed_by={"step": step_n, "role": role})
|
|
207
|
-
except Exception:
|
|
208
|
-
return "", []
|
|
209
|
-
if not msgs:
|
|
210
|
-
return "", []
|
|
211
|
-
lines = ["## 用户实时指令(运行中追加,针对当前进展的纠偏,优先级高于原始要求)"]
|
|
212
|
-
if _is_review_role(role):
|
|
213
|
-
lines.append("本步为评审步骤:请把上述用户意见作为评分依据之一,"
|
|
214
|
-
"在相应维度的分数与 issues 中明确体现(引用用户原话)。")
|
|
215
|
-
imgs = []
|
|
216
|
-
for m in msgs:
|
|
217
|
-
stamp = m.get("created_at") or ""
|
|
218
|
-
sender = m.get("sender") or "用户"
|
|
219
|
-
text = (m.get("text") or "").strip()
|
|
220
|
-
lines.append("- [%s %s] %s" % (stamp, sender, text) if text
|
|
221
|
-
else "- [%s %s](附件指令,见下方文件)" % (stamp, sender))
|
|
222
|
-
for rel in (m.get("attachments") or []):
|
|
223
|
-
rel = str(rel)
|
|
224
|
-
low = rel.lower()
|
|
225
|
-
if low.endswith((".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")):
|
|
226
|
-
ap = os.path.join(workdir or "", rel) if workdir else rel
|
|
227
|
-
if workdir and os.path.isfile(ap):
|
|
228
|
-
imgs.append(ap)
|
|
229
|
-
lines.append(" · 图片附件:%s(请查看图片内容)" % rel)
|
|
230
|
-
else:
|
|
231
|
-
lines.append(" · 图片附件:%s" % rel)
|
|
232
|
-
else:
|
|
233
|
-
lines.append(" · 文件附件:%s(位于工作目录,可直接读取)" % rel)
|
|
234
|
-
return "\n".join(lines), imgs
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
def _steered_task(run_id, task):
|
|
238
|
-
"""规划/大纲步骤的输入任务副本:未消费的用户指令合入 context(peek 不消费)。
|
|
239
|
-
|
|
240
|
-
编排者决策(code plan / 连载大纲)不走 _run_step,消息只在 context 里可见;
|
|
241
|
-
peek 语义保证后续真实步骤仍会 drain 注入——规划者和执行者都看到,双保险。
|
|
242
|
-
"""
|
|
243
|
-
try:
|
|
244
|
-
msgs = store.peek_messages(run_id)
|
|
245
|
-
except Exception:
|
|
246
|
-
return task
|
|
247
|
-
if not msgs:
|
|
248
|
-
return task
|
|
249
|
-
lines = ["## 用户实时指令(运行中追加,规划时必须纳入考量)"]
|
|
250
|
-
for m in msgs:
|
|
251
|
-
text = (m.get("text") or "").strip()
|
|
252
|
-
if text:
|
|
253
|
-
lines.append("- [%s %s] %s" % (m.get("created_at") or "",
|
|
254
|
-
m.get("sender") or "用户", text))
|
|
255
|
-
for rel in (m.get("attachments") or []):
|
|
256
|
-
lines.append(" · 附件:%s(位于工作目录,可直接读取)" % rel)
|
|
257
|
-
t2 = dict(task)
|
|
258
|
-
t2["context"] = ((task.get("context") or "") + "\n\n" + "\n".join(lines)).strip()
|
|
259
|
-
return t2
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
def _wait_gate(run_id, ev):
|
|
263
|
-
"""暂停闸门:run.paused 标志位挂在下一个步骤开始前,放行或取消才继续。
|
|
264
|
-
|
|
265
|
-
轮询 1s(本地内存读,开销可忽略);取消事件优先——用户点「取消运行」
|
|
266
|
-
不必先解除暂停。终止态(服务重启恢复/外部取消)同样放行,防卡死。"""
|
|
267
|
-
while True:
|
|
268
|
-
run = store.get_run(run_id) or {}
|
|
269
|
-
if not run.get("paused") or run.get("status") not in ("queued", "running"):
|
|
270
|
-
return
|
|
271
|
-
if ev is not None and ev.is_set():
|
|
272
|
-
return
|
|
273
|
-
time.sleep(1.0)
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner.DEFAULT_TIMEOUT, note="", resume=None, images=None):
|
|
277
|
-
"""执行一个智能体步骤并记录。返回 runner 统一结果。"""
|
|
278
|
-
_wait_gate(run_id, ev)
|
|
279
|
-
# 绑定解析为空 → CLI 将回落本机默认配置(用户配置的模型/供应商全部不生效)。
|
|
280
|
-
# 2026-09-16 实测:这种状态下烧干配额的本机默认供应商被静默使用,用户以为
|
|
281
|
-
# 在用自己配的模型。首次出现时在步骤备注里醒目标出。
|
|
282
|
-
if agent.get("mode") == "real" and not (agent.get("call_chain") or agent.get("env")):
|
|
283
|
-
note = ((note + ";") if note else "") + \
|
|
284
|
-
"⚠ 未解析到绑定链,本步回落 CLI 本机默认配置(请在模型接入页检查该 CLI 的供应商绑定)"
|
|
285
|
-
step, log_abs = store.add_step(run_id, role, agent["id"],
|
|
286
|
-
agent.get("label", agent["id"]), note=note)
|
|
287
|
-
start = time.time()
|
|
288
|
-
if agent.get("mode") == "mock":
|
|
289
|
-
time.sleep(0.3)
|
|
290
|
-
res = {"ok": True, "text": "[mock] %s" % prompt[:80], "json": None,
|
|
291
|
-
"cost_usd": 0.0, "tokens": 0, "error": "", "raw": {"exit_code": 0}}
|
|
292
|
-
if log_abs:
|
|
293
|
-
try:
|
|
294
|
-
log_abs.write_text("[mock 智能体] 跳过真实调用\n", encoding="utf-8")
|
|
295
|
-
except Exception:
|
|
296
|
-
pass
|
|
297
|
-
else:
|
|
298
|
-
# 5C:重复调用守门——指纹取原始 prompt(提醒注入 spawn 副本,不污染计数链)
|
|
299
|
-
guard = repeat_guard.check(run_id, role, prompt)
|
|
300
|
-
if guard["should_stop"]:
|
|
301
|
-
from .error_codes import ErrorCode
|
|
302
|
-
res = {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
|
|
303
|
-
"tokens": 0, "usage": None, "error": guard["reminder"],
|
|
304
|
-
"error_code": ErrorCode.ENV_BLOCK,
|
|
305
|
-
"raw": {"exit_code": None}, "kind": agent.get("kind", "generic"),
|
|
306
|
-
"model": agent.get("model")}
|
|
307
|
-
_finish_step_result(run_id, step, res, role, agent, start)
|
|
308
|
-
return res
|
|
309
|
-
# 运行中指挥:drain 用户追加的指令/附件,注入本步(守门拦截时不 drain,
|
|
310
|
-
# 消息留给下一个真实步骤,不空耗);role/step_n 作送达回执
|
|
311
|
-
directive_block, directive_imgs = _drain_directives(run_id, workdir,
|
|
312
|
-
role=role, step_n=step["n"])
|
|
313
|
-
if directive_imgs:
|
|
314
|
-
images = list(images or []) + directive_imgs
|
|
315
|
-
if directive_block:
|
|
316
|
-
prompt = directive_block + "\n\n---\n\n" + prompt
|
|
317
|
-
effective_prompt = (guard["reminder"] + "\n\n---\n\n" + prompt) if guard["reminder"] else prompt
|
|
318
|
-
res = _spawn_step(session_run_id=run_id, role=role, agent=agent,
|
|
319
|
-
prompt=effective_prompt, workdir=workdir, readonly=readonly,
|
|
320
|
-
ev=ev, timeout=timeout, resume=resume, step=step,
|
|
321
|
-
log_abs=log_abs, images=images)
|
|
322
|
-
# 先收尾再查取消:取消时进程已被 run_process 杀停,若先抛 Cancelled,
|
|
323
|
-
# 步骤记录会永远停在「运行中」变僵尸(与 _run_verify 的顺序对齐)
|
|
324
|
-
_finish_step_result(run_id, step, res, role, agent, start)
|
|
325
|
-
_check_cancel(ev)
|
|
326
|
-
return res
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note=""):
|
|
330
|
-
"""内置智能体步骤:直连模型 API + 工具循环(builtin_agent),不经 CLI 进程。
|
|
331
|
-
|
|
332
|
-
与 _run_step 对齐的三件事:暂停/取消闸门、运行中指令 drain 注入、重复调用
|
|
333
|
-
守门;结果同样经 _finish_step_result 落步骤(output=干净回答)并入用量台账。
|
|
334
|
-
日志只有「迭代/工具」摘要行——对话视图吃 output,日志抽屉看工具轨迹。"""
|
|
335
|
-
_wait_gate(run_id, ev)
|
|
336
|
-
step, log_abs = store.add_step(run_id, role, "builtin", "
|
|
337
|
-
start = time.time()
|
|
338
|
-
agent_pseudo = {"id": "builtin", "label": "
|
|
339
|
-
"provider": {"id": bi.get("provider_id") or "",
|
|
340
|
-
"name": bi.get("provider_name") or ""}}
|
|
341
|
-
guard = repeat_guard.check(run_id, role, prompt)
|
|
342
|
-
if guard["should_stop"]:
|
|
343
|
-
from .error_codes import ErrorCode
|
|
344
|
-
res = {"ok": False, "text": "", "usage": None, "cost_usd": 0.0, "tokens": 0,
|
|
345
|
-
"error": guard["reminder"], "error_code": ErrorCode.ENV_BLOCK,
|
|
346
|
-
"raw": {"exit_code": None}, "model": bi.get("model")}
|
|
347
|
-
_finish_step_result(run_id, step, res, role, agent_pseudo, start)
|
|
348
|
-
return res
|
|
349
|
-
# 运行中指挥:drain 用户追加的指令/附件,注入本轮(与 _run_step 同语义)
|
|
350
|
-
directive_block,
|
|
351
|
-
if
|
|
352
|
-
|
|
353
|
-
if
|
|
354
|
-
prompt =
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
res
|
|
370
|
-
res["
|
|
371
|
-
res
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
return 0
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
used =
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
"
|
|
416
|
-
"
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
status = "
|
|
461
|
-
|
|
462
|
-
status = "
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
"
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
health.
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
"
|
|
560
|
-
"
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
v_status, v_sum = "
|
|
602
|
-
|
|
603
|
-
v_status = "
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
.replace("
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
lines.append("-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
impl,
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
reviewer, route["reviewer"] =
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
#
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
.replace("
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
if not implement_all(impl,
|
|
789
|
-
return
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
if
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
"
|
|
837
|
-
"
|
|
838
|
-
"
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
"
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
"
|
|
847
|
-
|
|
848
|
-
"-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
""
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
prompt = (
|
|
1025
|
-
.replace("__GOAL__", task["goal"])
|
|
1026
|
-
.replace("__CONTEXT__", task.get("context") or "(无)"))
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
prompt =
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
#
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
return ""
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
##
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
return ""
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
"
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
store.
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
store.
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
.replace("
|
|
1577
|
-
.replace("
|
|
1578
|
-
.replace("
|
|
1579
|
-
.replace("
|
|
1580
|
-
.replace("
|
|
1581
|
-
.replace("
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
#
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
workdir
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
#
|
|
2171
|
-
#
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
#
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""编排流水线 v2:智能模式(规划→路由→执行→验证→评审→自动修复→换将)+ 手动模式。
|
|
3
|
+
|
|
4
|
+
安全约束:稿件文件名在流水线内再次消毒(basename + 去分隔符),且每次
|
|
5
|
+
open 前都用 commonpath 校验路径必须落在任务工作目录内,防止目录穿越。
|
|
6
|
+
|
|
7
|
+
智能模式(mode=auto):
|
|
8
|
+
1. 规划器把目标拆成有序子任务(LLM 计划,失败退化为单步模板);
|
|
9
|
+
2. 路由器按能力基线 × 历史胜率选实现者/评审者(跨厂商评审约束);
|
|
10
|
+
3. 验证/评审不通过 → 自动把问题清单发回实现者修复(至多 router.MAX_REPAIR_ROUNDS 轮);
|
|
11
|
+
4. 仍不通过 → 自动换将重实现一次;
|
|
12
|
+
5. 全程记录"为什么选它"与每轮修复结果。
|
|
13
|
+
手动模式(mode=manual):用户显式指定实现者/评审组,行为同 v1。
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
from . import catalog, history, jobs, manager, modelhub, mocks, planner, registry, router, runner, skills, store, usage
|
|
23
|
+
from . import builtin_agent
|
|
24
|
+
from . import diagnostics
|
|
25
|
+
from . import paths as paths_mod
|
|
26
|
+
from . import session_log as session_log_mod
|
|
27
|
+
from . import step_runner as step_runner_mod
|
|
28
|
+
from .repeat_guard import guard as repeat_guard
|
|
29
|
+
|
|
30
|
+
DEFAULT_RUBRIC = ["情节", "人物", "文笔", "节奏", "吸引力"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Cancelled(Exception):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _now():
|
|
38
|
+
return time.strftime("%Y-%m-%d %H:%M:%S")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _check_cancel(ev):
|
|
42
|
+
if ev is not None and ev.is_set():
|
|
43
|
+
raise Cancelled()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _inside(dirpath, target):
|
|
47
|
+
try:
|
|
48
|
+
return os.path.commonpath(
|
|
49
|
+
[os.path.abspath(dirpath), os.path.abspath(target)]) == os.path.abspath(dirpath)
|
|
50
|
+
except ValueError:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _task_images(task, workdir):
|
|
55
|
+
"""任务的图片附件绝对路径(仅 codex 原生 -i 用)。无附件/异常返回空列表。"""
|
|
56
|
+
try:
|
|
57
|
+
from . import attachments as att_mod
|
|
58
|
+
return att_mod.image_paths(task, workdir)
|
|
59
|
+
except Exception:
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _ms_name(raw):
|
|
64
|
+
name = re.sub(r"[\\/\x00]+", "_", str(raw or "")).strip()
|
|
65
|
+
name = re.sub(r"\.{2,}", "_", name).lstrip(".")
|
|
66
|
+
return os.path.basename(name) or "manuscript.md"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _ms_io(workdir, raw_name, mode):
|
|
70
|
+
"""打开稿件文件;open 紧邻边界校验,路径越界直接拒绝。"""
|
|
71
|
+
name = _ms_name(raw_name)
|
|
72
|
+
p = os.path.abspath(os.path.join(workdir, name))
|
|
73
|
+
if not _inside(workdir, p):
|
|
74
|
+
raise ValueError("稿件路径越界,已拒绝: %r" % raw_name)
|
|
75
|
+
return open(p, mode, encoding="utf-8", errors="replace")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _agents():
|
|
79
|
+
return registry.effective_agents(catalog.load(), manager.detect_all())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _pick(agents, agent_id):
|
|
83
|
+
for a in agents:
|
|
84
|
+
if a["id"] == agent_id:
|
|
85
|
+
return a
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _real(agents):
|
|
90
|
+
return [a for a in agents if a.get("mode") == "real"]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _pick_implementer(agents, wanted):
|
|
94
|
+
real = _real(agents)
|
|
95
|
+
if wanted:
|
|
96
|
+
a = _pick(agents, wanted)
|
|
97
|
+
if a:
|
|
98
|
+
return a, ""
|
|
99
|
+
if real:
|
|
100
|
+
return real[0], ""
|
|
101
|
+
return (agents[0], "") if agents else (None, "")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _pick_critics_manual(agents, task):
|
|
105
|
+
wanted = task.get("critics") or []
|
|
106
|
+
if wanted:
|
|
107
|
+
picked = [a for a in agents if a["id"] in wanted]
|
|
108
|
+
if picked:
|
|
109
|
+
return picked
|
|
110
|
+
real = _real(agents)
|
|
111
|
+
if real:
|
|
112
|
+
return real
|
|
113
|
+
return [a for a in agents if a.get("mode") == "mock"] or agents[:2]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _valid_resume(task, agents):
|
|
117
|
+
"""校验任务上的 resume 声明;返回 {agent, session, project, note} 或 None。"""
|
|
118
|
+
r = task.get("resume") or {}
|
|
119
|
+
if not (r.get("agent") and r.get("session")):
|
|
120
|
+
return None
|
|
121
|
+
a = _pick(agents, r.get("agent"))
|
|
122
|
+
if a is None or a.get("mode") != "real":
|
|
123
|
+
return None
|
|
124
|
+
return {"agent": a, "session": r["session"],
|
|
125
|
+
"project": (r.get("project") or "").strip(),
|
|
126
|
+
"note": "沿用已有会话 %s…(保留其上下文继续工作)" % str(r["session"])[:8]}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _resume_workdir(resume_ctx, fallback):
|
|
130
|
+
"""续会话的工作目录:CLI 必须在会话所属项目目录下启动才能定位到会话
|
|
131
|
+
(opencode/qwen 实测按 cwd 查找,否则报找不到或直接挂起)。
|
|
132
|
+
目录已不存在时退回任务工作目录。"""
|
|
133
|
+
if not resume_ctx:
|
|
134
|
+
return fallback
|
|
135
|
+
proj = resume_ctx.get("project") or ""
|
|
136
|
+
if proj and os.path.isdir(proj):
|
|
137
|
+
return proj
|
|
138
|
+
return fallback
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _compaction_enabled():
|
|
142
|
+
"""Phase 2 灰度开关:环境变量 TUTTI_COMPACTION=1 启用上下文压缩(默认关)。"""
|
|
143
|
+
return os.environ.get("TUTTI_COMPACTION") == "1"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
_sessions_cache = {}
|
|
147
|
+
_sessions_lock = threading.Lock()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _get_session(run_id):
|
|
151
|
+
"""每 run 一个 surface 会话日志(data/runs/<id>/session.jsonl)。"""
|
|
152
|
+
with _sessions_lock:
|
|
153
|
+
s = _sessions_cache.get(run_id)
|
|
154
|
+
if s is None:
|
|
155
|
+
sdir = paths_mod.RUNS_DIR / run_id
|
|
156
|
+
sdir.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
s = session_log_mod.Session(run_id, store_path=str(sdir / "session.jsonl"))
|
|
158
|
+
_sessions_cache[run_id] = s
|
|
159
|
+
return s
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _make_llm_caller(agent, workdir):
|
|
163
|
+
"""压缩摘要用 LLM:直接复用当前 step 的 agent(同 CLI 同模型)。"""
|
|
164
|
+
def caller(messages):
|
|
165
|
+
prompt = "\n\n".join(m.get("content", "") for m in messages)
|
|
166
|
+
res = runner.run_agent(agent, prompt, workdir=workdir, readonly=True,
|
|
167
|
+
timeout=300)
|
|
168
|
+
return res.get("text") or ""
|
|
169
|
+
return caller
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _resume_sid(agent, sid):
|
|
173
|
+
"""§07 T1.1:该 agent 是否可用会话 id 续会话;不可用返回 None(退回全新调用)。
|
|
174
|
+
|
|
175
|
+
codex/claude/opencode/qwen 原生支持 resume;generic 需 catalog 配了
|
|
176
|
+
resume_argv_template;mock/其余一律 None。避免 run_agent 对 generic 的
|
|
177
|
+
「未配置会话恢复」硬失败把修订流程打断。
|
|
178
|
+
"""
|
|
179
|
+
sid = (sid or "").strip()
|
|
180
|
+
if not sid or agent.get("mode") == "mock":
|
|
181
|
+
return None
|
|
182
|
+
kind = agent.get("kind", "generic")
|
|
183
|
+
if kind in ("codex", "claude", "opencode", "qwen"):
|
|
184
|
+
return sid
|
|
185
|
+
if kind == "generic" and agent.get("resume_argv_template"):
|
|
186
|
+
return sid
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _is_review_role(role):
|
|
191
|
+
"""评审类步骤:用户指令在此类步骤注入时升级为「评分依据」,不再是普通纠偏。"""
|
|
192
|
+
r = str(role or "")
|
|
193
|
+
return "critique" in r or r == "review"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _drain_directives(run_id, workdir, role=None, step_n=None):
|
|
197
|
+
"""取出运行中积压的用户指令(store.drain_messages),拼成注入块 + 收集图片附件。
|
|
198
|
+
|
|
199
|
+
无头 CLI 没有交互 stdin,插不进正在跑的进程——指令在下一个步骤开始前
|
|
200
|
+
生效(轮间干预),所以 drain 放在 _run_step 的真实调用分支。
|
|
201
|
+
role/step_n 仅作送达回执(consumed_by);评审类步骤额外追加「评分依据」
|
|
202
|
+
框架文案,把用户意见变成评审判定的正式输入(插话进评审门)。
|
|
203
|
+
返回 (注入文本块 或 "", 图片绝对路径列表);消费即标记,不会重复注入。
|
|
204
|
+
"""
|
|
205
|
+
try:
|
|
206
|
+
msgs = store.drain_messages(run_id, consumed_by={"step": step_n, "role": role})
|
|
207
|
+
except Exception:
|
|
208
|
+
return "", []
|
|
209
|
+
if not msgs:
|
|
210
|
+
return "", []
|
|
211
|
+
lines = ["## 用户实时指令(运行中追加,针对当前进展的纠偏,优先级高于原始要求)"]
|
|
212
|
+
if _is_review_role(role):
|
|
213
|
+
lines.append("本步为评审步骤:请把上述用户意见作为评分依据之一,"
|
|
214
|
+
"在相应维度的分数与 issues 中明确体现(引用用户原话)。")
|
|
215
|
+
imgs = []
|
|
216
|
+
for m in msgs:
|
|
217
|
+
stamp = m.get("created_at") or ""
|
|
218
|
+
sender = m.get("sender") or "用户"
|
|
219
|
+
text = (m.get("text") or "").strip()
|
|
220
|
+
lines.append("- [%s %s] %s" % (stamp, sender, text) if text
|
|
221
|
+
else "- [%s %s](附件指令,见下方文件)" % (stamp, sender))
|
|
222
|
+
for rel in (m.get("attachments") or []):
|
|
223
|
+
rel = str(rel)
|
|
224
|
+
low = rel.lower()
|
|
225
|
+
if low.endswith((".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")):
|
|
226
|
+
ap = os.path.join(workdir or "", rel) if workdir else rel
|
|
227
|
+
if workdir and os.path.isfile(ap):
|
|
228
|
+
imgs.append(ap)
|
|
229
|
+
lines.append(" · 图片附件:%s(请查看图片内容)" % rel)
|
|
230
|
+
else:
|
|
231
|
+
lines.append(" · 图片附件:%s" % rel)
|
|
232
|
+
else:
|
|
233
|
+
lines.append(" · 文件附件:%s(位于工作目录,可直接读取)" % rel)
|
|
234
|
+
return "\n".join(lines), imgs
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _steered_task(run_id, task):
|
|
238
|
+
"""规划/大纲步骤的输入任务副本:未消费的用户指令合入 context(peek 不消费)。
|
|
239
|
+
|
|
240
|
+
编排者决策(code plan / 连载大纲)不走 _run_step,消息只在 context 里可见;
|
|
241
|
+
peek 语义保证后续真实步骤仍会 drain 注入——规划者和执行者都看到,双保险。
|
|
242
|
+
"""
|
|
243
|
+
try:
|
|
244
|
+
msgs = store.peek_messages(run_id)
|
|
245
|
+
except Exception:
|
|
246
|
+
return task
|
|
247
|
+
if not msgs:
|
|
248
|
+
return task
|
|
249
|
+
lines = ["## 用户实时指令(运行中追加,规划时必须纳入考量)"]
|
|
250
|
+
for m in msgs:
|
|
251
|
+
text = (m.get("text") or "").strip()
|
|
252
|
+
if text:
|
|
253
|
+
lines.append("- [%s %s] %s" % (m.get("created_at") or "",
|
|
254
|
+
m.get("sender") or "用户", text))
|
|
255
|
+
for rel in (m.get("attachments") or []):
|
|
256
|
+
lines.append(" · 附件:%s(位于工作目录,可直接读取)" % rel)
|
|
257
|
+
t2 = dict(task)
|
|
258
|
+
t2["context"] = ((task.get("context") or "") + "\n\n" + "\n".join(lines)).strip()
|
|
259
|
+
return t2
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _wait_gate(run_id, ev):
|
|
263
|
+
"""暂停闸门:run.paused 标志位挂在下一个步骤开始前,放行或取消才继续。
|
|
264
|
+
|
|
265
|
+
轮询 1s(本地内存读,开销可忽略);取消事件优先——用户点「取消运行」
|
|
266
|
+
不必先解除暂停。终止态(服务重启恢复/外部取消)同样放行,防卡死。"""
|
|
267
|
+
while True:
|
|
268
|
+
run = store.get_run(run_id) or {}
|
|
269
|
+
if not run.get("paused") or run.get("status") not in ("queued", "running"):
|
|
270
|
+
return
|
|
271
|
+
if ev is not None and ev.is_set():
|
|
272
|
+
return
|
|
273
|
+
time.sleep(1.0)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner.DEFAULT_TIMEOUT, note="", resume=None, images=None, require_tools=False):
|
|
277
|
+
"""执行一个智能体步骤并记录。返回 runner 统一结果。"""
|
|
278
|
+
_wait_gate(run_id, ev)
|
|
279
|
+
# 绑定解析为空 → CLI 将回落本机默认配置(用户配置的模型/供应商全部不生效)。
|
|
280
|
+
# 2026-09-16 实测:这种状态下烧干配额的本机默认供应商被静默使用,用户以为
|
|
281
|
+
# 在用自己配的模型。首次出现时在步骤备注里醒目标出。
|
|
282
|
+
if agent.get("mode") == "real" and not (agent.get("call_chain") or agent.get("env")):
|
|
283
|
+
note = ((note + ";") if note else "") + \
|
|
284
|
+
"⚠ 未解析到绑定链,本步回落 CLI 本机默认配置(请在模型接入页检查该 CLI 的供应商绑定)"
|
|
285
|
+
step, log_abs = store.add_step(run_id, role, agent["id"],
|
|
286
|
+
agent.get("label", agent["id"]), note=note)
|
|
287
|
+
start = time.time()
|
|
288
|
+
if agent.get("mode") == "mock":
|
|
289
|
+
time.sleep(0.3)
|
|
290
|
+
res = {"ok": True, "text": "[mock] %s" % prompt[:80], "json": None,
|
|
291
|
+
"cost_usd": 0.0, "tokens": 0, "error": "", "raw": {"exit_code": 0}}
|
|
292
|
+
if log_abs:
|
|
293
|
+
try:
|
|
294
|
+
log_abs.write_text("[mock 智能体] 跳过真实调用\n", encoding="utf-8")
|
|
295
|
+
except Exception:
|
|
296
|
+
pass
|
|
297
|
+
else:
|
|
298
|
+
# 5C:重复调用守门——指纹取原始 prompt(提醒注入 spawn 副本,不污染计数链)
|
|
299
|
+
guard = repeat_guard.check(run_id, role, prompt)
|
|
300
|
+
if guard["should_stop"]:
|
|
301
|
+
from .error_codes import ErrorCode
|
|
302
|
+
res = {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
|
|
303
|
+
"tokens": 0, "usage": None, "error": guard["reminder"],
|
|
304
|
+
"error_code": ErrorCode.ENV_BLOCK,
|
|
305
|
+
"raw": {"exit_code": None}, "kind": agent.get("kind", "generic"),
|
|
306
|
+
"model": agent.get("model")}
|
|
307
|
+
_finish_step_result(run_id, step, res, role, agent, start)
|
|
308
|
+
return res
|
|
309
|
+
# 运行中指挥:drain 用户追加的指令/附件,注入本步(守门拦截时不 drain,
|
|
310
|
+
# 消息留给下一个真实步骤,不空耗);role/step_n 作送达回执
|
|
311
|
+
directive_block, directive_imgs = _drain_directives(run_id, workdir,
|
|
312
|
+
role=role, step_n=step["n"])
|
|
313
|
+
if directive_imgs:
|
|
314
|
+
images = list(images or []) + directive_imgs
|
|
315
|
+
if directive_block:
|
|
316
|
+
prompt = directive_block + "\n\n---\n\n" + prompt
|
|
317
|
+
effective_prompt = (guard["reminder"] + "\n\n---\n\n" + prompt) if guard["reminder"] else prompt
|
|
318
|
+
res = _spawn_step(session_run_id=run_id, role=role, agent=agent,
|
|
319
|
+
prompt=effective_prompt, workdir=workdir, readonly=readonly,
|
|
320
|
+
ev=ev, timeout=timeout, resume=resume, step=step,
|
|
321
|
+
log_abs=log_abs, images=images, require_tools=require_tools)
|
|
322
|
+
# 先收尾再查取消:取消时进程已被 run_process 杀停,若先抛 Cancelled,
|
|
323
|
+
# 步骤记录会永远停在「运行中」变僵尸(与 _run_verify 的顺序对齐)
|
|
324
|
+
_finish_step_result(run_id, step, res, role, agent, start)
|
|
325
|
+
_check_cancel(ev)
|
|
326
|
+
return res
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=None):
|
|
330
|
+
"""内置智能体步骤:直连模型 API + 工具循环(builtin_agent),不经 CLI 进程。
|
|
331
|
+
|
|
332
|
+
与 _run_step 对齐的三件事:暂停/取消闸门、运行中指令 drain 注入、重复调用
|
|
333
|
+
守门;结果同样经 _finish_step_result 落步骤(output=干净回答)并入用量台账。
|
|
334
|
+
日志只有「迭代/工具」摘要行——对话视图吃 output,日志抽屉看工具轨迹。"""
|
|
335
|
+
_wait_gate(run_id, ev)
|
|
336
|
+
step, log_abs = store.add_step(run_id, role, "builtin", "CodeBee", note=note)
|
|
337
|
+
start = time.time()
|
|
338
|
+
agent_pseudo = {"id": "builtin", "label": "CodeBee", "kind": "builtin", "mode": "real",
|
|
339
|
+
"provider": {"id": bi.get("provider_id") or "",
|
|
340
|
+
"name": bi.get("provider_name") or ""}}
|
|
341
|
+
guard = repeat_guard.check(run_id, role, prompt)
|
|
342
|
+
if guard["should_stop"]:
|
|
343
|
+
from .error_codes import ErrorCode
|
|
344
|
+
res = {"ok": False, "text": "", "usage": None, "cost_usd": 0.0, "tokens": 0,
|
|
345
|
+
"error": guard["reminder"], "error_code": ErrorCode.ENV_BLOCK,
|
|
346
|
+
"raw": {"exit_code": None}, "model": bi.get("model")}
|
|
347
|
+
_finish_step_result(run_id, step, res, role, agent_pseudo, start)
|
|
348
|
+
return res
|
|
349
|
+
# 运行中指挥:drain 用户追加的指令/附件,注入本轮(与 _run_step 同语义)
|
|
350
|
+
directive_block, directive_imgs = _drain_directives(run_id, workdir, role=role, step_n=step["n"])
|
|
351
|
+
if directive_imgs:
|
|
352
|
+
images = list(images or []) + directive_imgs
|
|
353
|
+
if directive_block:
|
|
354
|
+
prompt = directive_block + "\n\n---\n\n" + prompt
|
|
355
|
+
if guard["reminder"]:
|
|
356
|
+
prompt = guard["reminder"] + "\n\n---\n\n" + prompt
|
|
357
|
+
lines = ["CodeBee(%s · %s)" % (bi.get("provider_name"), bi.get("model"))]
|
|
358
|
+
|
|
359
|
+
def _log(line):
|
|
360
|
+
lines.append(str(line))
|
|
361
|
+
|
|
362
|
+
res = builtin_agent.run(bi, prompt, workdir, cancel_event=ev, log=_log, images=images)
|
|
363
|
+
if log_abs:
|
|
364
|
+
try:
|
|
365
|
+
log_abs.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
366
|
+
except Exception:
|
|
367
|
+
pass
|
|
368
|
+
usage = res.get("usage") or {}
|
|
369
|
+
res.setdefault("raw", {})
|
|
370
|
+
res["raw"]["exit_code"] = 0 if res.get("ok") else 1
|
|
371
|
+
res["raw"]["duration"] = time.time() - start
|
|
372
|
+
res["tokens"] = int(usage.get("total") or 0)
|
|
373
|
+
res.setdefault("cost_usd", 0.0)
|
|
374
|
+
# 先收尾再查取消:与 _run_step 同序,防步骤记录停在「运行中」变僵尸
|
|
375
|
+
_finish_step_result(run_id, step, res, role, agent_pseudo, start)
|
|
376
|
+
_check_cancel(ev)
|
|
377
|
+
return res
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _budget_max_tokens():
|
|
381
|
+
"""§07 T2.1:单次 run 的 token 预算上限;0/未配置 = 不限。
|
|
382
|
+
|
|
383
|
+
环境变量 TUTTI_BUDGET_MAX_TOKENS 优先(运维场景:不动配置文件直接钳住失控 run)。
|
|
384
|
+
"""
|
|
385
|
+
try:
|
|
386
|
+
env_val = os.environ.get("TUTTI_BUDGET_MAX_TOKENS")
|
|
387
|
+
if env_val:
|
|
388
|
+
return max(0, int(env_val))
|
|
389
|
+
except Exception:
|
|
390
|
+
pass
|
|
391
|
+
try:
|
|
392
|
+
from .settings_schema import get as ss_get, register_default_namespaces
|
|
393
|
+
register_default_namespaces()
|
|
394
|
+
return int(ss_get("budget", "max_tokens_per_run") or 0)
|
|
395
|
+
except Exception:
|
|
396
|
+
return 0
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
|
|
400
|
+
timeout, resume, step, log_abs, images=None, require_tools=False):
|
|
401
|
+
"""真实 CLI 调用:压缩灰度路径或原路径。"""
|
|
402
|
+
# T2.1 预算闸:已用 token 达到单次 run 上限 → 阻断后续真实调用(ENV_BLOCK)。
|
|
403
|
+
# 只拦「下一步」,允许越过线的当前步完成;auto 续跑可在用户调高预算后接手。
|
|
404
|
+
cap = _budget_max_tokens()
|
|
405
|
+
if cap > 0:
|
|
406
|
+
try:
|
|
407
|
+
from .token_meter import token_meter
|
|
408
|
+
used = token_meter.used(session_run_id)
|
|
409
|
+
except Exception:
|
|
410
|
+
used = 0
|
|
411
|
+
if used >= cap:
|
|
412
|
+
from .error_codes import ErrorCode
|
|
413
|
+
log_path_warn = "已超出单次运行 token 预算:%d/%d(可在设置 budget.max_tokens_per_run 调整),停止后续步骤" % (used, cap)
|
|
414
|
+
return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
|
|
415
|
+
"tokens": 0, "usage": None, "error": log_path_warn,
|
|
416
|
+
"error_code": ErrorCode.ENV_BLOCK, "sid": "",
|
|
417
|
+
"raw": {"exit_code": None}, "kind": agent.get("kind", "generic"),
|
|
418
|
+
"model": agent.get("model")}
|
|
419
|
+
if _compaction_enabled() and not resume:
|
|
420
|
+
# Phase 2(1D):撑爆 → 压缩 → 守门重试;同时把 usage 累进 token_meter(1C)
|
|
421
|
+
session = _get_session(session_run_id)
|
|
422
|
+
llm_caller = _make_llm_caller(agent, workdir)
|
|
423
|
+
call_kwargs = dict(workdir=workdir, readonly=readonly,
|
|
424
|
+
timeout=timeout, cancel_event=ev, log_path=str(log_abs),
|
|
425
|
+
images=images, require_tools=require_tools)
|
|
426
|
+
|
|
427
|
+
def _call(p, **kw):
|
|
428
|
+
# 模型可见即已记录(§1A 不变量):入参/出参先落 session 日志
|
|
429
|
+
session.append("user_message", {"content": p, "role": role},
|
|
430
|
+
turn_id=str(step["n"]))
|
|
431
|
+
r = runner.run_agent(agent, p, **{**call_kwargs, **kw})
|
|
432
|
+
session.append("assistant_message",
|
|
433
|
+
{"content": (r.get("text") or r.get("error") or ""),
|
|
434
|
+
"ok": r.get("ok"), "model": r.get("model")},
|
|
435
|
+
turn_id=str(step["n"]))
|
|
436
|
+
try:
|
|
437
|
+
from .token_meter import token_meter
|
|
438
|
+
token_meter.accumulate(session_run_id, r.get("usage"),
|
|
439
|
+
model=r.get("model") or "")
|
|
440
|
+
except Exception:
|
|
441
|
+
pass
|
|
442
|
+
return r
|
|
443
|
+
|
|
444
|
+
res, _retried = step_runner_mod.execute_step(
|
|
445
|
+
session, _call, prompt, model=agent.get("model") or "",
|
|
446
|
+
llm_caller=llm_caller)
|
|
447
|
+
else:
|
|
448
|
+
res = runner.run_agent(agent, prompt, workdir=workdir, readonly=readonly,
|
|
449
|
+
timeout=timeout, cancel_event=ev, log_path=str(log_abs),
|
|
450
|
+
resume=resume, images=images, require_tools=require_tools)
|
|
451
|
+
return res
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _finish_step_result(run_id, step, res, role, agent, start):
|
|
455
|
+
"""step 收尾:记录 + 用量 + 运行时断言(5F)。"""
|
|
456
|
+
# 被取消杀停的步骤如实记「已取消」;超时被杀记「超时」——都只是步骤
|
|
457
|
+
# 显示层的细分,run 级仍是 failed,善后路径(重试/自动续跑)不变
|
|
458
|
+
raw = res.get("raw") or {}
|
|
459
|
+
if raw.get("cancelled"):
|
|
460
|
+
status = "cancelled"
|
|
461
|
+
elif raw.get("timed_out"):
|
|
462
|
+
status = "timeout"
|
|
463
|
+
else:
|
|
464
|
+
status = "done" if res["ok"] else "failed"
|
|
465
|
+
store.finish_step(run_id, step["n"],
|
|
466
|
+
status,
|
|
467
|
+
summary=((res.get("text") or res.get("error") or "")[:600]),
|
|
468
|
+
exit_code=res.get("raw", {}).get("exit_code"),
|
|
469
|
+
cost_usd=res.get("cost_usd", 0.0),
|
|
470
|
+
tokens=res.get("tokens", 0),
|
|
471
|
+
duration_s=time.time() - start,
|
|
472
|
+
model=res.get("model"),
|
|
473
|
+
# 智能体的最终回答(runner 已从 JSONL 事件流里抽出 agent_message)。
|
|
474
|
+
# 对话视图直读这个;日志文件是全量事件流,塞进气泡就成了「看日志」。
|
|
475
|
+
output=(res.get("text") or ""))
|
|
476
|
+
if agent.get("mode") != "mock":
|
|
477
|
+
_record_usage(run_id, role, agent, res, source="pipeline", step=step["n"])
|
|
478
|
+
# 5F:step 级运行时断言(只告警不阻断)
|
|
479
|
+
try:
|
|
480
|
+
diagnostics.invariants.run_for("step", {
|
|
481
|
+
"run_id": run_id, "role": role, "ok": res.get("ok"),
|
|
482
|
+
"error_code": res.get("error_code") or "",
|
|
483
|
+
"text_len": len(res.get("text") or ""),
|
|
484
|
+
})
|
|
485
|
+
except Exception:
|
|
486
|
+
pass
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _record_usage(run_id, role, agent, res, source="pipeline", step=0):
|
|
490
|
+
"""把一次真实智能体调用记入用量台账;取不到的上下文留空,绝不抛错。
|
|
491
|
+
|
|
492
|
+
step 必须与 store.add_step 的步骤号一致:启动时的历史回填按
|
|
493
|
+
(run_id, role, step) 去重,缺了会把同一步骤重复入账。
|
|
494
|
+
"""
|
|
495
|
+
try:
|
|
496
|
+
run = store.get_run(run_id) or {}
|
|
497
|
+
task = store.get_task(run.get("task_id")) if run.get("task_id") else None
|
|
498
|
+
usage.record(
|
|
499
|
+
source=source, run_id=run_id, step=step,
|
|
500
|
+
task_id=run.get("task_id") or "",
|
|
501
|
+
task_type=(task or {}).get("type", ""),
|
|
502
|
+
role=role, agent=agent.get("id", ""),
|
|
503
|
+
agent_label=agent.get("label", ""),
|
|
504
|
+
tool=agent.get("kind", ""),
|
|
505
|
+
model=res.get("model") or "",
|
|
506
|
+
ok=bool(res.get("ok")),
|
|
507
|
+
duration_s=float(res.get("raw", {}).get("duration") or 0.0),
|
|
508
|
+
cost_usd=float(res.get("cost_usd") or 0.0),
|
|
509
|
+
usage=res.get("usage"))
|
|
510
|
+
# 告警模块:CLI 调用成功/失败上报(provider 名与 usage 台账一致)
|
|
511
|
+
from . import health
|
|
512
|
+
prov = agent.get("provider") or {}
|
|
513
|
+
prov_name = (prov.get("name") if isinstance(prov, dict) else "") or ""
|
|
514
|
+
if prov_name:
|
|
515
|
+
if res.get("ok"):
|
|
516
|
+
health.report_success(prov_name)
|
|
517
|
+
else:
|
|
518
|
+
health.report_failure(prov_name, res.get("error") or "",
|
|
519
|
+
model=res.get("model") or "",
|
|
520
|
+
provider_id=prov.get("id") or "")
|
|
521
|
+
except Exception:
|
|
522
|
+
pass
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# ---------------------------------------------------------------- 提示词
|
|
526
|
+
|
|
527
|
+
CODE_IMPL_PROMPT = """你是一名高级工程师,请在当前工作目录中直接完成以下任务(直接修改/创建文件)。创建/写入的文件一律以 UTF-8 编码保存(PowerShell 显式加 -Encoding UTF8,禁止依赖系统默认编码)。
|
|
528
|
+
|
|
529
|
+
## 总体目标
|
|
530
|
+
__GOAL__
|
|
531
|
+
|
|
532
|
+
## 本步指令
|
|
533
|
+
__SUBTASK__
|
|
534
|
+
|
|
535
|
+
## 背景与上下文
|
|
536
|
+
__CONTEXT__
|
|
537
|
+
|
|
538
|
+
## 要求
|
|
539
|
+
- 修改要最小化、聚焦任务目标;不要无关重构。
|
|
540
|
+
- 完成后只用 3-5 句话总结你改了什么、为什么。
|
|
541
|
+
__VERIFY_HINT__"""
|
|
542
|
+
|
|
543
|
+
CODE_FIX_PROMPT = """你是一名高级工程师。你之前的实现没有通过验收,请在当前工作目录中直接修复(直接修改/创建文件)。创建/写入的文件一律以 UTF-8 编码保存(PowerShell 显式加 -Encoding UTF8,禁止依赖系统默认编码)。
|
|
544
|
+
|
|
545
|
+
## 总体目标
|
|
546
|
+
__GOAL__
|
|
547
|
+
|
|
548
|
+
## 未通过的原因
|
|
549
|
+
__ISSUES__
|
|
550
|
+
|
|
551
|
+
## 要求
|
|
552
|
+
- 只针对上述问题修复;不要无关重构。
|
|
553
|
+
- 完成后用 2-3 句话说明改了什么。
|
|
554
|
+
__VERIFY_HINT__"""
|
|
555
|
+
|
|
556
|
+
CODE_REVIEW_PROMPT = """你是代码评审员(不要修改任何文件)。请只基于下方提供的任务与变更内容进行评审,
|
|
557
|
+
输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
558
|
+
{
|
|
559
|
+
"pass": true/false,
|
|
560
|
+
"scores": {"正确性": 1-10, "可维护性": 1-10, "安全": 1-10},
|
|
561
|
+
"issues": [{"severity": "blocker|major|minor", "title": "...", "detail": "..."}],
|
|
562
|
+
"summary": "一句话结论"
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
## 任务目标
|
|
566
|
+
__GOAL__
|
|
567
|
+
|
|
568
|
+
## 验收命令
|
|
569
|
+
__VERIFY__
|
|
570
|
+
|
|
571
|
+
## 变更内容(git diff,若为空表示无法获取)
|
|
572
|
+
__DIFF__
|
|
573
|
+
"""
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _git_diff(workdir):
|
|
577
|
+
"""评审用的变更集:git diff HEAD 之外还拼上未跟踪新文件——
|
|
578
|
+
新文件不进 git diff,但恰是智能体产物的大头(新章节/新模块),
|
|
579
|
+
缺了评审官等于半盲评。只读,不动 index。"""
|
|
580
|
+
from . import gitmod
|
|
581
|
+
return gitmod.collect_changes(workdir)["diff"]
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _verify_hint(task):
|
|
585
|
+
if task.get("verify_command"):
|
|
586
|
+
return "- 完成后请自查:`%s` 应当通过。" % task["verify_command"]
|
|
587
|
+
return ""
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _run_verify(run_id, task, workdir, ev):
|
|
591
|
+
"""确定性验证。返回 (verify_pass, ran)。"""
|
|
592
|
+
if not task.get("verify_command"):
|
|
593
|
+
return True, False
|
|
594
|
+
step, log_abs = store.add_step(run_id, "verify", "builtin", "内置验证器")
|
|
595
|
+
start = time.time()
|
|
596
|
+
r = runner.run_process(shell_cmd=task["verify_command"], cwd=workdir,
|
|
597
|
+
timeout=600, cancel_event=ev, log_path=str(log_abs))
|
|
598
|
+
ok = r["ok"]
|
|
599
|
+
# 与 _finish_step_result 同一套显示层细分:超时/取消杀停不再冒充「失败」
|
|
600
|
+
if r.get("cancelled"):
|
|
601
|
+
v_status, v_sum = "cancelled", "验证被取消终止"
|
|
602
|
+
elif r.get("timed_out"):
|
|
603
|
+
v_status, v_sum = "timeout", "验证超时被终止"
|
|
604
|
+
else:
|
|
605
|
+
v_status = "done" if ok else "failed"
|
|
606
|
+
v_sum = "验证通过" if ok else "验证失败(exit %s)" % r["exit_code"]
|
|
607
|
+
store.finish_step(run_id, step["n"], v_status,
|
|
608
|
+
summary=v_sum,
|
|
609
|
+
exit_code=r["exit_code"], duration_s=time.time() - start)
|
|
610
|
+
_check_cancel(ev)
|
|
611
|
+
return ok, True
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _run_review(run_id, task, workdir, reviewer, ev):
|
|
615
|
+
diff = _git_diff(workdir)
|
|
616
|
+
prompt = (CODE_REVIEW_PROMPT
|
|
617
|
+
.replace("__GOAL__", task["goal"])
|
|
618
|
+
.replace("__VERIFY__", task.get("verify_command") or "(未配置)")
|
|
619
|
+
.replace("__DIFF__", diff or "(无法获取 git diff,请综合任务目标谨慎评审)"))
|
|
620
|
+
res = _run_step(run_id, "review", reviewer, prompt, workdir, readonly=True, ev=ev,
|
|
621
|
+
images=_task_images(task, workdir))
|
|
622
|
+
if reviewer.get("mode") == "mock":
|
|
623
|
+
return mocks.review(task, True)
|
|
624
|
+
parsed = runner.extract_json(res.get("text") or "")
|
|
625
|
+
if not isinstance(parsed, dict):
|
|
626
|
+
return {"pass": False, "scores": {}, "issues": [],
|
|
627
|
+
"summary": "评审输出无法解析为 JSON:%s" % (res.get("text") or "")[:200]}
|
|
628
|
+
return parsed
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _format_issues(review_json, verify_pass, verify_failed_note):
|
|
632
|
+
lines = []
|
|
633
|
+
if not verify_pass and verify_failed_note:
|
|
634
|
+
lines.append("- 验证命令未通过(%s)" % verify_failed_note)
|
|
635
|
+
for it in (review_json.get("issues") or [])[:10]:
|
|
636
|
+
lines.append("- [%s] %s:%s" % (it.get("severity", "?"),
|
|
637
|
+
it.get("title", ""), str(it.get("detail", ""))[:300]))
|
|
638
|
+
return "\n".join(lines) or "(评审未给出具体问题,请自查实现质量与验收命令)"
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
# ---------------------------------------------------------------- 代码流水线
|
|
642
|
+
|
|
643
|
+
def _run_code(run, task, agents, ev, stats, mode):
|
|
644
|
+
run_id = run["id"]
|
|
645
|
+
workdir = task["workdir"]
|
|
646
|
+
repairs = [] # 每轮 {round, kind, verify_pass, review_pass, issues}
|
|
647
|
+
impl_sid = [""] # §07 T1.1:最后一次实现的 CLI 会话 id(fix 轮复用)
|
|
648
|
+
route = {}
|
|
649
|
+
switched = False
|
|
650
|
+
resume_ctx = _valid_resume(task, agents)
|
|
651
|
+
|
|
652
|
+
# ---- 难度(影响模型选择):用户指定 > 启发式 > 规划器判定
|
|
653
|
+
difficulty = task.get("difficulty") or "auto"
|
|
654
|
+
explicit = difficulty in ("easy", "hard")
|
|
655
|
+
if not explicit:
|
|
656
|
+
difficulty = modelhub.classify_difficulty(task["goal"], task.get("verify_command"))
|
|
657
|
+
|
|
658
|
+
# ---- 路由
|
|
659
|
+
if resume_ctx is not None:
|
|
660
|
+
impl = resume_ctx["agent"]
|
|
661
|
+
route["implementer"] = resume_ctx["note"]
|
|
662
|
+
elif mode == "manual":
|
|
663
|
+
impl, _ = _pick_implementer(agents, task.get("implementer"))
|
|
664
|
+
else:
|
|
665
|
+
impl, route["implementer"] = router.pick(agents, "implement", "code", stats)
|
|
666
|
+
if impl is None:
|
|
667
|
+
store.update_run(run_id, status="failed", error="没有可用智能体", ended_at=_now())
|
|
668
|
+
return
|
|
669
|
+
|
|
670
|
+
# ---- 规划
|
|
671
|
+
if mode == "auto":
|
|
672
|
+
_wait_gate(run_id, ev)
|
|
673
|
+
plan_step, plan_log = store.add_step(run_id, "plan", impl["id"], impl.get("label"),
|
|
674
|
+
note=route.get("implementer", ""))
|
|
675
|
+
plan = planner.make_code_plan(_steered_task(run_id, task),
|
|
676
|
+
modelhub.bind_agent(impl, difficulty),
|
|
677
|
+
_resume_workdir(resume_ctx, workdir), ev,
|
|
678
|
+
resume=resume_ctx["session"] if resume_ctx else None,
|
|
679
|
+
log_path=str(plan_log) if plan_log else None)
|
|
680
|
+
# 规划器判定优先于启发式(仅当用户未显式指定难度)
|
|
681
|
+
if not explicit and plan.get("difficulty") in ("easy", "hard"):
|
|
682
|
+
difficulty = plan["difficulty"]
|
|
683
|
+
store.finish_step(run_id, plan_step["n"], "done",
|
|
684
|
+
summary="计划来源 %s(难度 %s):%s" % (
|
|
685
|
+
plan["source"], difficulty,
|
|
686
|
+
";".join(s["title"] for s in plan["steps"])[:160]),
|
|
687
|
+
duration_s=0.1 if impl.get("mode") == "mock" else None)
|
|
688
|
+
else:
|
|
689
|
+
plan = {"source": "manual", "steps": [{"title": "实现任务", "detail": task["goal"]}]}
|
|
690
|
+
store.update_run(run_id, plan=plan, difficulty=difficulty)
|
|
691
|
+
subtasks = plan["steps"]
|
|
692
|
+
|
|
693
|
+
# ---- 评审者(有会话延续时评审者仍用新鲜上下文,避免偏见)
|
|
694
|
+
if mode == "auto":
|
|
695
|
+
reviewer, route["reviewer"] = router.pick_reviewer(agents, impl, "code", stats)
|
|
696
|
+
else:
|
|
697
|
+
reviewer, route["reviewer"] = _pick_reviewer_legacy(agents, impl)
|
|
698
|
+
store.update_run(run_id, route=route)
|
|
699
|
+
|
|
700
|
+
def implement_all(impl_agent, prefix_note):
|
|
701
|
+
# 会话延续只对原实现者有效;换将后新智能体没有该会话,必须丢弃
|
|
702
|
+
use_resume = (resume_ctx["session"]
|
|
703
|
+
if (resume_ctx and impl_agent["id"] == resume_ctx["agent"]["id"]) else None)
|
|
704
|
+
# 续会话时 CLI 要在会话所属项目目录下启动,否则定位不到会话
|
|
705
|
+
step_wd = _resume_workdir(resume_ctx, workdir) if use_resume else workdir
|
|
706
|
+
|
|
707
|
+
def _run_one(agt):
|
|
708
|
+
"""用指定智能体跑全部子任务;返回 (ok, 最后一次 res)。"""
|
|
709
|
+
agt_b = modelhub.bind_agent(agt, difficulty)
|
|
710
|
+
att_imgs = _task_images(task, workdir) # 图片附件供 codex 原生 -i 直读
|
|
711
|
+
# §07 T3.1 FrugalGPT 级联(默认关):easy 任务把链按 tier 升序重排,
|
|
712
|
+
# 便宜模型先跑;质量闸门不过走既有 repair/换将轮,等效"贵模型兜底"。
|
|
713
|
+
if difficulty == "easy":
|
|
714
|
+
try:
|
|
715
|
+
from .settings_schema import get as ss_get, register_default_namespaces
|
|
716
|
+
register_default_namespaces()
|
|
717
|
+
if ss_get("cascade", "enabled"):
|
|
718
|
+
from . import capability
|
|
719
|
+
agt_b = capability.cascade_reorder(
|
|
720
|
+
agt_b, capability.make_tier_lookup(modelhub.providers()))
|
|
721
|
+
except Exception:
|
|
722
|
+
pass
|
|
723
|
+
for i, sub in enumerate(subtasks):
|
|
724
|
+
prompt = (CODE_IMPL_PROMPT
|
|
725
|
+
.replace("__GOAL__", task["goal"])
|
|
726
|
+
.replace("__SUBTASK__",
|
|
727
|
+
sub["detail"] if sub["detail"] else sub["title"])
|
|
728
|
+
.replace("__CONTEXT__", task.get("context") or "(无)")
|
|
729
|
+
.replace("__VERIFY_HINT__", _verify_hint(task)))
|
|
730
|
+
role = "implement" if len(subtasks) == 1 else "implement-%d/%d" % (i + 1, len(subtasks))
|
|
731
|
+
res = _run_step(run_id, role, agt_b, prompt, step_wd,
|
|
732
|
+
readonly=False, ev=ev,
|
|
733
|
+
note=prefix_note if i == 0 else "",
|
|
734
|
+
resume=use_resume, images=att_imgs,
|
|
735
|
+
require_tools=True)
|
|
736
|
+
# §07 T1.1:记录最后一次实现的会话 id,fix 轮复用(会话内前缀走缓存读计价)
|
|
737
|
+
new_sid = _resume_sid(agt_b, res.get("sid"))
|
|
738
|
+
if new_sid:
|
|
739
|
+
impl_sid[0] = new_sid
|
|
740
|
+
if agt.get("mode") == "mock" and res["ok"]:
|
|
741
|
+
try:
|
|
742
|
+
mock_path = os.path.abspath(os.path.join(workdir, "mock-impl.txt"))
|
|
743
|
+
if _inside(workdir, mock_path):
|
|
744
|
+
with open(mock_path, "a", encoding="utf-8") as f:
|
|
745
|
+
f.write("%s mock 实现:%s / %s\n" % (_now(), task["title"], sub["title"]))
|
|
746
|
+
except Exception:
|
|
747
|
+
pass
|
|
748
|
+
if not res["ok"]:
|
|
749
|
+
return False, res
|
|
750
|
+
return True, res
|
|
751
|
+
|
|
752
|
+
ok, res = _run_one(impl_agent)
|
|
753
|
+
if ok:
|
|
754
|
+
return True
|
|
755
|
+
# 实现步失败不立刻判死:2026-09-16 实测配额烧干时 5 连跑全在同一条 CLI 上
|
|
756
|
+
# 失败收场,而健康的 opencode 一直在旁观望——跨 CLI 换将重试一次
|
|
757
|
+
# (mode=manual 尊重用户指定,不换)。
|
|
758
|
+
if mode == "auto" and impl_agent.get("mode") == "real":
|
|
759
|
+
ex = {impl_agent["id"], "mock-a", "mock-b"}
|
|
760
|
+
other, other_reason = router.pick(agents, "implement", "code", stats,
|
|
761
|
+
exclude=ex)
|
|
762
|
+
if other is not None and other.get("mode") == "real":
|
|
763
|
+
note = "实现步失败自动换将 %s → %s:%s。失败原因:%s" % (
|
|
764
|
+
impl_agent["id"], other["id"], other_reason,
|
|
765
|
+
(res.get("error") or "")[:200])
|
|
766
|
+
ok2, res = _run_one(other)
|
|
767
|
+
if ok2:
|
|
768
|
+
store.update_run(run_id, error="", route_note=note)
|
|
769
|
+
return True
|
|
770
|
+
res_err = "%s;换将后仍失败:%s" % (note, (res.get("error") or "")[:200])
|
|
771
|
+
else:
|
|
772
|
+
res_err = "实现步骤失败(无其他真实 CLI 可换将): %s" % res.get("error")
|
|
773
|
+
else:
|
|
774
|
+
res_err = "实现步骤失败: %s" % res.get("error")
|
|
775
|
+
store.update_run(run_id, status="failed", error=res_err, ended_at=_now())
|
|
776
|
+
return False
|
|
777
|
+
|
|
778
|
+
def review_and_score():
|
|
779
|
+
review_json = _run_review(run_id, task, workdir, modelhub.bind_agent(reviewer, difficulty), ev)
|
|
780
|
+
verify_pass, verify_ran = _run_verify(run_id, task, workdir, ev)
|
|
781
|
+
return review_json, verify_pass, verify_ran
|
|
782
|
+
|
|
783
|
+
attempt_note = route.get("implementer", "") if mode == "auto" else ""
|
|
784
|
+
round_no = 0
|
|
785
|
+
review_json, verify_pass, verify_ran = None, True, False
|
|
786
|
+
while True:
|
|
787
|
+
if round_no == 0:
|
|
788
|
+
if not implement_all(impl, attempt_note):
|
|
789
|
+
return
|
|
790
|
+
elif switched:
|
|
791
|
+
if not implement_all(impl, "换将后全量重实现"):
|
|
792
|
+
return
|
|
793
|
+
else:
|
|
794
|
+
issues_txt = _format_issues(review_json, verify_pass, task.get("verify_command"))
|
|
795
|
+
prompt = (CODE_FIX_PROMPT
|
|
796
|
+
.replace("__GOAL__", task["goal"])
|
|
797
|
+
.replace("__ISSUES__", issues_txt)
|
|
798
|
+
.replace("__VERIFY_HINT__", _verify_hint(task)))
|
|
799
|
+
res = _run_step(run_id, "fix-r%d" % round_no, modelhub.bind_agent(impl, difficulty),
|
|
800
|
+
prompt, workdir, readonly=False, ev=ev,
|
|
801
|
+
note="自动修复第 %d 轮" % round_no,
|
|
802
|
+
resume=resume_ctx["session"] if resume_ctx else impl_sid[0],
|
|
803
|
+
require_tools=True)
|
|
804
|
+
if impl.get("mode") == "mock" and res["ok"]:
|
|
805
|
+
pass # mock 不产生真实变更
|
|
806
|
+
review_json, verify_pass, verify_ran = review_and_score()
|
|
807
|
+
passed = verify_pass and bool(review_json.get("pass"))
|
|
808
|
+
repairs.append({"round": round_no, "kind": "switch" if switched else (
|
|
809
|
+
"initial" if round_no == 0 else "repair"),
|
|
810
|
+
"verify_pass": verify_pass, "review_pass": bool(review_json.get("pass")),
|
|
811
|
+
"passed": passed})
|
|
812
|
+
if passed:
|
|
813
|
+
break
|
|
814
|
+
if round_no < router.MAX_REPAIR_ROUNDS and not switched:
|
|
815
|
+
round_no += 1
|
|
816
|
+
continue
|
|
817
|
+
if mode == "auto" and not switched:
|
|
818
|
+
ex = (impl["id"],)
|
|
819
|
+
if impl.get("mode") == "real":
|
|
820
|
+
ex += ("mock-a", "mock-b") # 真实实现者失败时不降级到 mock
|
|
821
|
+
other, other_reason = router.pick(agents, "implement", "code", stats, exclude=ex)
|
|
822
|
+
if other is not None:
|
|
823
|
+
note = "换将 %s → %s:%d 轮实现/修复后仍未通过。%s" % (
|
|
824
|
+
impl["id"], other["id"], round_no + 1, other_reason)
|
|
825
|
+
store.update_run(run_id, error="")
|
|
826
|
+
impl = other
|
|
827
|
+
switched = True
|
|
828
|
+
round_no += 1
|
|
829
|
+
continue
|
|
830
|
+
break
|
|
831
|
+
|
|
832
|
+
overall_pass = verify_pass and bool(review_json.get("pass"))
|
|
833
|
+
scores = review_json.get("scores") or {}
|
|
834
|
+
overall_score = round(sum(scores.values()) / len(scores), 1) if scores else None
|
|
835
|
+
verdict = {
|
|
836
|
+
"type": "code", "engine": "code", "pass": overall_pass, "mode": mode,
|
|
837
|
+
"verify_ran": verify_ran, "verify_pass": verify_pass,
|
|
838
|
+
"review_pass": bool(review_json.get("pass")),
|
|
839
|
+
"scores": scores, "overall_score": overall_score,
|
|
840
|
+
"issues": review_json.get("issues") or [],
|
|
841
|
+
"reviewer": reviewer["id"],
|
|
842
|
+
"route": route, "repairs": repairs, "switched": switched,
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
lines = [
|
|
846
|
+
"# 代码任务报告:%s" % task["title"], "",
|
|
847
|
+
"- 结论:**%s**" % ("✅ 通过" if overall_pass else "❌ 未通过"),
|
|
848
|
+
"- 编排模式:%s 实现者:%s 评审者:%s" % (
|
|
849
|
+
"智能" if mode == "auto" else "手动", impl.get("label"), reviewer.get("label")),
|
|
850
|
+
"- 验证命令:%s → %s" % (task.get("verify_command") or "(未配置)",
|
|
851
|
+
"通过" if verify_pass else "未通过"),
|
|
852
|
+
"- 综合评分:%s 修复/换将:%s" % (
|
|
853
|
+
overall_score if overall_score is not None else "-",
|
|
854
|
+
("%d 轮修复" % (len(repairs) - 1)) if len(repairs) > 1 else "无") +
|
|
855
|
+
(" (已换将重实现)" if switched else ""),
|
|
856
|
+
]
|
|
857
|
+
if route:
|
|
858
|
+
lines.append("")
|
|
859
|
+
lines.append("## 路由依据")
|
|
860
|
+
lines.extend("- %s:%s" % (k, v) for k, v in route.items() if v)
|
|
861
|
+
lines += ["", "## 评审问题", ""]
|
|
862
|
+
if review_json.get("issues"):
|
|
863
|
+
for it in review_json["issues"]:
|
|
864
|
+
lines.append("- **[%s]** %s:%s" % (it.get("severity", "?"),
|
|
865
|
+
it.get("title", ""), it.get("detail", "")))
|
|
866
|
+
else:
|
|
867
|
+
lines.append("(无)")
|
|
868
|
+
lines += ["", "## 评审总评", "", review_json.get("summary", ""), ""]
|
|
869
|
+
store.write_report(run_id, "\n".join(lines))
|
|
870
|
+
store.update_run(run_id, status="done", verdict=verdict,
|
|
871
|
+
summary="代码任务%s(验证%s / 评审%s%s)" % (
|
|
872
|
+
"通过" if overall_pass else "未通过",
|
|
873
|
+
"通过" if verify_pass else "未通过",
|
|
874
|
+
"通过" if review_json.get("pass") else "未通过",
|
|
875
|
+
",%d 轮修复" % (len(repairs) - 1) if len(repairs) > 1 else ""),
|
|
876
|
+
ended_at=_now())
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
# ---------------------------------------------------------------- direct 引擎(直连单 CLI:无拆解/评审,对话式续轮)
|
|
880
|
+
|
|
881
|
+
DIRECT_PROMPT = """你是 CodeBee 的执行智能体,直接完成用户交代的任务。用户的目标、背景与工作目录内的附件就是全部输入:不拆解、不评审、不换人,直接动手。
|
|
882
|
+
|
|
883
|
+
## 任务
|
|
884
|
+
__GOAL__
|
|
885
|
+
|
|
886
|
+
## 背景与上下文
|
|
887
|
+
__CONTEXT__
|
|
888
|
+
|
|
889
|
+
## 要求
|
|
890
|
+
- 能改直接改、能写直接写(限本工作目录内),产出文件一律 UTF-8 编码(PowerShell 写文件显式 -Encoding UTF8)。
|
|
891
|
+
- 回复的最后一行单独输出一行交代结果:
|
|
892
|
+
DIRECT_DONE: <一句话说明本轮做了什么、产出了哪些文件>
|
|
893
|
+
这一行之后不要再输出任何内容。"""
|
|
894
|
+
|
|
895
|
+
DIRECT_FOLLOWUP_PROMPT = """你在与用户的持续对话中。用户针对已有成果发来了新消息(见下方「用户实时指令」注入块),请接着处理。
|
|
896
|
+
|
|
897
|
+
## 原始任务
|
|
898
|
+
__GOAL__
|
|
899
|
+
|
|
900
|
+
## 要求
|
|
901
|
+
- 优先回应用户新消息(继续做/改/答疑均可),仍限本工作目录内。
|
|
902
|
+
- 回复的最后一行单独输出:
|
|
903
|
+
DIRECT_DONE: <一句话说明本轮做了什么>
|
|
904
|
+
这一行之后不要再输出任何内容。"""
|
|
905
|
+
|
|
906
|
+
DIRECT_MAX_TURNS = 200 # 对话续轮上限(每轮都要用户主动发消息才触发,防意外打满)
|
|
907
|
+
|
|
908
|
+
# 内置智能体版:人格与工具说明在 builtin_agent._SYSTEM_PROMPT,这里只给任务输入;
|
|
909
|
+
# 不要求 DIRECT_DONE 协议尾行——builtin 的最终回答本身就是干净文本
|
|
910
|
+
BUILTIN_DIRECT_PROMPT = """## 任务
|
|
911
|
+
__GOAL__
|
|
912
|
+
|
|
913
|
+
## 背景与上下文
|
|
914
|
+
__CONTEXT__
|
|
915
|
+
|
|
916
|
+
## 要求
|
|
917
|
+
- 能改直接改、能写直接写(用工具,限本工作目录内),产出文件一律 UTF-8 编码。
|
|
918
|
+
- 完成后直接给用户一段简短说明:做了什么、产出/修改了哪些文件。"""
|
|
919
|
+
|
|
920
|
+
BUILTIN_FOLLOWUP_PROMPT = """## 原始任务
|
|
921
|
+
__GOAL__
|
|
922
|
+
|
|
923
|
+
## 上一轮输出(结尾)
|
|
924
|
+
__PREV__
|
|
925
|
+
|
|
926
|
+
## 要求
|
|
927
|
+
- 优先回应用户的新消息(继续做/改/答疑均可),仍限本工作目录内,工具可用。
|
|
928
|
+
- 回复直接说清本轮做了什么、答案是什么。"""
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _pending_messages(run_id):
|
|
932
|
+
"""该 run 信箱里未消费消息列表(读不到时当空,绝不因信箱异常打断执行)。"""
|
|
933
|
+
try:
|
|
934
|
+
return store.peek_messages(run_id)
|
|
935
|
+
except Exception:
|
|
936
|
+
return []
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _direct_prev_run(task_id, exclude_run_id):
|
|
940
|
+
"""同一任务下最近一次已结束的 direct run(追话时继承会话 id 与工作目录)。"""
|
|
941
|
+
try:
|
|
942
|
+
runs = store.list_runs(limit=200)
|
|
943
|
+
except Exception:
|
|
944
|
+
return None
|
|
945
|
+
cands = [r for r in runs
|
|
946
|
+
if r.get("task_id") == task_id and r.get("id") != exclude_run_id
|
|
947
|
+
and r.get("status") in ("done", "failed", "cancelled")]
|
|
948
|
+
if not cands:
|
|
949
|
+
return None
|
|
950
|
+
return max(cands, key=lambda r: r.get("id") or "")
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _direct_last_text(run):
|
|
954
|
+
"""上一轮 direct run 的最后一条步骤输出(取步骤记录里的 summary)。"""
|
|
955
|
+
steps = run.get("steps") or []
|
|
956
|
+
for s in reversed(steps):
|
|
957
|
+
txt = (s.get("summary") or "").strip()
|
|
958
|
+
if txt:
|
|
959
|
+
return txt
|
|
960
|
+
return ""
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _run_direct(run, task, agents, ev, stats, mode):
|
|
964
|
+
"""直连引擎:目标+附件直接交给一个执行者,跑完即止。
|
|
965
|
+
|
|
966
|
+
执行者优先级:内置智能体(直连模型 API + 工具循环,无 CLI 进程)→ CLI 智能体。
|
|
967
|
+
任务显式声明 CLI 会话续接(resume)或手动指定了执行者时尊重选择走 CLI;
|
|
968
|
+
无可用供应商时回退 CLI。无规划/评审/验证/换将——快档位。对话式续轮:
|
|
969
|
+
运行中信箱来消息 → drain 注入下一步;步骤结束后信箱还有未消费消息就
|
|
970
|
+
再续一轮;信箱空了收工为 done。运行结束后再来消息走 retry_task
|
|
971
|
+
(消息自动继承到新 run)。
|
|
972
|
+
"""
|
|
973
|
+
run_id = run["id"]
|
|
974
|
+
workdir = task["workdir"]
|
|
975
|
+
route = {}
|
|
976
|
+
resume_ctx = _valid_resume(task, agents)
|
|
977
|
+
bi = None
|
|
978
|
+
if resume_ctx is None and not (mode == "manual" and task.get("implementer")):
|
|
979
|
+
try:
|
|
980
|
+
bi = builtin_agent.resolve()
|
|
981
|
+
except Exception:
|
|
982
|
+
bi = None
|
|
983
|
+
if bi is not None:
|
|
984
|
+
impl = None
|
|
985
|
+
route["implementer"] = "CodeBee(%s · %s)" % (bi["provider_name"], bi["model"])
|
|
986
|
+
elif resume_ctx is not None:
|
|
987
|
+
impl = resume_ctx["agent"]
|
|
988
|
+
route["implementer"] = resume_ctx["note"]
|
|
989
|
+
elif mode == "manual":
|
|
990
|
+
impl, _ = _pick_implementer(agents, task.get("implementer"))
|
|
991
|
+
else:
|
|
992
|
+
impl, route["implementer"] = router.pick(agents, "implement", task["type"], stats)
|
|
993
|
+
if impl is None and bi is None:
|
|
994
|
+
store.update_run(run_id, status="failed", error="没有可用智能体", ended_at=_now())
|
|
995
|
+
return
|
|
996
|
+
difficulty = task.get("difficulty") or "default"
|
|
997
|
+
step_wd = _resume_workdir(resume_ctx, workdir) if resume_ctx else workdir
|
|
998
|
+
store.update_run(run_id, route=route, difficulty=difficulty)
|
|
999
|
+
|
|
1000
|
+
sid = (resume_ctx["session"] if resume_ctx else "") or ""
|
|
1001
|
+
last_text = ""
|
|
1002
|
+
# 追话起跑(/api/runs/<id>/chat → retry_task):信箱已有未消费消息 = 这是对话
|
|
1003
|
+
# 的下一轮而非首轮。CLI 继承上一轮的会话 id(真的「接着上次聊」),内置智能体
|
|
1004
|
+
# 靠「上一轮输出(结尾)」块带上下文;同时把首步切成续轮档。
|
|
1005
|
+
try:
|
|
1006
|
+
pending0 = store.peek_messages(run_id)
|
|
1007
|
+
except Exception:
|
|
1008
|
+
pending0 = []
|
|
1009
|
+
if pending0:
|
|
1010
|
+
prev = _direct_prev_run(task["id"], run_id)
|
|
1011
|
+
if prev:
|
|
1012
|
+
ps = (prev.get("direct_session") or {})
|
|
1013
|
+
if impl is not None and ps.get("agent") == impl["id"] and ps.get("session"):
|
|
1014
|
+
sid = sid or ps["session"]
|
|
1015
|
+
if ps.get("workdir"):
|
|
1016
|
+
step_wd = ps["workdir"]
|
|
1017
|
+
last_text = _direct_last_text(prev)
|
|
1018
|
+
first = not pending0
|
|
1019
|
+
turns = 0
|
|
1020
|
+
while True:
|
|
1021
|
+
_wait_gate(run_id, ev)
|
|
1022
|
+
if first:
|
|
1023
|
+
if bi is not None:
|
|
1024
|
+
prompt = (BUILTIN_DIRECT_PROMPT
|
|
1025
|
+
.replace("__GOAL__", task["goal"])
|
|
1026
|
+
.replace("__CONTEXT__", task.get("context") or "(无)"))
|
|
1027
|
+
else:
|
|
1028
|
+
prompt = (DIRECT_PROMPT
|
|
1029
|
+
.replace("__GOAL__", task["goal"])
|
|
1030
|
+
.replace("__CONTEXT__", task.get("context") or "(无)"))
|
|
1031
|
+
note = route.get("implementer", "")
|
|
1032
|
+
images = _task_images(task, workdir)
|
|
1033
|
+
else:
|
|
1034
|
+
if bi is not None:
|
|
1035
|
+
prompt = (BUILTIN_FOLLOWUP_PROMPT
|
|
1036
|
+
.replace("__GOAL__", task["goal"])
|
|
1037
|
+
.replace("__PREV__", (last_text or "(无)")[-3000:]))
|
|
1038
|
+
else:
|
|
1039
|
+
prompt = DIRECT_FOLLOWUP_PROMPT.replace("__GOAL__", task["goal"])
|
|
1040
|
+
if not sid and last_text:
|
|
1041
|
+
# 无会话续接能力的 CLI(如 dsh 一次性任务):把上一轮输出尾部带进上下文
|
|
1042
|
+
prompt += "\n\n## 上一轮输出(结尾)\n" + last_text[-3000:]
|
|
1043
|
+
note = "对话续轮"
|
|
1044
|
+
images = None
|
|
1045
|
+
# 续轮判据:只有「本步执行期间新到」的消息才再开一轮。
|
|
1046
|
+
# 不能只看「信箱非空」——真实步骤的 drain 在 _run_step/_run_builtin_step
|
|
1047
|
+
# 内部发生,起跑前就积压的消息会被本步吃掉(peek 归零);而 mock/不走
|
|
1048
|
+
# drain 的路径消息永远不消费,只看非空会空转到轮数上限。
|
|
1049
|
+
# 比较步骤前后的未消费数即可区分。
|
|
1050
|
+
before_n = len(_pending_messages(run_id))
|
|
1051
|
+
if bi is not None:
|
|
1052
|
+
res = _run_builtin_step(run_id, "direct" if first else "chat", bi, prompt,
|
|
1053
|
+
step_wd, ev=ev, note=note, images=images)
|
|
1054
|
+
else:
|
|
1055
|
+
res = _run_step(run_id, "direct" if first else "chat", impl, prompt, step_wd,
|
|
1056
|
+
readonly=False, ev=ev, note=note,
|
|
1057
|
+
resume=sid or None, images=images)
|
|
1058
|
+
if not res["ok"]:
|
|
1059
|
+
store.update_run(run_id, status="failed",
|
|
1060
|
+
error="执行失败: %s" % res.get("error"), ended_at=_now())
|
|
1061
|
+
return
|
|
1062
|
+
turns += 1
|
|
1063
|
+
last_text = (res.get("text") or "").strip()
|
|
1064
|
+
if impl is not None:
|
|
1065
|
+
new_sid = _resume_sid(impl, res.get("sid"))
|
|
1066
|
+
if new_sid:
|
|
1067
|
+
sid = new_sid
|
|
1068
|
+
try:
|
|
1069
|
+
store.update_run(run_id, direct_session={
|
|
1070
|
+
"agent": "builtin" if bi is not None else impl["id"],
|
|
1071
|
+
"session": sid, "workdir": step_wd})
|
|
1072
|
+
except Exception:
|
|
1073
|
+
pass
|
|
1074
|
+
if turns >= DIRECT_MAX_TURNS:
|
|
1075
|
+
break
|
|
1076
|
+
if len(_pending_messages(run_id)) <= before_n:
|
|
1077
|
+
break # 本步期间没有新消息:对话告一段落
|
|
1078
|
+
first = False
|
|
1079
|
+
|
|
1080
|
+
verdict = {"type": task["type"], "engine": "direct", "pass": True, "mode": mode,
|
|
1081
|
+
"direct": True, "turns": turns,
|
|
1082
|
+
"impl": "builtin" if bi is not None else impl["id"], "route": route}
|
|
1083
|
+
impl_label = ("CodeBee(%s · %s)" % (bi["provider_name"], bi["model"])
|
|
1084
|
+
if bi is not None else impl.get("label"))
|
|
1085
|
+
report = ["# 直连任务:%s" % task["title"], "",
|
|
1086
|
+
"- 执行者:%s(%d 轮对话)" % (impl_label, turns), ""]
|
|
1087
|
+
if last_text:
|
|
1088
|
+
report += ["## 最近一轮输出", "", last_text[-5000:], ""]
|
|
1089
|
+
store.write_report(run_id, "\n".join(report))
|
|
1090
|
+
store.update_run(run_id, status="done", verdict=verdict,
|
|
1091
|
+
summary="直连完成(%d 轮):%s" % (turns, last_text[:160]),
|
|
1092
|
+
ended_at=_now())
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _pick_reviewer_legacy(agents, impl):
|
|
1096
|
+
"""手动模式评审者选择(v1 逻辑:跨厂商 > mock-b > 自评)。"""
|
|
1097
|
+
real = _real(agents)
|
|
1098
|
+
for a in real:
|
|
1099
|
+
if a["id"] != impl["id"]:
|
|
1100
|
+
return a, ""
|
|
1101
|
+
mb = _pick(agents, "mock-b")
|
|
1102
|
+
if mb and impl.get("mode") != "mock":
|
|
1103
|
+
return mb, "(无第二个真实智能体,用 mock 评审)"
|
|
1104
|
+
return impl, "(自评:仅有实现者一个智能体可用)"
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
# ---------------------------------------------------------------- review 引擎(小说/文档/翻译/调研…通用)
|
|
1108
|
+
|
|
1109
|
+
NOVEL_DRAFT_PROMPT = """你是一名专业作者。请在当前工作目录中撰写/修订稿件文件:`__FILE__`(直接写入该文件)。文件必须以 UTF-8 编码保存(PowerShell 写文件显式加 -Encoding UTF8,禁止依赖默认编码)。
|
|
1110
|
+
|
|
1111
|
+
## 写作任务
|
|
1112
|
+
__GOAL__
|
|
1113
|
+
|
|
1114
|
+
## 背景与上下文
|
|
1115
|
+
__CONTEXT__
|
|
1116
|
+
|
|
1117
|
+
## 要求
|
|
1118
|
+
- 只修改 `__FILE__` 这一个文件;保持 Markdown 结构。
|
|
1119
|
+
- 完成后用 3 句话说明本轮写了什么。"""
|
|
1120
|
+
|
|
1121
|
+
NOVEL_REVISE_PROMPT = """你是一名专业作者。请根据下方汇总评审意见修订稿件文件:`__FILE__`(直接写入该文件)。文件必须以 UTF-8 编码保存(PowerShell 写文件显式加 -Encoding UTF8,禁止依赖默认编码)。
|
|
1122
|
+
|
|
1123
|
+
## 原始写作任务
|
|
1124
|
+
__GOAL__
|
|
1125
|
+
|
|
1126
|
+
## 评审汇总(各维度均分与主要问题)
|
|
1127
|
+
__CRITIQUE__
|
|
1128
|
+
|
|
1129
|
+
## 要求
|
|
1130
|
+
- 针对性改进所有 major 问题;保持既定风格与设定。
|
|
1131
|
+
- 完成后用 3 句话说明本轮改了什么。"""
|
|
1132
|
+
|
|
1133
|
+
NOVEL_CRITIQUE_PROMPT = """你是严格的评审(不要使用任何工具、不要修改文件,只依据下方稿件内容评审)。
|
|
1134
|
+
请输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
1135
|
+
{
|
|
1136
|
+
"scores": {"__DIMKEYS__"},
|
|
1137
|
+
"issues": [{"dim": "维度名", "severity": "major|minor", "note": "具体问题"}],
|
|
1138
|
+
"summary": "一句话总评"
|
|
1139
|
+
}
|
|
1140
|
+
每个维度打 1-10 分(可为小数),宁严勿宽。
|
|
1141
|
+
|
|
1142
|
+
## 待评审稿件
|
|
1143
|
+
---
|
|
1144
|
+
__MANUSCRIPT__
|
|
1145
|
+
---"""
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _tpl(task, key, default):
|
|
1149
|
+
"""自定义流程的提示词覆盖:任务上带模板则用之,否则用内置默认。"""
|
|
1150
|
+
t = (task.get(key) or "").strip()
|
|
1151
|
+
return t if t else default
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
# ---------------------------------------------------------------- 故事圣经与评审视角
|
|
1155
|
+
|
|
1156
|
+
BIBLE_FILE = "story-bible.md"
|
|
1157
|
+
_BIBLE_MAX_CHARS = 20000
|
|
1158
|
+
|
|
1159
|
+
# 评审视角播种(dev-3.0 式 bug hunters):N 个评审各领一个深挖镜头,
|
|
1160
|
+
# 避免全员盯着同一处。按评审序号取模分配——同一评审每轮同一镜头,
|
|
1161
|
+
# 提示词前缀字节稳定,不碎前缀缓存。
|
|
1162
|
+
CRITIC_LENSES = (
|
|
1163
|
+
"情节逻辑与因果链(事件是否成立、动机是否充分、有没有逻辑硬伤)",
|
|
1164
|
+
"人物一致性与弧光(言行是否符合人设、成长是否有迹可循)",
|
|
1165
|
+
"文笔与节奏(语言质量、场景切换、详略与爽点铺排)",
|
|
1166
|
+
"设定与伏笔台账(世界观自洽、伏笔是否按故事圣经埋设与回收)",
|
|
1167
|
+
)
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def _story_bible(workdir):
|
|
1171
|
+
"""故事圣经(NovelClaw 式结构化记忆):工作目录里的 story-bible.md
|
|
1172
|
+
(人物卡/世界观/伏笔台账),作者手工维护,每章起草与评审前自动注入。
|
|
1173
|
+
不存在/为空返回 ""——约定式功能,零配置时不产生任何提示词噪音。"""
|
|
1174
|
+
p = os.path.abspath(os.path.join(str(workdir or ""), BIBLE_FILE))
|
|
1175
|
+
if not _inside(workdir, p) or not os.path.isfile(p):
|
|
1176
|
+
return ""
|
|
1177
|
+
try:
|
|
1178
|
+
txt = _read_text_any_enc(p)[:_BIBLE_MAX_CHARS].strip()
|
|
1179
|
+
except OSError:
|
|
1180
|
+
return ""
|
|
1181
|
+
if not txt:
|
|
1182
|
+
return ""
|
|
1183
|
+
return ("## 故事圣经(story-bible.md:人物/世界观/伏笔台账,本书一切写作与评审以此为准,"
|
|
1184
|
+
"与其冲突处以圣经为准)\n\n" + txt)
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def _critic_lens(critics, agent):
|
|
1188
|
+
"""该评审的专属视角;单评审/手动指定时不播种(无从轮换,也别稀释注意力)。"""
|
|
1189
|
+
try:
|
|
1190
|
+
idx = list(critics).index(agent)
|
|
1191
|
+
except ValueError:
|
|
1192
|
+
return ""
|
|
1193
|
+
if len(critics) < 2:
|
|
1194
|
+
return ""
|
|
1195
|
+
return CRITIC_LENSES[idx % len(CRITIC_LENSES)]
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def _ensure_critique_placeholders(tpl):
|
|
1199
|
+
"""自定义评审模板缺占位符时补上,避免稿件内容/维度定义丢失导致盲评。"""
|
|
1200
|
+
if "__MANUSCRIPT__" not in tpl:
|
|
1201
|
+
tpl += "\n\n## 待评审稿件\n---\n__MANUSCRIPT__\n---"
|
|
1202
|
+
if "__DIMKEYS__" not in tpl:
|
|
1203
|
+
tpl = ("请按维度打分(1-10 分)。\n\n" + tpl)
|
|
1204
|
+
return tpl
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
# ---------------------------------------------------------------- 连载引擎(长篇小说:逐章打磨)
|
|
1208
|
+
|
|
1209
|
+
SERIAL_CHAPTER_PROMPT = """你是一名网文作者(写作规范见下方经验库)。本书信息如下,请先完整读完再执行末尾的「本章任务」。
|
|
1210
|
+
|
|
1211
|
+
__SKILLS__
|
|
1212
|
+
|
|
1213
|
+
## 全书目标
|
|
1214
|
+
__GOAL__
|
|
1215
|
+
|
|
1216
|
+
## 全书大纲(__SCOPE__)
|
|
1217
|
+
__OUTLINE__
|
|
1218
|
+
|
|
1219
|
+
---
|
|
1220
|
+
## 本章任务(执行这一条即可)
|
|
1221
|
+
- 撰写本书第 __I__ 章,把本章正文写入文件 `__FILE__`(直接写入该文件,只写本章)。文件必须以 UTF-8 编码保存:PowerShell 一律显式加 `-Encoding UTF8`(如 `Set-Content -Path __FILE__ -Encoding UTF8`),禁止依赖系统默认编码,否则中文会乱码。
|
|
1222
|
+
- 章节标题:__TITLE__
|
|
1223
|
+
- 剧情要点:__BEATS__
|
|
1224
|
+
- 章末钩子:__HOOK__
|
|
1225
|
+
- 正文约 __WORDS__ 字,中文,直接开写正文(可含本章标题行)。
|
|
1226
|
+
|
|
1227
|
+
## 前情提要(此前各章结尾摘录,衔接用)
|
|
1228
|
+
__PREV__
|
|
1229
|
+
|
|
1230
|
+
- 写完文件后,最终回复只输出一行:`第 __I__ 章完成(约 __WORDS__ 字)`——不要在回复里复述或解释正文。"""
|
|
1231
|
+
|
|
1232
|
+
SERIAL_REVISE_PROMPT = """你是一名网文作者。第 __I__ 章没有通过评审,请修订文件 `__FILE__`(直接改写该文件)。文件必须以 UTF-8 编码保存(PowerShell 显式加 -Encoding UTF8,禁止依赖默认编码)。
|
|
1233
|
+
|
|
1234
|
+
## 全书目标
|
|
1235
|
+
__GOAL__
|
|
1236
|
+
|
|
1237
|
+
## 本章评审意见
|
|
1238
|
+
__CRITIQUE__
|
|
1239
|
+
|
|
1240
|
+
## 要求
|
|
1241
|
+
- 针对性解决所有 major 问题,保持与前后的剧情衔接;字数仍约 __WORDS__ 字。"""
|
|
1242
|
+
|
|
1243
|
+
SERIAL_GLOBAL_PROMPT = """你是网文主编(不要修改任何文件)。全书各章已完稿,请从**全书整体**视角评审。
|
|
1244
|
+
请输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
1245
|
+
{
|
|
1246
|
+
"scores": {"__DIMKEYS__"},
|
|
1247
|
+
"issues": [{"dim": "维度名", "severity": "major|minor", "note": "具体问题(指明哪一章)"}],
|
|
1248
|
+
"summary": "一句话总评:是否达到可签约水平"
|
|
1249
|
+
}
|
|
1250
|
+
每个维度打 1-10 分,宁严勿宽。重点关注:主线一致性、人物弧光、节奏、爽点密度、完本感。
|
|
1251
|
+
|
|
1252
|
+
## 全书目标
|
|
1253
|
+
__GOAL__
|
|
1254
|
+
|
|
1255
|
+
## 全文
|
|
1256
|
+
---
|
|
1257
|
+
__MANUSCRIPT__
|
|
1258
|
+
---"""
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def _read_text_any_enc(p):
|
|
1262
|
+
"""工作区文件读文本:委托 runner.read_text_any_enc(UTF-8 → GBK → replace)。
|
|
1263
|
+
CLI 子代理在中文 Windows 上可能把文件落成 GBK,统一走这条纪律,消费侧
|
|
1264
|
+
(前情提要/评审/合并稿)不再因编码混编出 U+FFFD。"""
|
|
1265
|
+
return runner.read_text_any_enc(p)
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def _chapter_io(workdir, i, mode):
|
|
1269
|
+
"""打开第 i 章文件;open 紧邻边界校验,路径越界直接拒绝(形态同 _ms_io)。
|
|
1270
|
+
读模式兼容 GBK 落盘的章稿(见 _read_text_any_enc)。"""
|
|
1271
|
+
p = os.path.abspath(os.path.join(workdir, "chapter-%02d.md" % i))
|
|
1272
|
+
if not _inside(workdir, p):
|
|
1273
|
+
raise ValueError("章节路径越界: chapter-%02d.md" % i)
|
|
1274
|
+
if mode == "r":
|
|
1275
|
+
return _read_text_any_enc(p)
|
|
1276
|
+
return open(p, mode, encoding="utf-8", errors="replace")
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def _read_chapter(workdir, i):
|
|
1280
|
+
try:
|
|
1281
|
+
return _chapter_io(workdir, i, "r")
|
|
1282
|
+
except Exception:
|
|
1283
|
+
return ""
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def _read_variant(workdir, i, k):
|
|
1287
|
+
"""赛马变体稿 chapter-XX-vK.md;不存在/读失败返回空串。"""
|
|
1288
|
+
p = os.path.abspath(os.path.join(str(workdir), "chapter-%02d-v%d.md" % (i, k)))
|
|
1289
|
+
if not _inside(workdir, p) or not os.path.isfile(p):
|
|
1290
|
+
return ""
|
|
1291
|
+
try:
|
|
1292
|
+
return _read_text_any_enc(p)
|
|
1293
|
+
except OSError:
|
|
1294
|
+
return ""
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
def _wc(text):
|
|
1298
|
+
"""近似字数(去空白后的字符数,中文场景够用)。"""
|
|
1299
|
+
return len(re.sub(r"\s", "", text or ""))
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
def _write_chapter(workdir, i, text):
|
|
1303
|
+
with _chapter_io(workdir, i, "w") as f:
|
|
1304
|
+
f.write(text)
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def _all_ge(scores, threshold):
|
|
1308
|
+
"""scores 全部 ≥ threshold(分数缺失视为不达标,不炸比较)。"""
|
|
1309
|
+
def num(v):
|
|
1310
|
+
try:
|
|
1311
|
+
return float(v)
|
|
1312
|
+
except (TypeError, ValueError):
|
|
1313
|
+
return None
|
|
1314
|
+
thr = num(threshold)
|
|
1315
|
+
if thr is None:
|
|
1316
|
+
return False
|
|
1317
|
+
vals = [num(v) for v in (scores or {}).values()]
|
|
1318
|
+
return bool(vals) and all(v is not None and v >= thr for v in vals)
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def _weakest_chapters(chapter_scores, global_means, threshold, limit=2):
|
|
1322
|
+
"""定位最该重改的章:维度不达标者优先,其次全书短板对应维度最低者。
|
|
1323
|
+
|
|
1324
|
+
分数缺失/非法(None、非数字)按"未知"处理:不参与比较,也不让排序崩溃。
|
|
1325
|
+
"""
|
|
1326
|
+
def num(v):
|
|
1327
|
+
try:
|
|
1328
|
+
return float(v)
|
|
1329
|
+
except (TypeError, ValueError):
|
|
1330
|
+
return None
|
|
1331
|
+
|
|
1332
|
+
thr = num(threshold) or 7.0
|
|
1333
|
+
short = [d for d, v in (global_means or {}).items()
|
|
1334
|
+
if (num(v) if num(v) is not None else thr) < thr]
|
|
1335
|
+
|
|
1336
|
+
def score(c):
|
|
1337
|
+
m = {d: num(v) for d, v in (c.get("means") or {}).items()}
|
|
1338
|
+
bad = [v for d, v in m.items() if d in short and v is not None]
|
|
1339
|
+
vals = [v for v in m.values() if v is not None]
|
|
1340
|
+
return (0 if not c.get("passed") else 1,
|
|
1341
|
+
-sum(1 for v in vals if v < thr),
|
|
1342
|
+
(sum(bad) / len(bad)) if bad else 99.0,
|
|
1343
|
+
(sum(vals) / len(vals)) if vals else 99.0)
|
|
1344
|
+
|
|
1345
|
+
cand = [c for c in chapter_scores if not c.get("passed") or short]
|
|
1346
|
+
cand.sort(key=score)
|
|
1347
|
+
return cand[:limit]
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route, resume_ctx, difficulty):
|
|
1351
|
+
"""连载流水线:大纲 → 逐章起草/评审/修订 → 全局一致性评审 → 合并成书。"""
|
|
1352
|
+
import json as _json
|
|
1353
|
+
run_id = run["id"]
|
|
1354
|
+
workdir = task["workdir"]
|
|
1355
|
+
# 续会话步骤的 CLI 启动目录(稿件读写仍用 workdir)
|
|
1356
|
+
step_wd = _resume_workdir(resume_ctx, workdir) if resume_ctx else workdir
|
|
1357
|
+
serial = task.get("serial") or {}
|
|
1358
|
+
n = int(serial.get("chapters") or 8)
|
|
1359
|
+
wpc = int(serial.get("words_per_chapter") or 2500)
|
|
1360
|
+
dims = task.get("rubric") or DEFAULT_RUBRIC
|
|
1361
|
+
threshold = task.get("threshold", 7.0)
|
|
1362
|
+
threshold_ch = threshold - 0.5 if threshold >= 7.5 else threshold # 单章阈值略放宽 0.5 分
|
|
1363
|
+
dimkey = ", ".join('"%s": 0' % d for d in dims)
|
|
1364
|
+
# 续写批次的全书起始章号(=1 为全新连载;>1 时章节文件/步骤/评分都用全书章号,
|
|
1365
|
+
# 与上一批任务在同一工作目录无缝衔接)
|
|
1366
|
+
start = int(serial.get("start_chapter") or 1)
|
|
1367
|
+
# 故事圣经:工作目录里的 story-bible.md,整个 run 内字节稳定(前缀缓存友好)
|
|
1368
|
+
bible = _story_bible(workdir)
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
def crit_prompt_for(text, note=""):
|
|
1372
|
+
tpl = _ensure_critique_placeholders(
|
|
1373
|
+
_tpl(task, "critique_prompt", NOVEL_CRITIQUE_PROMPT))
|
|
1374
|
+
if note:
|
|
1375
|
+
tpl = tpl.replace("你是严格的评审",
|
|
1376
|
+
"你是严格的评审(背景:%s,请结合全书目标评审本章节)" % note, 1)
|
|
1377
|
+
# stable_order:评审分轮次调用,hits 中途变化会打碎前缀缓存(§07 T1.2')
|
|
1378
|
+
sk, _ = skills.block_for(task, stable_order=True)
|
|
1379
|
+
if sk:
|
|
1380
|
+
tpl = tpl.replace("## 待评审稿件", "%s\n\n## 待评审稿件" % sk, 1)
|
|
1381
|
+
if bible:
|
|
1382
|
+
tpl = tpl.replace("## 待评审稿件", "%s\n\n## 待评审稿件" % bible, 1)
|
|
1383
|
+
return tpl.replace("__DIMKEYS__", dimkey).replace(
|
|
1384
|
+
"__MANUSCRIPT__", text or "(稿件为空!)")
|
|
1385
|
+
|
|
1386
|
+
# ---- 1) 大纲(断点续跑时直接继承上一遍,保证全书结构一致)
|
|
1387
|
+
inherit = run.get("inherit") or {}
|
|
1388
|
+
done_set = set(inherit.get("done_chapters") or [])
|
|
1389
|
+
inh_scores = {c.get("chapter"): c for c in (inherit.get("chapter_scores") or [])}
|
|
1390
|
+
if inherit.get("outline"):
|
|
1391
|
+
outline = inherit["outline"]
|
|
1392
|
+
# 历史遗留:降级/模板大纲被继承时,真实任务宁可中止重生成,也不按空模板写全书
|
|
1393
|
+
if (outline.get("degraded") or outline.get("source") == "template") \
|
|
1394
|
+
and impl.get("mode") != "mock":
|
|
1395
|
+
store.update_run(run_id, status="failed",
|
|
1396
|
+
error="继承的大纲为降级模板(无真实情节),已中止以重新生成大纲",
|
|
1397
|
+
ended_at=_now())
|
|
1398
|
+
return
|
|
1399
|
+
n = len(outline.get("chapters") or []) or n
|
|
1400
|
+
outline_step, _ = store.add_step(run_id, "outline", impl["id"], impl.get("label"),
|
|
1401
|
+
note="断点续跑")
|
|
1402
|
+
store.finish_step(run_id, outline_step["n"], "done",
|
|
1403
|
+
summary="继承上一遍大纲(共 %d 章),已完成 %d 章将被复用"
|
|
1404
|
+
% (n, len(done_set & set(range(start, start + n)))),
|
|
1405
|
+
duration_s=0.1)
|
|
1406
|
+
else:
|
|
1407
|
+
_wait_gate(run_id, ev)
|
|
1408
|
+
outline_step, outline_log = store.add_step(run_id, "outline", impl["id"], impl.get("label"),
|
|
1409
|
+
note=route.get("author", ""))
|
|
1410
|
+
outline = planner.make_serial_outline(_steered_task(run_id, task), impl, workdir, ev,
|
|
1411
|
+
log_path=str(outline_log) if outline_log else None)
|
|
1412
|
+
if outline.get("degraded") and impl.get("mode") != "mock":
|
|
1413
|
+
# 兜底模板只有章号没有情节,据此写出的两万字等于废稿——
|
|
1414
|
+
# 中止并交给自动续跑等编排者恢复后重试,而不是空转烧配额。
|
|
1415
|
+
store.finish_step(run_id, outline_step["n"], "failed",
|
|
1416
|
+
summary="大纲降级:%s" % (outline.get("degraded_reason") or "编排者不可用"),
|
|
1417
|
+
duration_s=None)
|
|
1418
|
+
store.update_run(run_id, status="failed",
|
|
1419
|
+
error="%s,已中止以免按空模板写全书"
|
|
1420
|
+
% (outline.get("degraded_reason") or "编排者不可用"),
|
|
1421
|
+
ended_at=_now())
|
|
1422
|
+
return
|
|
1423
|
+
try:
|
|
1424
|
+
with open(outline_log, "a", encoding="utf-8") as f:
|
|
1425
|
+
f.write("\n===== 最终大纲 =====\n")
|
|
1426
|
+
f.write(_json.dumps(outline, ensure_ascii=False, indent=2))
|
|
1427
|
+
except Exception:
|
|
1428
|
+
pass
|
|
1429
|
+
store.finish_step(run_id, outline_step["n"], "done",
|
|
1430
|
+
summary="大纲来源 %s:%s(共 %d 章)" % (
|
|
1431
|
+
outline.get("source", "?"),
|
|
1432
|
+
outline.get("book_title") or task["title"], n),
|
|
1433
|
+
duration_s=0.1 if impl.get("mode") == "mock" else None)
|
|
1434
|
+
store.update_run(run_id, outline=outline)
|
|
1435
|
+
_check_cancel(ev)
|
|
1436
|
+
end = start + n - 1 # 本批最后一章的全书章号(全局评审与合并成书覆盖 1..end)
|
|
1437
|
+
outline_txt = "\n".join(
|
|
1438
|
+
"第 %d 章《%s》:%s%s" % (start + k, c["title"], c["beats"],
|
|
1439
|
+
("(章末钩子:%s)" % c.get("hook")) if c.get("hook") else "")
|
|
1440
|
+
for k, c in enumerate(outline["chapters"]))
|
|
1441
|
+
|
|
1442
|
+
chapter_scores = [] # [{chapter,title,means,passed,rounds,words}]
|
|
1443
|
+
issues_all = []
|
|
1444
|
+
|
|
1445
|
+
# ---- 2) 逐章
|
|
1446
|
+
for k in range(1, n + 1):
|
|
1447
|
+
i = start + k - 1 # 全书章号:文件名/步骤角色/评分记录都按全书编号
|
|
1448
|
+
ch = outline["chapters"][k - 1]
|
|
1449
|
+
ch_file = "chapter-%02d.md" % i
|
|
1450
|
+
prev = ""
|
|
1451
|
+
draft_sid = "" # §07 T1.1:本轮 draft/复用章的会话 id(revise 复用;reuse 时为空)
|
|
1452
|
+
if i > 1:
|
|
1453
|
+
tails = []
|
|
1454
|
+
for j in range(max(1, i - 2), i):
|
|
1455
|
+
t = _read_chapter(workdir, j)
|
|
1456
|
+
if t:
|
|
1457
|
+
tails.append("(第 %d 章结尾)…%s" % (j, t[-260:].strip()))
|
|
1458
|
+
prev = "\n".join(tails) or "(无)"
|
|
1459
|
+
|
|
1460
|
+
# 评审-修订(每章至多 1 轮修订)
|
|
1461
|
+
rounds_used = 1
|
|
1462
|
+
means = {}
|
|
1463
|
+
|
|
1464
|
+
def run_critique(text, rnd, note_extra="", critic_sids=None):
|
|
1465
|
+
"""一轮多维评审:返回 (cj_by_agent, scored)。变体赛马与主循环共用。"""
|
|
1466
|
+
cj_map, sids = {}, dict(critic_sids or {})
|
|
1467
|
+
scored = 0 # 真正给出分数的评审数;失败/不可解析不得当成 0 分计入
|
|
1468
|
+
for agent in critics:
|
|
1469
|
+
role = "critique-c%d" % i
|
|
1470
|
+
if agent.get("mode") == "mock":
|
|
1471
|
+
step, log_abs = store.add_step(run_id, role, agent["id"], agent.get("label"))
|
|
1472
|
+
time.sleep(0.15)
|
|
1473
|
+
cj = mocks.critique(agent["id"], rnd, dims, threshold_ch)
|
|
1474
|
+
scored += 1
|
|
1475
|
+
store.finish_step(run_id, step["n"], "done",
|
|
1476
|
+
summary="均分 %.1f:%s" % (
|
|
1477
|
+
sum(cj["scores"].values()) / max(1, len(dims)),
|
|
1478
|
+
cj["summary"]),
|
|
1479
|
+
duration_s=0.15)
|
|
1480
|
+
else:
|
|
1481
|
+
lens = _critic_lens(critics, agent)
|
|
1482
|
+
res = _run_step(run_id, role, modelhub.bind_agent(agent, difficulty),
|
|
1483
|
+
crit_prompt_for(
|
|
1484
|
+
text,
|
|
1485
|
+
note=("小说第 %d 章" % i) + (
|
|
1486
|
+
"|你的专属评审视角:%s(其他评审会覆盖其余视角,"
|
|
1487
|
+
"请深挖你的镜头,但所有维度仍需打分)" % lens)
|
|
1488
|
+
if lens else "") + note_extra,
|
|
1489
|
+
workdir, readonly=True, ev=ev,
|
|
1490
|
+
resume=sids.get(agent["id"]))
|
|
1491
|
+
cj = runner.extract_json(res.get("text") or "")
|
|
1492
|
+
if not isinstance(cj, dict) or not isinstance(cj.get("scores"), dict) \
|
|
1493
|
+
or not cj.get("scores"):
|
|
1494
|
+
cj = {"scores": {}, "issues": [],
|
|
1495
|
+
"summary": "评审输出无法解析:%s" % (res.get("text")
|
|
1496
|
+
or res.get("error") or "")[:150]}
|
|
1497
|
+
else:
|
|
1498
|
+
scored += 1
|
|
1499
|
+
# §07 T1.1:记录该评审的会话 id(第 2 轮复用)
|
|
1500
|
+
csid = _resume_sid(agent, res.get("sid"))
|
|
1501
|
+
if csid:
|
|
1502
|
+
sids[agent["id"]] = csid
|
|
1503
|
+
cj_map[agent["id"]] = cj
|
|
1504
|
+
issues_all.extend({"chapter": i, **it} for it in (cj.get("issues") or [])[:6])
|
|
1505
|
+
_check_cancel(ev)
|
|
1506
|
+
|
|
1507
|
+
# 评审者级 fallback:名单内评审全挂(网关抖动/CLI 故障)时,
|
|
1508
|
+
# 从其它已启用真实智能体补位至多 2 个(排除 mock 与已试过的),
|
|
1509
|
+
# 只要有一个出分就不触发「评审全败中止」。
|
|
1510
|
+
if not scored and impl.get("mode") != "mock":
|
|
1511
|
+
tried = {a.get("id") for a in critics}
|
|
1512
|
+
pool = [a for a in (agents or [])
|
|
1513
|
+
if a.get("mode") == "real" and a.get("id") not in tried]
|
|
1514
|
+
for spare in pool[:2]:
|
|
1515
|
+
res = _run_step(run_id, role, modelhub.bind_agent(spare, difficulty),
|
|
1516
|
+
crit_prompt_for(
|
|
1517
|
+
text,
|
|
1518
|
+
note="小说第 %d 章" % i) + note_extra,
|
|
1519
|
+
workdir, readonly=True, ev=ev)
|
|
1520
|
+
cj = runner.extract_json(res.get("text") or "")
|
|
1521
|
+
if isinstance(cj, dict) and isinstance(cj.get("scores"), dict) \
|
|
1522
|
+
and cj.get("scores"):
|
|
1523
|
+
cj_map[spare["id"]] = cj
|
|
1524
|
+
scored += 1
|
|
1525
|
+
issues_all.extend({"chapter": i, **it}
|
|
1526
|
+
for it in (cj.get("issues") or [])[:6])
|
|
1527
|
+
break
|
|
1528
|
+
_check_cancel(ev)
|
|
1529
|
+
return cj_map, scored, sids
|
|
1530
|
+
|
|
1531
|
+
def means_of(cj_map):
|
|
1532
|
+
vals = {}
|
|
1533
|
+
for d in dims:
|
|
1534
|
+
xs = [float(cj["scores"].get(d, 0)) for cj in cj_map.values()
|
|
1535
|
+
if isinstance(cj.get("scores"), dict) and d in cj["scores"]]
|
|
1536
|
+
vals[d] = round(sum(xs) / len(xs), 1) if xs else 0.0
|
|
1537
|
+
return vals
|
|
1538
|
+
|
|
1539
|
+
critic_sids = {} # §07 T1.1:每评审的会话 id(第 2 轮复用,前缀走缓存读)
|
|
1540
|
+
race_cj = None # 变体赛马已评审胜者:直接作为第 1 轮结果,不重评
|
|
1541
|
+
race_scored = 0
|
|
1542
|
+
|
|
1543
|
+
reuse = i in done_set and os.path.exists(os.path.join(workdir, ch_file))
|
|
1544
|
+
if reuse:
|
|
1545
|
+
# 坏稿防线:进程中途死掉会留下半成品文件(实测 4 字节的 chapter-12),
|
|
1546
|
+
# 按「文件存在」复用会让评审给垃圾稿打低分、修订陷入循环。
|
|
1547
|
+
# 字数低于 max(200, 30% 目标字数) → 视为未完成,整章重写。
|
|
1548
|
+
words_now = _wc(_read_chapter(workdir, i))
|
|
1549
|
+
if words_now < max(200, int(wpc * 0.3)):
|
|
1550
|
+
reuse = False
|
|
1551
|
+
if reuse:
|
|
1552
|
+
# 断点续跑:上一遍已写好的章直接复用(不重写;分数沿用既有记录或重评)
|
|
1553
|
+
step, _log = store.add_step(run_id, "draft-c%d" % i, impl["id"], impl.get("label"),
|
|
1554
|
+
note="断点续跑")
|
|
1555
|
+
store.finish_step(run_id, step["n"], "done",
|
|
1556
|
+
summary="(断点续跑)复用上一遍成稿 %s(约 %d 字)"
|
|
1557
|
+
% (ch_file, _wc(_read_chapter(workdir, i))),
|
|
1558
|
+
duration_s=0.1)
|
|
1559
|
+
elif impl.get("mode") == "mock":
|
|
1560
|
+
step, log_abs = store.add_step(run_id, "draft-c%d" % i, impl["id"], impl.get("label"))
|
|
1561
|
+
_write_chapter(workdir, i, mocks.draft_manuscript(
|
|
1562
|
+
{"title": ch["title"], "goal": task["goal"]}, 1))
|
|
1563
|
+
time.sleep(0.2)
|
|
1564
|
+
store.finish_step(run_id, step["n"], "done", summary="(mock)第 %d 章草稿落盘" % i,
|
|
1565
|
+
duration_s=0.2)
|
|
1566
|
+
else:
|
|
1567
|
+
# stable_order:同一任务 8 个章节的技能块必须字节级一致(§07 T1.2' 前缀缓存)
|
|
1568
|
+
sk_block, _ = skills.block_for(task, stable_order=True)
|
|
1569
|
+
if bible:
|
|
1570
|
+
sk_block = (sk_block + "\n\n" + bible) if sk_block else bible
|
|
1571
|
+
scope = ("本章 = 大纲第 %d 章" % i) if start == 1 else (
|
|
1572
|
+
"本批为第 %d–%d 章,下列按全书章号列出各章要点" % (start, end))
|
|
1573
|
+
|
|
1574
|
+
def _draft_prompt(vfile):
|
|
1575
|
+
return (SERIAL_CHAPTER_PROMPT
|
|
1576
|
+
.replace("__SKILLS__", sk_block)
|
|
1577
|
+
.replace("__SCOPE__", scope)
|
|
1578
|
+
.replace("__I__", str(i)).replace("__FILE__", vfile)
|
|
1579
|
+
.replace("__GOAL__", task["goal"])
|
|
1580
|
+
.replace("__OUTLINE__", outline_txt)
|
|
1581
|
+
.replace("__PREV__", prev)
|
|
1582
|
+
.replace("__TITLE__", ch["title"])
|
|
1583
|
+
.replace("__BEATS__", ch["beats"] or "按大纲推进")
|
|
1584
|
+
.replace("__HOOK__", ch.get("hook") or "留下悬念")
|
|
1585
|
+
.replace("__WORDS__", str(wpc)))
|
|
1586
|
+
|
|
1587
|
+
n_variants = max(1, min(3, int(serial.get("variants") or 1)))
|
|
1588
|
+
race = (n_variants >= 2 and not _compaction_enabled()
|
|
1589
|
+
and not resume_ctx) # 续会话语义只认 impl 一人,赛马退场
|
|
1590
|
+
if not race:
|
|
1591
|
+
prompt = _draft_prompt(ch_file)
|
|
1592
|
+
# 网关突发限流(Not Allowed / UnknownError)秒~分钟级自愈:章稿
|
|
1593
|
+
# 失败先退避重试同作者(保住连载风格一致),重试穷尽才判死整 run
|
|
1594
|
+
# ——2026-09-17 七猫连载实测:多 run 并行推进时后位章节必撞限流,
|
|
1595
|
+
# 一章失败即整 run 作废,太贵。
|
|
1596
|
+
def _chapter_state():
|
|
1597
|
+
"""草稿验收:成品是章稿文件本身,不是退出码——文件够长才算好。"""
|
|
1598
|
+
txt = _read_chapter(workdir, i)
|
|
1599
|
+
return (bool(txt) and _wc(txt) >= int(wpc * 0.6)), (txt or "")
|
|
1600
|
+
|
|
1601
|
+
res = None
|
|
1602
|
+
good = False
|
|
1603
|
+
txt = ""
|
|
1604
|
+
for draft_attempt in range(3):
|
|
1605
|
+
if draft_attempt:
|
|
1606
|
+
if ev is not None and ev.is_set():
|
|
1607
|
+
break
|
|
1608
|
+
time.sleep(30 * draft_attempt) # 30s / 60s 退避
|
|
1609
|
+
res = _run_step(run_id, "draft-c%d" % i, modelhub.bind_agent(impl, difficulty), prompt,
|
|
1610
|
+
step_wd, readonly=False, ev=ev, timeout=2400,
|
|
1611
|
+
resume=resume_ctx["session"] if resume_ctx else None,
|
|
1612
|
+
images=_task_images(task, workdir),
|
|
1613
|
+
note=("起草重试 %d/2(网关限流退避)" % draft_attempt) if draft_attempt else "")
|
|
1614
|
+
good, txt = _chapter_state()
|
|
1615
|
+
if good:
|
|
1616
|
+
break
|
|
1617
|
+
if res["ok"] and _wc(res.get("text") or "") >= int(wpc * 0.6):
|
|
1618
|
+
# 回复正文就是完整章稿(kimi 35B 实测:正文当消息回而不
|
|
1619
|
+
# 落盘)→ 代为落盘救回成品
|
|
1620
|
+
try:
|
|
1621
|
+
_write_chapter(workdir, i, res["text"])
|
|
1622
|
+
good, txt = _chapter_state()
|
|
1623
|
+
except OSError:
|
|
1624
|
+
pass
|
|
1625
|
+
if good:
|
|
1626
|
+
break
|
|
1627
|
+
if ev is not None and ev.is_set():
|
|
1628
|
+
break
|
|
1629
|
+
# 同作者重试穷尽 → 起草换将(连载不断档优先,风格差异交评审门与
|
|
1630
|
+
# 后续 revise 拉回)——与代码流程换将同款语义(2026-09-17 c34 实测)
|
|
1631
|
+
if not good:
|
|
1632
|
+
other, other_reason = router.pick(
|
|
1633
|
+
agents, "implement", task.get("type") or "serial", None,
|
|
1634
|
+
exclude={impl["id"], "mock-a", "mock-b"})
|
|
1635
|
+
if other and other.get("mode") == "real":
|
|
1636
|
+
res = _run_step(run_id, "draft-c%d" % i,
|
|
1637
|
+
modelhub.bind_agent(other, difficulty), prompt,
|
|
1638
|
+
step_wd, readonly=False, ev=ev, timeout=2400,
|
|
1639
|
+
images=_task_images(task, workdir),
|
|
1640
|
+
note="起草换将 %s → %s:%s" % (
|
|
1641
|
+
impl["id"], other["id"],
|
|
1642
|
+
(other_reason or "")[:90]))
|
|
1643
|
+
good, txt = _chapter_state()
|
|
1644
|
+
if not good and res["ok"] and _wc(res.get("text") or "") >= int(wpc * 0.6):
|
|
1645
|
+
try:
|
|
1646
|
+
_write_chapter(workdir, i, res["text"])
|
|
1647
|
+
good, txt = _chapter_state()
|
|
1648
|
+
except OSError:
|
|
1649
|
+
pass
|
|
1650
|
+
if good:
|
|
1651
|
+
draft_sid = "" # 换将作者无本任会话,revise 另起
|
|
1652
|
+
if not good:
|
|
1653
|
+
store.update_run(run_id, status="failed",
|
|
1654
|
+
error="第 %d 章起草失败: %s" % (i, (res or {}).get("error")), ended_at=_now())
|
|
1655
|
+
return
|
|
1656
|
+
if not res["ok"]:
|
|
1657
|
+
# 成品是文件不是退出码:CLI 超时但章稿已完整落盘(终章长文实测
|
|
1658
|
+
# 反复出现——文件写完、收尾声明没等到)就送评审门把关,别整章作废
|
|
1659
|
+
live = (store.get_run(run_id).get("steps") or [])
|
|
1660
|
+
if live:
|
|
1661
|
+
store.finish_step(run_id, live[-1]["n"], "done",
|
|
1662
|
+
summary="起草调用超时,但章稿已完整落盘(约 %d 字)——交评审门判质量"
|
|
1663
|
+
% _wc(txt))
|
|
1664
|
+
# §07 T1.1:draft 会话 id 供本轮 revise 复用(同会话内前缀走缓存读计价)
|
|
1665
|
+
draft_sid = (_resume_sid(impl, res.get("sid")) or "") if res["ok"] else draft_sid
|
|
1666
|
+
else:
|
|
1667
|
+
# ---- 同章多稿赛马(dev-3.0):n 个作者并行起草 → 逐变体评审 →
|
|
1668
|
+
# 均分最高者为正稿。变体写隔离文件 chapter-XX-vK.md,赢家改名、
|
|
1669
|
+
# 败稿删除;变体 0 = 本任作者(revise 会话沿用),其余取跨族优先的
|
|
1670
|
+
# 其他真实智能体,不足时同作者开新会话凑数。
|
|
1671
|
+
pool = [impl]
|
|
1672
|
+
others = [a for a in agents if a.get("mode") == "real" and a["id"] != impl["id"]]
|
|
1673
|
+
others.sort(key=lambda a: 0 if a.get("kind") != impl.get("kind") else 1)
|
|
1674
|
+
pool += others[:n_variants - 1]
|
|
1675
|
+
while len(pool) < n_variants:
|
|
1676
|
+
pool.append(impl) # 不够就同作者再开一路(新会话天然出不同稿)
|
|
1677
|
+
results = {}
|
|
1678
|
+
|
|
1679
|
+
def _draft_one(kk, agent):
|
|
1680
|
+
vfile = "chapter-%02d-v%d.md" % (i, kk)
|
|
1681
|
+
r = _run_step(run_id, "draft-c%d-v%d" % (i, kk),
|
|
1682
|
+
modelhub.bind_agent(agent, difficulty),
|
|
1683
|
+
_draft_prompt(vfile), step_wd, readonly=False, ev=ev,
|
|
1684
|
+
timeout=2400,
|
|
1685
|
+
# 赛马只在全新起草时启用(无续会话),每路都是新会话
|
|
1686
|
+
images=_task_images(task, workdir),
|
|
1687
|
+
note="赛马变体 %d/%d(%s)" % (kk + 1, len(pool), agent.get("id")))
|
|
1688
|
+
results[kk] = (vfile, agent, r)
|
|
1689
|
+
|
|
1690
|
+
threads = []
|
|
1691
|
+
for kk, agent in enumerate(pool):
|
|
1692
|
+
th = threading.Thread(target=_draft_one, args=(kk, agent),
|
|
1693
|
+
name="race-%s-c%d-v%d" % (run_id, i, kk), daemon=True)
|
|
1694
|
+
threads.append(th)
|
|
1695
|
+
th.start()
|
|
1696
|
+
for th in threads:
|
|
1697
|
+
th.join(3000)
|
|
1698
|
+
_check_cancel(ev)
|
|
1699
|
+
|
|
1700
|
+
scored_variants = []
|
|
1701
|
+
for kk in range(len(pool)):
|
|
1702
|
+
vfile, agent, r = results.get(kk, (None, None, None))
|
|
1703
|
+
if vfile is None:
|
|
1704
|
+
continue
|
|
1705
|
+
txt = _read_variant(workdir, i, kk)
|
|
1706
|
+
ok_text = txt and _wc(txt) >= int(wpc * 0.6)
|
|
1707
|
+
if r is not None and not r["ok"] and not ok_text:
|
|
1708
|
+
continue # 这一路彻底失败(无成品也不够长)
|
|
1709
|
+
if not ok_text:
|
|
1710
|
+
continue
|
|
1711
|
+
cj_map, sc, sids2 = run_critique(
|
|
1712
|
+
txt, 1, note_extra="(本稿为同章赛马变体 %d/%d,只评这一份)" % (kk + 1, len(pool)))
|
|
1713
|
+
m = means_of(cj_map)
|
|
1714
|
+
avg = round(sum(m.values()) / max(1, len(m)), 2) if m else 0.0
|
|
1715
|
+
scored_variants.append({"variant": kk, "agent": agent.get("id"),
|
|
1716
|
+
"file": vfile, "means": m, "avg": avg,
|
|
1717
|
+
"cj": cj_map, "scored": sc, "sids": sids2})
|
|
1718
|
+
if not scored_variants:
|
|
1719
|
+
store.update_run(run_id, status="failed",
|
|
1720
|
+
error="第 %d 章赛马全部变体起草失败" % i, ended_at=_now())
|
|
1721
|
+
return
|
|
1722
|
+
scored_variants.sort(key=lambda v: (-v["avg"], v["variant"]))
|
|
1723
|
+
win = scored_variants[0]
|
|
1724
|
+
# 收敛:赢家转正,败稿删除;胜者评审结果直接作为第 1 轮(不重评)
|
|
1725
|
+
if win["file"] != ch_file:
|
|
1726
|
+
try:
|
|
1727
|
+
os.replace(os.path.join(workdir, win["file"]),
|
|
1728
|
+
os.path.join(workdir, ch_file))
|
|
1729
|
+
except OSError as e:
|
|
1730
|
+
store.update_run(run_id, status="failed",
|
|
1731
|
+
error="第 %d 章赛马收卷失败: %r" % (i, e), ended_at=_now())
|
|
1732
|
+
return
|
|
1733
|
+
for v in scored_variants[1:]:
|
|
1734
|
+
try:
|
|
1735
|
+
os.remove(os.path.join(workdir, v["file"]))
|
|
1736
|
+
except OSError:
|
|
1737
|
+
pass
|
|
1738
|
+
race_log = [{"variant": v["variant"], "agent": v["agent"], "avg": v["avg"],
|
|
1739
|
+
"chosen": v is win, "reviewed": v["scored"] > 0}
|
|
1740
|
+
for v in scored_variants]
|
|
1741
|
+
latest_run = store.get_run(run_id) or {}
|
|
1742
|
+
all_variants = dict(latest_run.get("variants") or {})
|
|
1743
|
+
all_variants[str(i)] = race_log
|
|
1744
|
+
store.update_run(run_id, variants=all_variants)
|
|
1745
|
+
if win["scored"] > 0:
|
|
1746
|
+
race_cj, race_scored = win["cj"], win["scored"]
|
|
1747
|
+
critic_sids.update(win["sids"])
|
|
1748
|
+
# revise 会话沿用变体 0(本任作者)的会话;那一路失败则留空
|
|
1749
|
+
r0 = (results.get(0) or (None, None, None))[2] or {}
|
|
1750
|
+
draft_sid = _resume_sid(impl, r0.get("sid")) or ""
|
|
1751
|
+
|
|
1752
|
+
# 复用章且上一遍已有评审分数 → 直接沿用,不再重评
|
|
1753
|
+
if reuse and inh_scores.get(i, {}).get("means"):
|
|
1754
|
+
cs = dict(inh_scores[i])
|
|
1755
|
+
cs.setdefault("chapter", i)
|
|
1756
|
+
cs["reused"] = True
|
|
1757
|
+
chapter_scores.append(cs)
|
|
1758
|
+
store.update_run(run_id, chapter_scores=chapter_scores)
|
|
1759
|
+
continue
|
|
1760
|
+
|
|
1761
|
+
for rnd in (1, 2):
|
|
1762
|
+
text = _read_chapter(workdir, i)
|
|
1763
|
+
if rnd == 1 and race_cj is not None:
|
|
1764
|
+
cj_by_agent, scored = race_cj, race_scored
|
|
1765
|
+
else:
|
|
1766
|
+
cj_by_agent, scored, sids_now = run_critique(text, rnd, critic_sids=critic_sids)
|
|
1767
|
+
critic_sids.update(sids_now)
|
|
1768
|
+
if not scored:
|
|
1769
|
+
# 「评不上」≠「评了 0 分」:全部评审失败时中止本轮,
|
|
1770
|
+
# 让自动续跑换个时机重试,而不是以 0 分误判章稿质量。
|
|
1771
|
+
store.update_run(run_id, status="failed",
|
|
1772
|
+
error="第 %d 章评审全部失败(评审模型不可用或输出不可解析),"
|
|
1773
|
+
"已中止以免以 0 分误判质量" % i, ended_at=_now())
|
|
1774
|
+
return
|
|
1775
|
+
means = means_of(cj_by_agent)
|
|
1776
|
+
passed = bool(means) and all(v >= threshold_ch for v in means.values())
|
|
1777
|
+
if passed or rnd == 2:
|
|
1778
|
+
break
|
|
1779
|
+
# 第 1 轮不达标 → 修订该章后重评审
|
|
1780
|
+
rounds_used = 2
|
|
1781
|
+
majors = [x for x in issues_all if x.get("chapter") == i
|
|
1782
|
+
and x.get("severity") == "major"][:8]
|
|
1783
|
+
crit_lines = ["- %s:%.1f(章阈值 %.1f)" % (d, means[d], threshold_ch) for d in dims]
|
|
1784
|
+
crit_lines += ["- [%s] %s" % (x.get("dim", "?"), str(x.get("note", ""))[:140])
|
|
1785
|
+
for x in majors]
|
|
1786
|
+
if impl.get("mode") == "mock":
|
|
1787
|
+
step, log_abs = store.add_step(run_id, "revise-c%d" % i, impl["id"],
|
|
1788
|
+
impl.get("label"))
|
|
1789
|
+
_write_chapter(workdir, i, mocks.draft_manuscript(
|
|
1790
|
+
{"title": ch["title"], "goal": task["goal"]}, 2))
|
|
1791
|
+
time.sleep(0.15)
|
|
1792
|
+
store.finish_step(run_id, step["n"], "done",
|
|
1793
|
+
summary="(mock)已按评审意见修订第 %d 章" % i, duration_s=0.15)
|
|
1794
|
+
else:
|
|
1795
|
+
prompt = (SERIAL_REVISE_PROMPT
|
|
1796
|
+
.replace("__I__", str(i)).replace("__FILE__", ch_file)
|
|
1797
|
+
.replace("__GOAL__", task["goal"])
|
|
1798
|
+
.replace("__CRITIQUE__", "\n".join(crit_lines))
|
|
1799
|
+
.replace("__WORDS__", str(wpc)))
|
|
1800
|
+
_run_step(run_id, "revise-c%d" % i, modelhub.bind_agent(impl, difficulty), prompt,
|
|
1801
|
+
step_wd, readonly=False, ev=ev, timeout=2400,
|
|
1802
|
+
resume=resume_ctx["session"] if resume_ctx else draft_sid)
|
|
1803
|
+
_check_cancel(ev)
|
|
1804
|
+
chapter_scores.append({"chapter": i, "title": ch["title"], "means": means,
|
|
1805
|
+
"passed": bool(means) and all(v >= threshold_ch for v in means.values()),
|
|
1806
|
+
"rounds": rounds_used,
|
|
1807
|
+
"words": _wc(_read_chapter(workdir, i))})
|
|
1808
|
+
# 每章即时持久化:长篇中断/超时后可断点续跑,不丢已完成章的分数
|
|
1809
|
+
store.update_run(run_id, chapter_scores=chapter_scores)
|
|
1810
|
+
|
|
1811
|
+
# ---- 3) 全局一致性评审(覆盖 1..end 全书:续写批次必须连同旧章一起查一致性)
|
|
1812
|
+
full_text = "\n\n".join(_read_chapter(workdir, i) for i in range(1, end + 1))
|
|
1813
|
+
global_means, global_issues = {}, []
|
|
1814
|
+
for agent in critics:
|
|
1815
|
+
role = "global-critique"
|
|
1816
|
+
if agent.get("mode") == "mock":
|
|
1817
|
+
step, _ = store.add_step(run_id, role, agent["id"], agent.get("label"))
|
|
1818
|
+
time.sleep(0.15)
|
|
1819
|
+
gj = {"scores": {d: 8.0 for d in dims},
|
|
1820
|
+
"issues": [], "summary": "(mock)全书结构完整,达到可签约水平"}
|
|
1821
|
+
store.finish_step(run_id, step["n"], "done", summary="均分 8.0:(mock)全书达标",
|
|
1822
|
+
duration_s=0.15)
|
|
1823
|
+
else:
|
|
1824
|
+
gtpl = SERIAL_GLOBAL_PROMPT
|
|
1825
|
+
if bible:
|
|
1826
|
+
gtpl = gtpl.replace("## 全书目标", bible + "\n\n## 全书目标", 1)
|
|
1827
|
+
res = _run_step(run_id, role, modelhub.bind_agent(agent, difficulty),
|
|
1828
|
+
(gtpl.replace("__DIMKEYS__", dimkey)
|
|
1829
|
+
.replace("__GOAL__", task["goal"])
|
|
1830
|
+
.replace("__MANUSCRIPT__", full_text[:60000])),
|
|
1831
|
+
workdir, readonly=True, ev=ev, timeout=2400)
|
|
1832
|
+
gj = runner.extract_json(res.get("text") or "")
|
|
1833
|
+
if not isinstance(gj, dict) or not isinstance(gj.get("scores"), dict):
|
|
1834
|
+
gj = {"scores": {}, "issues": [], "summary": "全局评审输出无法解析"}
|
|
1835
|
+
global_issues.extend({"chapter": "全书", **it} for it in (gj.get("issues") or [])[:8])
|
|
1836
|
+
for d in dims:
|
|
1837
|
+
v = gj.get("scores", {}).get(d)
|
|
1838
|
+
if v is not None:
|
|
1839
|
+
global_means.setdefault(d, []).append(float(v))
|
|
1840
|
+
_check_cancel(ev)
|
|
1841
|
+
global_means = {d: round(sum(xs) / len(xs), 1) for d, xs in global_means.items()}
|
|
1842
|
+
global_pass = _all_ge(global_means, threshold)
|
|
1843
|
+
|
|
1844
|
+
# ---- 3.5) 自驱打磨:全局评审不过 → 自动重改最弱章并重评(至多 2 轮,无需人工)
|
|
1845
|
+
polish_rounds = 0
|
|
1846
|
+
while (not global_pass) and polish_rounds < 2 and chapter_scores:
|
|
1847
|
+
polish_rounds += 1
|
|
1848
|
+
weak = _weakest_chapters(chapter_scores, global_means, threshold, limit=2)
|
|
1849
|
+
if not weak:
|
|
1850
|
+
break
|
|
1851
|
+
note = "自动打磨第 %d 轮:全局评审未过,重改最弱章 %s" % (
|
|
1852
|
+
polish_rounds, "、".join("第 %d 章" % c["chapter"] for c in weak))
|
|
1853
|
+
pstep, _ = store.add_step(run_id, "polish-r%d" % polish_rounds, impl["id"],
|
|
1854
|
+
impl.get("label"), note=note)
|
|
1855
|
+
fixed = []
|
|
1856
|
+
for c in weak:
|
|
1857
|
+
i = c["chapter"]
|
|
1858
|
+
ch = outline["chapters"][i - start] # outline 是本批的:按批内下标取,i 是全书章号
|
|
1859
|
+
dims_txt = ";".join(
|
|
1860
|
+
"%s %.1f" % (d, (c.get("means") or {}).get(d, 0.0))
|
|
1861
|
+
for d in dims if float((c.get("means") or {}).get(d, 0.0)) < threshold)
|
|
1862
|
+
gj = ";".join("%s %.1f" % (d, s2) for d, s2 in global_means.items()
|
|
1863
|
+
if float(s2) < threshold)
|
|
1864
|
+
crit = ("- 本章维度不达标:%s(阈值 %.1f)\n- 全书一致性评审指出的短板:%s"
|
|
1865
|
+
"\n- 重改要求:优先修全书节奏/衔接问题(章间过渡、信息倾泻、主角主动性),"
|
|
1866
|
+
"再补本章短板;不得改动既有剧情主线的关键事实。"
|
|
1867
|
+
% (dims_txt or "(无)", threshold, gj or "(无)"))
|
|
1868
|
+
if impl.get("mode") == "mock":
|
|
1869
|
+
_write_chapter(workdir, i, mocks.draft_manuscript(
|
|
1870
|
+
{"title": ch["title"], "goal": task["goal"]}, 3))
|
|
1871
|
+
else:
|
|
1872
|
+
prompt = (SERIAL_REVISE_PROMPT
|
|
1873
|
+
.replace("__I__", str(i)).replace("__FILE__", "chapter-%02d.md" % i)
|
|
1874
|
+
.replace("__GOAL__", task["goal"])
|
|
1875
|
+
.replace("__CRITIQUE__", crit)
|
|
1876
|
+
.replace("__WORDS__", str(wpc)))
|
|
1877
|
+
res = _run_step(run_id, "polish-c%d" % i, modelhub.bind_agent(impl, difficulty),
|
|
1878
|
+
prompt, workdir, readonly=False, ev=ev, timeout=2400,
|
|
1879
|
+
resume=resume_ctx["session"] if resume_ctx else None)
|
|
1880
|
+
if not res["ok"]:
|
|
1881
|
+
continue
|
|
1882
|
+
# 重评该章
|
|
1883
|
+
cj_by_agent = {}
|
|
1884
|
+
for agent in critics:
|
|
1885
|
+
if agent.get("mode") == "mock":
|
|
1886
|
+
cj = mocks.critique(agent["id"], 3, dims, threshold_ch)
|
|
1887
|
+
else:
|
|
1888
|
+
res2 = _run_step(run_id, "critique-c%d" % i, modelhub.bind_agent(agent, difficulty),
|
|
1889
|
+
crit_prompt_for(_read_chapter(workdir, i), note="小说第 %d 章(打磨后)" % i),
|
|
1890
|
+
workdir, readonly=True, ev=ev)
|
|
1891
|
+
cj = runner.extract_json(res2.get("text") or "")
|
|
1892
|
+
if not isinstance(cj, dict) or not isinstance(cj.get("scores"), dict):
|
|
1893
|
+
cj = {"scores": {}, "issues": [], "summary": "评审输出无法解析"}
|
|
1894
|
+
cj_by_agent[agent["id"]] = cj
|
|
1895
|
+
_check_cancel(ev)
|
|
1896
|
+
vals = {}
|
|
1897
|
+
for d in dims:
|
|
1898
|
+
xs = [float(cj["scores"].get(d, 0)) for cj in cj_by_agent.values()
|
|
1899
|
+
if isinstance(cj.get("scores"), dict) and d in cj["scores"]]
|
|
1900
|
+
vals[d] = round(sum(xs) / len(xs), 1) if xs else 0.0
|
|
1901
|
+
for c2 in chapter_scores:
|
|
1902
|
+
if c2["chapter"] == i:
|
|
1903
|
+
c2["means"] = vals
|
|
1904
|
+
c2["passed"] = bool(vals) and all(v >= threshold_ch for v in vals.values())
|
|
1905
|
+
c2["rounds"] = int(c2.get("rounds") or 1) + 1
|
|
1906
|
+
c2["polished"] = True
|
|
1907
|
+
c2["words"] = _wc(_read_chapter(workdir, i))
|
|
1908
|
+
fixed.append(i)
|
|
1909
|
+
store.update_run(run_id, chapter_scores=chapter_scores)
|
|
1910
|
+
_check_cancel(ev)
|
|
1911
|
+
# 重评全书一致性
|
|
1912
|
+
full_text = "\n\n".join(_read_chapter(workdir, i2) for i2 in range(1, end + 1))
|
|
1913
|
+
gmeans, gissues = {}, []
|
|
1914
|
+
for agent in critics:
|
|
1915
|
+
if agent.get("mode") == "mock":
|
|
1916
|
+
gj2 = {"scores": {d: 8.0 for d in dims}, "issues": [],
|
|
1917
|
+
"summary": "(mock)打磨后全书达标"}
|
|
1918
|
+
else:
|
|
1919
|
+
res3 = _run_step(run_id, "global-critique", modelhub.bind_agent(agent, difficulty),
|
|
1920
|
+
(SERIAL_GLOBAL_PROMPT.replace("__DIMKEYS__", dimkey)
|
|
1921
|
+
.replace("__GOAL__", task["goal"])
|
|
1922
|
+
.replace("__MANUSCRIPT__", full_text[:60000])),
|
|
1923
|
+
workdir, readonly=True, ev=ev, timeout=2400)
|
|
1924
|
+
gj2 = runner.extract_json(res3.get("text") or "")
|
|
1925
|
+
if not isinstance(gj2, dict) or not isinstance(gj2.get("scores"), dict):
|
|
1926
|
+
gj2 = {"scores": {}, "issues": [], "summary": "全局评审输出无法解析"}
|
|
1927
|
+
global_issues.extend({"chapter": "全书", **it} for it in (gj2.get("issues") or [])[:8])
|
|
1928
|
+
for d in dims:
|
|
1929
|
+
v = gj2.get("scores", {}).get(d)
|
|
1930
|
+
if v is not None:
|
|
1931
|
+
gmeans.setdefault(d, []).append(float(v))
|
|
1932
|
+
_check_cancel(ev)
|
|
1933
|
+
global_means = {d: round(sum(xs) / len(xs), 1) for d, xs in gmeans.items()}
|
|
1934
|
+
global_pass = _all_ge(global_means, threshold)
|
|
1935
|
+
store.finish_step(run_id, pstep["n"], "done" if global_pass else "failed",
|
|
1936
|
+
summary="重改 %s;打磨后全局 %s(%s)" % (
|
|
1937
|
+
"、".join("第 %d 章" % x for x in fixed),
|
|
1938
|
+
"、".join("%s %.1f" % (d, global_means.get(d, 0.0)) for d in dims),
|
|
1939
|
+
"通过" if global_pass else "仍未通过"),
|
|
1940
|
+
duration_s=1.0)
|
|
1941
|
+
|
|
1942
|
+
# ---- 4) 合并成书
|
|
1943
|
+
step, _ = store.add_step(run_id, "merge", "builtin", "内置合成器")
|
|
1944
|
+
ms_name = _ms_name(task.get("manuscript"))
|
|
1945
|
+
book_title = outline.get("book_title") or task["title"]
|
|
1946
|
+
parts = ["# %s" % book_title, ""]
|
|
1947
|
+
for i in range(1, end + 1): # 合并全书:续写时包含上一批已写好的章
|
|
1948
|
+
parts.append(_read_chapter(workdir, i).strip())
|
|
1949
|
+
parts.append("")
|
|
1950
|
+
with _ms_io(workdir, ms_name, "w") as f:
|
|
1951
|
+
f.write("\n".join(parts))
|
|
1952
|
+
total_words = _wc("\n".join(parts))
|
|
1953
|
+
store.finish_step(run_id, step["n"], "done",
|
|
1954
|
+
summary="已合并 %d 章为 %s(约 %d 字)" % (n, ms_name, total_words),
|
|
1955
|
+
duration_s=0.1)
|
|
1956
|
+
|
|
1957
|
+
chapters_pass = all(c["passed"] for c in chapter_scores)
|
|
1958
|
+
publishable = bool(chapters_pass and global_pass)
|
|
1959
|
+
overall = round(sum(sum(c["means"].values()) / max(1, len(c["means"]))
|
|
1960
|
+
for c in chapter_scores) / max(1, len(chapter_scores)), 1)
|
|
1961
|
+
verdict = {
|
|
1962
|
+
"type": task["type"], "engine": "review", "serial": True,
|
|
1963
|
+
"publishable": publishable, "overall": overall, "mode": mode,
|
|
1964
|
+
"threshold": threshold, "chapters_used": n,
|
|
1965
|
+
"start_chapter": start, "end_chapter": end, "total_words": total_words,
|
|
1966
|
+
"chapter_scores": chapter_scores, "global_scores": global_means,
|
|
1967
|
+
"global_pass": global_pass, "route": route,
|
|
1968
|
+
}
|
|
1969
|
+
scope_txt = ("续写第 %d–%d 章,衔接前文 %d 章" % (start, end, start - 1)) if start > 1 \
|
|
1970
|
+
else ("共 %d 章" % n)
|
|
1971
|
+
lines = ["# 连载小说评审报告:%s" % task["title"], "",
|
|
1972
|
+
"- 书名:%s(%s / 约 %d 字,合并为 `%s`)" % (
|
|
1973
|
+
book_title, scope_txt, total_words, ms_name),
|
|
1974
|
+
"- 结论:**%s**(各章门禁 %s / 全局评审 %s)" % (
|
|
1975
|
+
"✅ 达到发布标准" if publishable else "❌ 未达标",
|
|
1976
|
+
"通过" if chapters_pass else "未通过",
|
|
1977
|
+
"通过" if global_pass else "未通过"),
|
|
1978
|
+
"- 编排模式:%s 作者:%s 评审组:%s" % (
|
|
1979
|
+
"智能" if mode == "auto" else "手动",
|
|
1980
|
+
impl.get("label"), "、".join(a.get("label") for a in critics)),
|
|
1981
|
+
"", "## 各章得分(章阈值 %.1f)" % threshold_ch, "",
|
|
1982
|
+
"| 章 | 标题 | " + " | ".join(dims) + " | 均分 | 达标 | 轮次 | 字数 |",
|
|
1983
|
+
"|" + "---|" * (len(dims) + 6)]
|
|
1984
|
+
for c in chapter_scores:
|
|
1985
|
+
mean = round(sum(c["means"].values()) / max(1, len(c["means"])), 1) if c["means"] else 0
|
|
1986
|
+
lines.append(("| %d | %s | " % (c["chapter"], c["title"]))
|
|
1987
|
+
+ " | ".join("%.1f" % c["means"].get(d, 0.0) for d in dims)
|
|
1988
|
+
+ " | %.1f | %s | %d | %d |" % (mean, "✓" if c["passed"] else "✗",
|
|
1989
|
+
c["rounds"], c["words"]))
|
|
1990
|
+
lines += ["", "## 全局评审(阈值 %.1f)" % threshold, ""]
|
|
1991
|
+
lines += ["- %s:%.1f" % (d, global_means.get(d, 0.0)) for d in dims]
|
|
1992
|
+
lines += ["", "## 主要问题", ""]
|
|
1993
|
+
majors = [x for x in issues_all + global_issues if x.get("severity") == "major"][:12]
|
|
1994
|
+
if majors:
|
|
1995
|
+
lines.extend("- [第%s章][%s] %s" % (str(x.get("chapter", "?")), x.get("dim", "?"),
|
|
1996
|
+
str(x.get("note", ""))[:150]) for x in majors)
|
|
1997
|
+
else:
|
|
1998
|
+
lines.append("(无 major 问题)")
|
|
1999
|
+
store.write_report(run_id, "\n".join(lines))
|
|
2000
|
+
store.update_run(run_id, status="done", verdict=verdict,
|
|
2001
|
+
summary="连载任务%s(%s,约 %d 字,综合 %.1f)" % (
|
|
2002
|
+
"达标" if publishable else "未达标", scope_txt,
|
|
2003
|
+
total_words, overall),
|
|
2004
|
+
ended_at=_now())
|
|
2005
|
+
|
|
2006
|
+
|
|
2007
|
+
def _run_content_review(run, task, agents, ev, stats, mode):
|
|
2008
|
+
run_id = run["id"]
|
|
2009
|
+
workdir = task["workdir"]
|
|
2010
|
+
ms_name = _ms_name(task.get("manuscript"))
|
|
2011
|
+
dims = task.get("rubric") or DEFAULT_RUBRIC
|
|
2012
|
+
threshold = task.get("threshold", 7.0)
|
|
2013
|
+
rounds = task.get("rounds", 2)
|
|
2014
|
+
route = {}
|
|
2015
|
+
resume_ctx = _valid_resume(task, agents)
|
|
2016
|
+
# 续会话步骤的 CLI 启动目录(稿件读写仍用 workdir)
|
|
2017
|
+
step_wd = _resume_workdir(resume_ctx, workdir) if resume_ctx else workdir
|
|
2018
|
+
difficulty = task.get("difficulty") or (
|
|
2019
|
+
"hard" if threshold >= 8.5 else "easy" if threshold <= 6 else "default")
|
|
2020
|
+
|
|
2021
|
+
# ---- 路由
|
|
2022
|
+
if resume_ctx is not None:
|
|
2023
|
+
impl = resume_ctx["agent"]
|
|
2024
|
+
route["author"] = resume_ctx["note"]
|
|
2025
|
+
elif mode == "manual":
|
|
2026
|
+
impl, _ = _pick_implementer(agents, task.get("implementer"))
|
|
2027
|
+
critics = _pick_critics_manual(agents, task)
|
|
2028
|
+
else:
|
|
2029
|
+
impl, route["author"] = router.pick(agents, "implement", "novel", stats)
|
|
2030
|
+
critics, route["critics"] = router.pick_critics(agents, "novel", stats, impl=impl)
|
|
2031
|
+
if impl is None:
|
|
2032
|
+
store.update_run(run_id, status="failed", error="没有可用智能体", ended_at=_now())
|
|
2033
|
+
return
|
|
2034
|
+
if resume_ctx is not None and mode == "auto":
|
|
2035
|
+
critics, route["critics"] = router.pick_critics(agents, "novel", stats, impl=impl)
|
|
2036
|
+
|
|
2037
|
+
# ---- 规划(小说为模板计划)
|
|
2038
|
+
_wait_gate(run_id, ev)
|
|
2039
|
+
plan = planner.make_novel_plan(_steered_task(run_id, task), impl, critics)
|
|
2040
|
+
store.update_run(run_id, plan=plan, route=route, difficulty=difficulty)
|
|
2041
|
+
|
|
2042
|
+
ms_path = os.path.join(workdir, ms_name)
|
|
2043
|
+
|
|
2044
|
+
def write_ms(text):
|
|
2045
|
+
with _ms_io(workdir, ms_name, "w") as f:
|
|
2046
|
+
f.write(text)
|
|
2047
|
+
|
|
2048
|
+
def read_ms():
|
|
2049
|
+
try:
|
|
2050
|
+
return _read_text_any_enc(ms_path)
|
|
2051
|
+
except Exception:
|
|
2052
|
+
return ""
|
|
2053
|
+
|
|
2054
|
+
draft_note = route.get("author", "") if mode == "auto" else ""
|
|
2055
|
+
|
|
2056
|
+
# 编排者大纲:只对真实执行有意义;失败静默退回无大纲(喂入带指令的任务副本)
|
|
2057
|
+
outline = (planner.make_review_outline(_steered_task(run_id, task))
|
|
2058
|
+
if impl.get("mode") != "mock" else None)
|
|
2059
|
+
if outline:
|
|
2060
|
+
store.update_run(run_id, outline=outline)
|
|
2061
|
+
|
|
2062
|
+
# 1) 起草
|
|
2063
|
+
if impl.get("mode") == "mock":
|
|
2064
|
+
step, log_abs = store.add_step(run_id, "draft", impl["id"], impl.get("label"), note=draft_note)
|
|
2065
|
+
write_ms(mocks.draft_manuscript(task, 1))
|
|
2066
|
+
time.sleep(0.2)
|
|
2067
|
+
store.finish_step(run_id, step["n"], "done", summary="(mock)草稿已写入 %s" % ms_name,
|
|
2068
|
+
duration_s=0.2)
|
|
2069
|
+
try:
|
|
2070
|
+
log_abs.write_text("[mock] 已写入草稿\n", encoding="utf-8")
|
|
2071
|
+
except Exception:
|
|
2072
|
+
pass
|
|
2073
|
+
else:
|
|
2074
|
+
prompt = (_tpl(task, "draft_prompt", NOVEL_DRAFT_PROMPT).replace("__FILE__", ms_name)
|
|
2075
|
+
.replace("__GOAL__", task["goal"])
|
|
2076
|
+
.replace("__CONTEXT__", task.get("context") or "(无)"))
|
|
2077
|
+
if outline:
|
|
2078
|
+
prompt += "\n\n## 编排者大纲(按要点组织稿件)\n" + \
|
|
2079
|
+
"\n".join("- " + i for i in outline["items"])
|
|
2080
|
+
draft_res = _run_step(run_id, "draft", modelhub.bind_agent(impl, difficulty), prompt,
|
|
2081
|
+
step_wd, readonly=False, ev=ev, note=draft_note,
|
|
2082
|
+
resume=resume_ctx["session"] if resume_ctx else None,
|
|
2083
|
+
images=_task_images(task, workdir))
|
|
2084
|
+
if not draft_res["ok"]:
|
|
2085
|
+
store.update_run(run_id, status="failed", error="起草失败: %s" % draft_res.get("error"),
|
|
2086
|
+
ended_at=_now())
|
|
2087
|
+
return
|
|
2088
|
+
|
|
2089
|
+
# 2) 评审-修订循环
|
|
2090
|
+
history_rounds = []
|
|
2091
|
+
publishable = False
|
|
2092
|
+
for r in range(1, rounds + 1):
|
|
2093
|
+
manuscript = read_ms()
|
|
2094
|
+
per_agent, issues_all = {}, []
|
|
2095
|
+
dimkey = ", ".join('"%s": 0' % d for d in dims)
|
|
2096
|
+
crit_prompt = (_ensure_critique_placeholders(
|
|
2097
|
+
_tpl(task, "critique_prompt", NOVEL_CRITIQUE_PROMPT))
|
|
2098
|
+
.replace("__DIMKEYS__", dimkey)
|
|
2099
|
+
.replace("__MANUSCRIPT__", manuscript or "(稿件为空!)"))
|
|
2100
|
+
for agent in critics:
|
|
2101
|
+
role = "critique-r%d" % r
|
|
2102
|
+
if agent.get("mode") == "mock":
|
|
2103
|
+
step, log_abs = store.add_step(run_id, role, agent["id"], agent.get("label"))
|
|
2104
|
+
time.sleep(0.15)
|
|
2105
|
+
cj = mocks.critique(agent["id"], r, dims, threshold)
|
|
2106
|
+
store.finish_step(run_id, step["n"], "done",
|
|
2107
|
+
summary="均分 %.1f:%s" % (
|
|
2108
|
+
sum(cj["scores"].values()) / max(1, len(dims)), cj["summary"]),
|
|
2109
|
+
duration_s=0.15)
|
|
2110
|
+
try:
|
|
2111
|
+
log_abs.write_text("[mock] %s\n" % str(cj), encoding="utf-8")
|
|
2112
|
+
except Exception:
|
|
2113
|
+
pass
|
|
2114
|
+
else:
|
|
2115
|
+
res = _run_step(run_id, role, modelhub.bind_agent(agent, difficulty),
|
|
2116
|
+
crit_prompt, workdir, readonly=True, ev=ev)
|
|
2117
|
+
cj = runner.extract_json(res.get("text") or "")
|
|
2118
|
+
if not isinstance(cj, dict) or not isinstance(cj.get("scores"), dict):
|
|
2119
|
+
cj = {"scores": {}, "issues": [],
|
|
2120
|
+
"summary": "评审输出无法解析:%s" % (res.get("text") or "")[:150]}
|
|
2121
|
+
per_agent[agent["id"]] = cj
|
|
2122
|
+
issues_all.extend(cj.get("issues") or [])
|
|
2123
|
+
_check_cancel(ev)
|
|
2124
|
+
|
|
2125
|
+
means = {}
|
|
2126
|
+
for d in dims:
|
|
2127
|
+
vals = [float(cj["scores"].get(d, 0)) for cj in per_agent.values()
|
|
2128
|
+
if isinstance(cj.get("scores"), dict) and d in cj["scores"]]
|
|
2129
|
+
means[d] = round(sum(vals) / len(vals), 1) if vals else 0.0
|
|
2130
|
+
publishable = bool(means) and all(v >= threshold for v in means.values())
|
|
2131
|
+
history_rounds.append({"round": r, "means": means,
|
|
2132
|
+
"per_agent": {k: v.get("scores", {}) for k, v in per_agent.items()},
|
|
2133
|
+
"issues": issues_all, "passed": publishable})
|
|
2134
|
+
if publishable or r == rounds:
|
|
2135
|
+
break
|
|
2136
|
+
|
|
2137
|
+
# 修订
|
|
2138
|
+
crit_lines = ["- %s:%.1f(阈值 %.1f)" % (d, means[d], threshold) for d in dims]
|
|
2139
|
+
majors = [i for i in issues_all if i.get("severity") == "major"][:8]
|
|
2140
|
+
for i in majors:
|
|
2141
|
+
crit_lines.append("- [%s] %s" % (i.get("dim", "?"), str(i.get("note", ""))[:120]))
|
|
2142
|
+
if impl.get("mode") == "mock":
|
|
2143
|
+
step, log_abs = store.add_step(run_id, "revise-r%d" % r, impl["id"], impl.get("label"))
|
|
2144
|
+
write_ms(mocks.draft_manuscript(task, r + 1))
|
|
2145
|
+
time.sleep(0.2)
|
|
2146
|
+
store.finish_step(run_id, step["n"], "done", summary="(mock)已按评审意见修订",
|
|
2147
|
+
duration_s=0.2)
|
|
2148
|
+
try:
|
|
2149
|
+
log_abs.write_text("[mock] 已修订\n", encoding="utf-8")
|
|
2150
|
+
except Exception:
|
|
2151
|
+
pass
|
|
2152
|
+
else:
|
|
2153
|
+
prompt = (NOVEL_REVISE_PROMPT.replace("__FILE__", ms_name)
|
|
2154
|
+
.replace("__GOAL__", task["goal"])
|
|
2155
|
+
.replace("__CRITIQUE__", "\n".join(crit_lines)))
|
|
2156
|
+
_run_step(run_id, "revise-r%d" % r, modelhub.bind_agent(impl, difficulty), prompt,
|
|
2157
|
+
workdir, readonly=False, ev=ev,
|
|
2158
|
+
resume=resume_ctx["session"] if resume_ctx else None)
|
|
2159
|
+
_check_cancel(ev)
|
|
2160
|
+
|
|
2161
|
+
final_means = history_rounds[-1]["means"] if history_rounds else {}
|
|
2162
|
+
overall = round(sum(final_means.values()) / len(final_means), 1) if final_means else 0.0
|
|
2163
|
+
verdict = {
|
|
2164
|
+
"type": task["type"], "engine": "review", "publishable": publishable,
|
|
2165
|
+
"overall": overall, "mode": mode,
|
|
2166
|
+
"threshold": threshold, "rounds_used": history_rounds[-1]["round"] if history_rounds else 0,
|
|
2167
|
+
"scores": final_means, "history": history_rounds, "route": route,
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
# 3) 报告
|
|
2171
|
+
lines = ["# 评审报告:%s" % task["title"], "",
|
|
2172
|
+
"- 任务类型:%s" % task["type"],
|
|
2173
|
+
"- 结论:**%s**(综合 %.1f / 阈值 %.1f,%d 轮评审)"
|
|
2174
|
+
% ("✅ 达到发布标准" if publishable else "❌ 未达标,建议再修",
|
|
2175
|
+
overall, threshold, verdict["rounds_used"]),
|
|
2176
|
+
"- 编排模式:%s 起草/修订:%s 评审组:%s" % (
|
|
2177
|
+
"智能" if mode == "auto" else "手动",
|
|
2178
|
+
impl.get("label"), "、".join(a.get("label") for a in critics))]
|
|
2179
|
+
if route:
|
|
2180
|
+
lines.append("")
|
|
2181
|
+
lines.append("## 路由依据")
|
|
2182
|
+
lines.extend("- %s:%s" % (k, v) for k, v in route.items() if v)
|
|
2183
|
+
lines += ["", "## 各轮维度均分", "",
|
|
2184
|
+
"| 轮次 | " + " | ".join(dims) + " | 达标 |",
|
|
2185
|
+
"|" + "---|" * (len(dims) + 2)]
|
|
2186
|
+
for h in history_rounds:
|
|
2187
|
+
lines.append("| %d | " % h["round"]
|
|
2188
|
+
+ " | ".join("%.1f" % h["means"].get(d, 0.0) for d in dims)
|
|
2189
|
+
+ " | %s |" % ("✓" if h["passed"] else "✗"))
|
|
2190
|
+
lines += ["", "## 末轮主要问题", ""]
|
|
2191
|
+
last_issues = (history_rounds[-1].get("issues") if history_rounds else []) or []
|
|
2192
|
+
majors = [i for i in last_issues if i.get("severity") == "major"]
|
|
2193
|
+
minors = [i for i in last_issues if i.get("severity") != "major"]
|
|
2194
|
+
if majors:
|
|
2195
|
+
lines.append("**major**")
|
|
2196
|
+
lines.extend("- [%s] %s" % (i.get("dim", "?"), str(i.get("note", ""))[:150]) for i in majors)
|
|
2197
|
+
if minors:
|
|
2198
|
+
lines.append("**minor**")
|
|
2199
|
+
lines.extend("- [%s] %s" % (i.get("dim", "?"), str(i.get("note", ""))[:150]) for i in minors)
|
|
2200
|
+
if not majors and not minors:
|
|
2201
|
+
lines.append("(无)")
|
|
2202
|
+
lines += ["", "## 稿件位置", "", "`%s`" % ms_path, ""]
|
|
2203
|
+
store.write_report(run_id, "\n".join(lines))
|
|
2204
|
+
store.update_run(run_id, status="done", verdict=verdict,
|
|
2205
|
+
summary="评审任务%s(综合 %.1f)" % ("达标" if publishable else "未达标", overall),
|
|
2206
|
+
ended_at=_now())
|
|
2207
|
+
|
|
2208
|
+
|
|
2209
|
+
# ---------------------------------------------------------------- 入口
|
|
2210
|
+
|
|
2211
|
+
def execute_run(run_id):
|
|
2212
|
+
run = store.get_run(run_id)
|
|
2213
|
+
if not run:
|
|
2214
|
+
return
|
|
2215
|
+
ev = jobs.cancel_event_for(run_id)
|
|
2216
|
+
if run.get("cancelled_by_user"):
|
|
2217
|
+
# 排队期间被取消:事件可能已置位,也可能只在 run 上留了标记——
|
|
2218
|
+
# 双保险兜底,任务标记已落,失败收尾照常走(不会被自动续跑复活)。
|
|
2219
|
+
ev.set()
|
|
2220
|
+
store.update_run(run_id, status="cancelled", ended_at=_now(),
|
|
2221
|
+
error="排队期间被取消")
|
|
2222
|
+
return
|
|
2223
|
+
task = store.get_task(run.get("task_id"))
|
|
2224
|
+
store.update_run(run_id, status="running", started_at=_now())
|
|
2225
|
+
if task is None:
|
|
2226
|
+
store.update_run(run_id, status="failed", error="找不到任务 %s" % run.get("task_id"),
|
|
2227
|
+
ended_at=_now())
|
|
2228
|
+
return
|
|
2229
|
+
# 代码版本检出:任务指定了基线版本时,先检出任务分支 tutti/<task-id> 再跑流水线。
|
|
2230
|
+
# 显式意图不容静默降级——仓库缺失/脏工作区/引用不存在一律中止运行并报错,
|
|
2231
|
+
# 绝不带着用户未提交改动切分支、也不悄悄退回当前 HEAD。
|
|
2232
|
+
git_ctx = None
|
|
2233
|
+
if task.get("git_rev"):
|
|
2234
|
+
from . import gitmod
|
|
2235
|
+
ok, err, gitinfo = gitmod.prepare_checkout(
|
|
2236
|
+
task["workdir"], task["git_rev"], task["id"])
|
|
2237
|
+
if not ok:
|
|
2238
|
+
store.update_run(run_id, status="failed", error="代码版本检出失败:%s" % err,
|
|
2239
|
+
ended_at=_now())
|
|
2240
|
+
return
|
|
2241
|
+
git_ctx = gitinfo
|
|
2242
|
+
store.update_run(run_id, git=gitinfo)
|
|
2243
|
+
# 任务分支裁决状态:新一轮 run 产生新分支内容,重置回「待裁决」
|
|
2244
|
+
store.set_task_git_state(task["id"], "isolated")
|
|
2245
|
+
agents = _agents()
|
|
2246
|
+
# 续会话是对该 CLI 的显式指定:目标未启用编排时也注入本次运行(不影响路由池)
|
|
2247
|
+
want = ((task.get("resume") or {}).get("agent") or "").strip()
|
|
2248
|
+
if want and _pick(agents, want) is None:
|
|
2249
|
+
extra = registry.installed_agent(want, catalog.load(), manager.detect_all())
|
|
2250
|
+
if extra:
|
|
2251
|
+
agents.append(extra)
|
|
2252
|
+
stats = history.agent_stats()
|
|
2253
|
+
mode = task.get("mode") or ("manual" if task.get("implementer") else "auto")
|
|
2254
|
+
store.update_run(run_id, mode=mode)
|
|
2255
|
+
# engine 决定流水线:code=实现/验证/评审/修复;review=起草/多维评审/修订/门禁;
|
|
2256
|
+
# direct=单 CLI 直达(无拆解/评审,信箱续轮即对话)
|
|
2257
|
+
engine = task.get("engine") or ("code" if task["type"] == "code" else "review")
|
|
2258
|
+
try:
|
|
2259
|
+
if engine == "code":
|
|
2260
|
+
_run_code(run, task, agents, ev, stats, mode)
|
|
2261
|
+
elif engine == "direct":
|
|
2262
|
+
_run_direct(run, task, agents, ev, stats, mode)
|
|
2263
|
+
else:
|
|
2264
|
+
route = {}
|
|
2265
|
+
resume_ctx = _valid_resume(task, agents)
|
|
2266
|
+
difficulty = task.get("difficulty") or (
|
|
2267
|
+
"hard" if (task.get("threshold") or 7.0) >= 8.5 else
|
|
2268
|
+
"easy" if (task.get("threshold") or 7.0) <= 6 else "default")
|
|
2269
|
+
# 路由(与单稿件评审一致的规则)
|
|
2270
|
+
if resume_ctx is not None:
|
|
2271
|
+
impl = resume_ctx["agent"]
|
|
2272
|
+
route["author"] = resume_ctx["note"]
|
|
2273
|
+
elif mode == "manual":
|
|
2274
|
+
impl, _ = _pick_implementer(agents, task.get("implementer"))
|
|
2275
|
+
critics = _pick_critics_manual(agents, task)
|
|
2276
|
+
else:
|
|
2277
|
+
impl, route["author"] = router.pick(agents, "implement", task["type"], stats)
|
|
2278
|
+
critics, route["critics"] = router.pick_critics(agents, task["type"], stats, impl=impl)
|
|
2279
|
+
if impl is None:
|
|
2280
|
+
store.update_run(run_id, status="failed", error="没有可用智能体", ended_at=_now())
|
|
2281
|
+
return
|
|
2282
|
+
if resume_ctx is not None and mode == "auto":
|
|
2283
|
+
critics, route["critics"] = router.pick_critics(agents, task["type"], stats, impl=impl)
|
|
2284
|
+
if task.get("serial"):
|
|
2285
|
+
_run_serial_review(run, task, agents, ev, stats, mode,
|
|
2286
|
+
critics, impl, route, resume_ctx, difficulty)
|
|
2287
|
+
else:
|
|
2288
|
+
_run_content_review(run, task, agents, ev, stats, mode)
|
|
2289
|
+
except Cancelled:
|
|
2290
|
+
store.update_run(run_id, status="cancelled", ended_at=_now())
|
|
2291
|
+
except Exception as e:
|
|
2292
|
+
import traceback
|
|
2293
|
+
store.update_run(run_id, status="failed", error=repr(e)[:500],
|
|
2294
|
+
ended_at=_now())
|
|
2295
|
+
try:
|
|
2296
|
+
err_path = store.run_dir(run_id) / "error.log"
|
|
2297
|
+
if _inside(str(store.run_dir(run_id).parent), str(err_path)):
|
|
2298
|
+
err_path.write_text(traceback.format_exc(), encoding="utf-8")
|
|
2299
|
+
except Exception:
|
|
2300
|
+
pass
|
|
2301
|
+
finally:
|
|
2302
|
+
# 任务分支收尾(git_rev 隔离链的第二半):先只读快照本 run 的全部变更
|
|
2303
|
+
# 落 run 记录供人审,再把产物提交到 tutti/<task-id> 并切回原分支。
|
|
2304
|
+
# 放 finally:done/failed/cancelled/异常一律保存现场;收尾自身绝不抛错,
|
|
2305
|
+
# 问题记入 run.git.restore_error,不覆盖 run 的最终结论。
|
|
2306
|
+
if git_ctx is not None:
|
|
2307
|
+
from . import gitmod
|
|
2308
|
+
try:
|
|
2309
|
+
store.update_run(run_id, changes=gitmod.collect_changes(task["workdir"]))
|
|
2310
|
+
fin = gitmod.finalize_run(
|
|
2311
|
+
task["workdir"], git_ctx,
|
|
2312
|
+
"tutti %s: %s(%s)" % (run_id, task.get("title") or task["goal"][:40],
|
|
2313
|
+
(store.get_run(run_id) or {}).get("status") or "?"))
|
|
2314
|
+
store.update_run(run_id, git={**git_ctx, **fin})
|
|
2315
|
+
except Exception as e:
|
|
2316
|
+
try:
|
|
2317
|
+
store.update_run(run_id, git={**git_ctx,
|
|
2318
|
+
"restore_error": repr(e)[:200]})
|
|
2319
|
+
except Exception:
|
|
2320
|
+
pass
|
|
2321
|
+
# 自学习闭环:运行结束自动把本次评审暴露的问题沉淀为可复用教训(异步,不阻塞)
|
|
2322
|
+
try:
|
|
2323
|
+
if store.get_run(run_id):
|
|
2324
|
+
skills.learn_async(run_id)
|
|
2325
|
+
except Exception:
|
|
2326
|
+
pass
|