codebee 0.1.52 → 0.1.54
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 +16 -0
- package/README.md +4 -3
- package/app/core/automation.py +57 -3
- package/app/core/board.py +209 -0
- package/app/core/branching.py +105 -0
- package/app/core/chaptersafe.py +32 -0
- package/app/core/jobs.py +1 -1
- package/app/core/pipeline.py +111 -7
- package/app/core/runner.py +146 -49
- package/app/core/share_page.py +4 -1
- package/app/core/store.py +13 -1
- package/app/main.py +4 -1
- package/app/ui/app.js +13403 -13321
- package/app/ui/board.css +292 -0
- package/app/ui/board.html +84 -0
- package/app/ui/board.js +259 -0
- package/app/ui/i18n.js +24 -0
- package/app/ui/index.html +2 -0
- package/app/ui/style.css +17 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -23,6 +23,22 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
|
|
|
23
23
|
|
|
24
24
|
- **首选 KEY 超限自动切备用了**:同厂商多密钥此前只认欠费类文案——首选 KEY 撞上 429 限流(智谱「并发数超过限制」「每分钟Token数已超过上限」、claude 的 rate_limit_error、codex 的 "429 Too Many Requests" 等)只记一笔错误、不进冷却,每个新步骤仍从超限的首选 KEY 重新烧起。现在限流文案与欠费同口径:进冷却 30 分钟、密钥镜像自动切下一把、链展开跳过冷却中的 KEY(到期或「恢复」按钮一键回首选)。顺带补一处漏账:无显式链时「换模型回退」的链条目不带 KEY 标记,这类重试失败此前完全不记 KEY 账。
|
|
25
25
|
|
|
26
|
+
## v0.1.54(2026-09-23)
|
|
27
|
+
|
|
28
|
+
- 任务驾驶舱大屏:/board.html 独立页 + /api/board 聚合端点——模型/厂商分布、实时思考流、排队滚动,中英双语
|
|
29
|
+
- 连载章节安全落盘:临时文件 + 原子改名,半截稿不再污染章节文件
|
|
30
|
+
- 日志抽屉一键复制全文(报错 JSON 不再手动圈选)
|
|
31
|
+
- 删除/归档乐观清场:SSE 半死时已删/已归档任务不再挂侧栏
|
|
32
|
+
- 卡死的模型调用快速失败转投,不再耗满超时
|
|
33
|
+
- run_end 钩子新增「完成后 AI 复查并自动提交推送」:AI 复查 diff 判 OK 才 commit+push
|
|
34
|
+
- 蜂巢卡显示当前使用模型 + 运行中思考过程行
|
|
35
|
+
|
|
36
|
+
## v0.1.53(2026-09-23)
|
|
37
|
+
|
|
38
|
+
- 换将救活两类「假终态」:上游内容拒答("can't help with this" 一类 refusal)与真超时,现在都会换绑定链上下一个候选再试——此前拒答直接判死整轮任务(链上异上游候选从未被尝试),超时能不能换全凭输出尾段碰巧带 "timeout" 字样;真实案:夜间知识库整理任务被网关 gemini 拒答后整 run 白烧
|
|
39
|
+
- 同一慢上游连烧止损:链上候选连续两次相同超时即收手(换将=换壳不换命),最坏从 N×单步超时压到 2×;取消显式列为终态绝不降级
|
|
40
|
+
- 网关模型漂移可见:请求 A 模型被网关实际映射成 B 模型时(真实案:请求 deepseek-v4.1-flash 实服 gemini-3.8-flash),失败信息与步骤日志明确标注「请求 X,上游实服 Y」,排查看对病根
|
|
41
|
+
|
|
26
42
|
## v0.1.52(2026-09-22)
|
|
27
43
|
|
|
28
44
|
- 手动指定执行者优先级全面收口:用户点名的 CLI 压过一切自动选择——路由不换将、旁观不抢占、绑定不覆盖;「手动指定执行者」作为一等偏好随任务走
|
package/README.md
CHANGED
|
@@ -21,10 +21,11 @@ Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness……),提供
|
|
|
21
21
|
不会把你的任务内容交给任何第三方。
|
|
22
22
|
|
|
23
23
|
<!-- relnotes:start -->
|
|
24
|
-
### 最新版更新内容(v0.1.
|
|
24
|
+
### 最新版更新内容(v0.1.54)
|
|
25
25
|
|
|
26
|
-
-
|
|
27
|
-
-
|
|
26
|
+
- 新增任务驾驶舱大屏(/board.html):模型/厂商分布、实时思考流、排队滚动
|
|
27
|
+
- 章节原子落盘;日志一键复制;删除/归档乐观清场;卡死调用快速转投
|
|
28
|
+
- run_end 钩子支持「AI 复查后自动提交推送」;蜂巢卡显示当前模型与思考流
|
|
28
29
|
<!-- relnotes:end -->
|
|
29
30
|
|
|
30
31
|
---
|
package/app/core/automation.py
CHANGED
|
@@ -65,7 +65,7 @@ _DEFAULTS = {"id": "", "name": "", "prompt": "", "workdir": "", "kind": "", "tim
|
|
|
65
65
|
"mode": "auto", "thinking": "standard",
|
|
66
66
|
"direct_provider_id": "", "direct_model": "",
|
|
67
67
|
"enabled": False, "created_at": "", "last_run": "", "next_run": "",
|
|
68
|
-
"run_count": 0, "last_status": ""}
|
|
68
|
+
"run_count": 0, "last_status": "", "last_run_id": ""}
|
|
69
69
|
|
|
70
70
|
# 允许通过 update() 修改的字段(id/created_at/run_count 等运行痕迹不可改)
|
|
71
71
|
_UPDATABLE = ("name", "prompt", "workdir", "kind", "time", "interval_hours",
|
|
@@ -386,8 +386,9 @@ def _fire(snapshot, now):
|
|
|
386
386
|
"""触发一个到期任务:拉起运行并推进 last_run/next_run/run_count/last_status。
|
|
387
387
|
once 触发完自动停用。拉起失败只记 error,next_run 照常推进(下个周期重试)。"""
|
|
388
388
|
status = "started"
|
|
389
|
+
run_id = ""
|
|
389
390
|
try:
|
|
390
|
-
_launch_run(snapshot)
|
|
391
|
+
run_id = _launch_run(snapshot) or ""
|
|
391
392
|
except Exception as e:
|
|
392
393
|
log.warning("automation: 定时触发 %s 失败: %r", snapshot.get("id"), e)
|
|
393
394
|
status = "error"
|
|
@@ -398,6 +399,8 @@ def _fire(snapshot, now):
|
|
|
398
399
|
cur["last_run"] = _fmt_dt(now)
|
|
399
400
|
cur["run_count"] = int(cur.get("run_count") or 0) + 1
|
|
400
401
|
cur["last_status"] = status
|
|
402
|
+
if run_id:
|
|
403
|
+
cur["last_run_id"] = run_id
|
|
401
404
|
if cur.get("kind") == "once":
|
|
402
405
|
cur["enabled"] = False
|
|
403
406
|
cur["next_run"] = ""
|
|
@@ -408,6 +411,49 @@ def _fire(snapshot, now):
|
|
|
408
411
|
|
|
409
412
|
# ---------------------------------------------------------------- 调度线程
|
|
410
413
|
|
|
414
|
+
def _default_run_status(rid):
|
|
415
|
+
"""按 run_id 查运行状态(终态回写用);查不到返回 None。"""
|
|
416
|
+
from . import store
|
|
417
|
+
try:
|
|
418
|
+
r = store.get_run(rid)
|
|
419
|
+
except Exception:
|
|
420
|
+
return None
|
|
421
|
+
return (r or {}).get("status") or None
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# 测试可替换的查态钩子
|
|
425
|
+
_RUN_STATUS = _default_run_status
|
|
426
|
+
|
|
427
|
+
_RUN_TERMINAL = ("done", "failed", "cancelled")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _reconcile_statuses():
|
|
431
|
+
"""把 last_status 停在 started 的任务对账到真实运行终态。此前触发后永不
|
|
432
|
+
回写,任务卡片永远显示「运行中」(2026-09-23 实案:三个任务全部如此)。
|
|
433
|
+
store 查不到或还没到终态就保持原样,下一轮 tick 再看。"""
|
|
434
|
+
with _LOCK:
|
|
435
|
+
pending = {tid: t.get("last_run_id") for tid, t in _TASKS.items()
|
|
436
|
+
if t.get("last_run_id") and t.get("last_status") == "started"}
|
|
437
|
+
if not pending:
|
|
438
|
+
return
|
|
439
|
+
settled = {}
|
|
440
|
+
for tid, rid in pending.items():
|
|
441
|
+
st = _RUN_STATUS(rid)
|
|
442
|
+
if st in _RUN_TERMINAL:
|
|
443
|
+
settled[tid] = st
|
|
444
|
+
if not settled:
|
|
445
|
+
return
|
|
446
|
+
with _LOCK:
|
|
447
|
+
dirty = False
|
|
448
|
+
for tid, st in settled.items():
|
|
449
|
+
t = _TASKS.get(tid)
|
|
450
|
+
if (t and t.get("last_run_id") == pending[tid]
|
|
451
|
+
and t.get("last_status") == "started"):
|
|
452
|
+
t["last_status"] = st
|
|
453
|
+
dirty = True
|
|
454
|
+
if dirty:
|
|
455
|
+
_save_locked()
|
|
456
|
+
|
|
411
457
|
def _tick():
|
|
412
458
|
"""扫描一遍到期任务并逐个触发。单任务异常只跳过该条,绝不上抛。"""
|
|
413
459
|
now = datetime.now()
|
|
@@ -429,6 +475,11 @@ def _tick():
|
|
|
429
475
|
_fire(dict(t), now)
|
|
430
476
|
except Exception:
|
|
431
477
|
log.exception("automation: tick 处理任务 %s 异常,已跳过", tid)
|
|
478
|
+
# 运行终态对账:last_status 停在 started 的任务查一次真实终态(见函数注)。
|
|
479
|
+
try:
|
|
480
|
+
_reconcile_statuses()
|
|
481
|
+
except Exception:
|
|
482
|
+
log.debug("automation: 运行终态对账跳过", exc_info=True)
|
|
432
483
|
# 定时发布联动(P2.5):任务级 auto_publish 标记到点触发批量发布。
|
|
433
484
|
# 发布逻辑全在 publish/auto(护栏/幂等/单飞),这里只当调度宿主。
|
|
434
485
|
try:
|
|
@@ -581,10 +632,11 @@ def run_now(tid):
|
|
|
581
632
|
now = datetime.now()
|
|
582
633
|
run_id = ""
|
|
583
634
|
status = "started"
|
|
635
|
+
run_id = ""
|
|
584
636
|
try:
|
|
585
637
|
with _LOCK:
|
|
586
638
|
snapshot = dict(_TASKS[tid])
|
|
587
|
-
run_id = _launch_run(snapshot)
|
|
639
|
+
run_id = _launch_run(snapshot) or ""
|
|
588
640
|
except Exception as e:
|
|
589
641
|
log.warning("automation: 手动触发 %s 失败: %r", tid, e)
|
|
590
642
|
status = "error"
|
|
@@ -601,6 +653,8 @@ def run_now(tid):
|
|
|
601
653
|
t["last_run"] = _fmt_dt(now)
|
|
602
654
|
t["run_count"] = int(t.get("run_count") or 0) + 1
|
|
603
655
|
t["last_status"] = status
|
|
656
|
+
if run_id:
|
|
657
|
+
t["last_run_id"] = run_id
|
|
604
658
|
_save_locked()
|
|
605
659
|
return dict(t), run_id
|
|
606
660
|
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""任务驾驶舱(/board 大屏)的轻量聚合数据源。
|
|
3
|
+
|
|
4
|
+
大屏 4 秒轮询:不能拉 /api/state 全量(MB 级),也不能每次全量扫 usage
|
|
5
|
+
台账(summary 全历史读入),所以这里只取大屏要看的字段,并把 usage 聚合
|
|
6
|
+
缓存 8 秒。只读 store/usage/jobs/health,不落新盘。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
_CACHE = {"ts": 0.0, "data": None}
|
|
13
|
+
_TTL = 8.0
|
|
14
|
+
|
|
15
|
+
_TAIL_MAX = 150 # 跑马灯尾行截断
|
|
16
|
+
_TAIL_LINES = 6 # 实时流窗口行数
|
|
17
|
+
_TAIL_LINE_CHARS = 56 # 实时流单行截断(卡内 11px 等宽不换行的安全宽度)
|
|
18
|
+
_RECENT_LIMIT = 10 # 最近完成/失败条数
|
|
19
|
+
_RUN_WINDOW = 300 # 今日计数扫描的运行窗口(id 字典序=时间序)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def payload():
|
|
23
|
+
now = time.time()
|
|
24
|
+
if _CACHE["data"] is not None and now - _CACHE["ts"] < _TTL:
|
|
25
|
+
return _CACHE["data"]
|
|
26
|
+
data = _build()
|
|
27
|
+
_CACHE["ts"] = now
|
|
28
|
+
_CACHE["data"] = data
|
|
29
|
+
return data
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _tail(step):
|
|
33
|
+
"""步骤实时流(正文优先,其次思考)的最后一行,给大屏跑马灯。"""
|
|
34
|
+
for key in ("stream", "thinking"):
|
|
35
|
+
v = step.get(key)
|
|
36
|
+
if isinstance(v, str) and v.strip():
|
|
37
|
+
lines = [ln.strip() for ln in v.strip().splitlines() if ln.strip()]
|
|
38
|
+
if lines:
|
|
39
|
+
return lines[-1][:_TAIL_MAX]
|
|
40
|
+
return ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _live_block(step):
|
|
44
|
+
"""步骤实时流窗口:尾部多行(正文优先,其次思考),供大屏看它在想什么。
|
|
45
|
+
|
|
46
|
+
返回 (来源标签 code, 文本);标签是 code(output/thinking),由前端 i18n
|
|
47
|
+
翻译——大屏要做多语言,后端不下发中文。都没有返回 ("", "")。
|
|
48
|
+
"""
|
|
49
|
+
for key, code in (("stream", "output"), ("thinking", "thinking")):
|
|
50
|
+
v = step.get(key)
|
|
51
|
+
if not (isinstance(v, str) and v.strip()):
|
|
52
|
+
continue
|
|
53
|
+
lines = [ln.strip()[:_TAIL_LINE_CHARS] for ln in v.strip().splitlines()]
|
|
54
|
+
lines = [ln for ln in lines if ln]
|
|
55
|
+
if lines:
|
|
56
|
+
return code, "\n".join(lines[-_TAIL_LINES:])
|
|
57
|
+
return "", ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _model_provider_map():
|
|
61
|
+
"""模型名 → 厂商名(enabled 优先,同模型多厂商取 priority 最靠前的)。"""
|
|
62
|
+
try:
|
|
63
|
+
from . import modelhub
|
|
64
|
+
rows = modelhub.models_view()
|
|
65
|
+
except Exception:
|
|
66
|
+
return {}
|
|
67
|
+
best = {}
|
|
68
|
+
for r in rows:
|
|
69
|
+
name = r.get("name")
|
|
70
|
+
if not name or not r.get("enabled", False):
|
|
71
|
+
continue
|
|
72
|
+
pri = r.get("priority", 0)
|
|
73
|
+
cur = best.get(name)
|
|
74
|
+
if cur is None or pri < cur[0]:
|
|
75
|
+
best[name] = (pri, r.get("provider_name") or r.get("provider_id") or "")
|
|
76
|
+
return {k: v[1] for k, v in best.items()}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _parse_ts(s):
|
|
80
|
+
try:
|
|
81
|
+
return time.mktime(time.strptime(str(s), "%Y-%m-%d %H:%M:%S"))
|
|
82
|
+
except (ValueError, TypeError):
|
|
83
|
+
return 0.0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _duration_s(run):
|
|
87
|
+
"""运行耗时(秒):ended-started,缺 started 用 created 兜底。"""
|
|
88
|
+
end = _parse_ts(run.get("ended_at"))
|
|
89
|
+
start = _parse_ts(run.get("started_at")) or _parse_ts(run.get("created_at"))
|
|
90
|
+
if end > 0 and start > 0 and end >= start:
|
|
91
|
+
return int(end - start)
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _step_view(steps, prov_map):
|
|
96
|
+
"""进度概要:完成数 + 当前运行中步骤(含模型/厂商/实时流)。"""
|
|
97
|
+
done = sum(1 for s in steps if s.get("status") == "done")
|
|
98
|
+
cur = next((s for s in steps if s.get("status") == "running"), None)
|
|
99
|
+
cur_v = None
|
|
100
|
+
if cur:
|
|
101
|
+
live_label, live_text = _live_block(cur)
|
|
102
|
+
model = str(cur.get("model") or "")[:60]
|
|
103
|
+
cur_v = {"n": cur.get("n"), "role": cur.get("role") or "",
|
|
104
|
+
"agent": cur.get("agent_label") or cur.get("agent") or "",
|
|
105
|
+
"summary": (cur.get("summary") or "")[:80],
|
|
106
|
+
"tail": _tail(cur),
|
|
107
|
+
"model": model, "provider": prov_map.get(model, "") if model else "",
|
|
108
|
+
"live_label": live_label, "live_text": live_text}
|
|
109
|
+
return done, len(steps), cur_v
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _build():
|
|
113
|
+
from . import flows, health, jobs, store, usage
|
|
114
|
+
|
|
115
|
+
flow_map = {}
|
|
116
|
+
try:
|
|
117
|
+
for f in flows.list_flows():
|
|
118
|
+
flow_map[f.get("id")] = f
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
prov_map = _model_provider_map()
|
|
123
|
+
today = time.strftime("%Y-%m-%d")
|
|
124
|
+
tasks = store.list_tasks(200, archived=False)
|
|
125
|
+
latest = store.latest_run_by_task()
|
|
126
|
+
|
|
127
|
+
running, queued = [], []
|
|
128
|
+
for t in tasks:
|
|
129
|
+
tid = t.get("id")
|
|
130
|
+
run = latest.get(tid)
|
|
131
|
+
status = t.get("status")
|
|
132
|
+
if status == "running" and run and run.get("status") == "running":
|
|
133
|
+
done, total, cur = _step_view(run.get("steps") or [], prov_map)
|
|
134
|
+
running.append({
|
|
135
|
+
"task_id": tid, "run_id": run.get("id"),
|
|
136
|
+
"title": t.get("title") or "", "goal": (t.get("goal") or "")[:120],
|
|
137
|
+
"type": t.get("type") or "",
|
|
138
|
+
"type_name": (flow_map.get(t.get("type")) or {}).get("name") or (t.get("type") or ""),
|
|
139
|
+
"status": status,
|
|
140
|
+
"started_at": run.get("started_at") or run.get("created_at") or "",
|
|
141
|
+
"steps_done": done, "steps_total": total,
|
|
142
|
+
"current": cur,
|
|
143
|
+
"run_tokens": run.get("tokens") or 0,
|
|
144
|
+
"run_cost_usd": round(float(run.get("cost_usd") or 0.0), 4),
|
|
145
|
+
"estimate_s": run.get("estimated_duration_s"),
|
|
146
|
+
})
|
|
147
|
+
elif status == "queued":
|
|
148
|
+
queued.append({
|
|
149
|
+
"task_id": tid, "run_id": (run or {}).get("id"),
|
|
150
|
+
"title": t.get("title") or "",
|
|
151
|
+
"type_name": (flow_map.get(t.get("type")) or {}).get("name") or (t.get("type") or ""),
|
|
152
|
+
"created_at": (run or {}).get("created_at") or "",
|
|
153
|
+
})
|
|
154
|
+
running.sort(key=lambda c: _parse_ts(c["started_at"]))
|
|
155
|
+
queued.sort(key=lambda c: _parse_ts(c["created_at"]))
|
|
156
|
+
|
|
157
|
+
runs = store.list_runs(_RUN_WINDOW)
|
|
158
|
+
today_done = today_failed = 0
|
|
159
|
+
recent = []
|
|
160
|
+
for r in runs:
|
|
161
|
+
st = r.get("status")
|
|
162
|
+
ended = str(r.get("ended_at") or "")
|
|
163
|
+
if st in ("done", "failed", "cancelled") and ended.startswith(today):
|
|
164
|
+
if st == "done":
|
|
165
|
+
today_done += 1
|
|
166
|
+
elif st == "failed":
|
|
167
|
+
today_failed += 1
|
|
168
|
+
if len(recent) < _RECENT_LIMIT and st in ("done", "failed", "cancelled") and ended:
|
|
169
|
+
recent.append({
|
|
170
|
+
"run_id": r.get("id"), "task_id": r.get("task_id"),
|
|
171
|
+
"title": r.get("title") or "", "status": st,
|
|
172
|
+
"ended_at": ended, "duration_s": _duration_s(r),
|
|
173
|
+
"error": (r.get("error") or "")[:80],
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
trend, models, usage_today = [], [], {"tokens": 0, "calls": 0, "cost_usd": 0.0}
|
|
177
|
+
try:
|
|
178
|
+
s = usage.summary(days=7)
|
|
179
|
+
trend = [{"day": d.get("day"), "tokens": d.get("tokens") or 0}
|
|
180
|
+
for d in (s.get("by_day") or [])]
|
|
181
|
+
for row in (s.get("by_model") or [])[:5]:
|
|
182
|
+
if not row.get("tokens"):
|
|
183
|
+
continue
|
|
184
|
+
models.append({"model": row.get("key") or "?",
|
|
185
|
+
"tokens": row.get("tokens") or 0,
|
|
186
|
+
"calls": row.get("calls") or 0})
|
|
187
|
+
for d in s.get("by_day") or []:
|
|
188
|
+
if str(d.get("day")) == today:
|
|
189
|
+
usage_today = {"tokens": d.get("tokens") or 0,
|
|
190
|
+
"calls": d.get("calls") or 0,
|
|
191
|
+
"cost_usd": round(float(d.get("cost_usd") or 0.0), 3)}
|
|
192
|
+
break
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
"now": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
198
|
+
"today": today,
|
|
199
|
+
"running": running,
|
|
200
|
+
"queued": queued,
|
|
201
|
+
"today_done": today_done,
|
|
202
|
+
"today_failed": today_failed,
|
|
203
|
+
"recent": recent,
|
|
204
|
+
"trend": trend,
|
|
205
|
+
"models": models,
|
|
206
|
+
"usage_today": usage_today,
|
|
207
|
+
"pool": jobs.workers_info(),
|
|
208
|
+
"health": health.snapshot().get("providers") or [],
|
|
209
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""多线剧情推演(inkos 借鉴 2026-09-23):写章前一次调用生成 2-3 条分支
|
|
3
|
+
节拍计划,自带推荐择优,选中分支注入本章 BEATS;全部分支追加审计文件
|
|
4
|
+
.codebee/branch-plans.md 供回看。计划级赛马——比 prose 级 Best-of-N 省
|
|
5
|
+
一个数量级的 token。"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
BRANCH_PLAN_PROMPT = """你是网文剧情策划。基于全书设定与上一章结尾,为下一章生成 __N__ 条**彼此方向不同**的剧情分支计划。
|
|
14
|
+
|
|
15
|
+
要求:
|
|
16
|
+
- 每条分支给出:本章节拍(50-120 字:事件/冲突/推进)+ 章末钩子(一句话)+ 一句取舍理由(为什么选/不选它)
|
|
17
|
+
- 分支之间要有真实的方向差异(不同冲突来源/不同人物决定/不同信息揭示),不是同一件事换措辞
|
|
18
|
+
- 你是策划也是评委:综合大纲意图、前情、人物动机,推荐其中最有力的一条
|
|
19
|
+
|
|
20
|
+
只输出一个 ```json 代码块:
|
|
21
|
+
{"branches": [{"beats": "…", "hook": "…", "why": "…"}, …], "pick": 0}
|
|
22
|
+
pick = 推荐分支的下标(0 起)。
|
|
23
|
+
|
|
24
|
+
## 全书目标
|
|
25
|
+
__GOAL__
|
|
26
|
+
|
|
27
|
+
## 大纲上下文(本章前后)
|
|
28
|
+
__OUTLINE__
|
|
29
|
+
|
|
30
|
+
## 上一章结尾
|
|
31
|
+
__PREV__
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def plan_branches(run_id, task, i, goal, outline_txt, prev, n, *, log_path=None):
|
|
36
|
+
"""生成并择优一条分支计划。返回 (beats, hook) 或 None(失败/未启用走原大纲)。
|
|
37
|
+
|
|
38
|
+
编排者一次调用产 N 条+自荐 pick;失败静默回落原大纲节拍(推演是增强
|
|
39
|
+
不是闸门)。全部分支连同 pick 追加 .codebee/branch-plans.md 审计。"""
|
|
40
|
+
if n < 2:
|
|
41
|
+
return None
|
|
42
|
+
from . import modelhub, runner
|
|
43
|
+
if n < 2:
|
|
44
|
+
return None
|
|
45
|
+
orch = modelhub.resolve_orchestrator()
|
|
46
|
+
if not orch:
|
|
47
|
+
return None
|
|
48
|
+
prov, model = orch
|
|
49
|
+
prompt = (BRANCH_PLAN_PROMPT
|
|
50
|
+
.replace("__N__", str(n))
|
|
51
|
+
.replace("__GOAL__", goal[:600])
|
|
52
|
+
.replace("__OUTLINE__", outline_txt[:3000])
|
|
53
|
+
.replace("__PREV__", (prev or "(开篇)")[-1200:]))
|
|
54
|
+
res = modelhub.chat(prov["id"], model, prompt, max_tokens=4000, timeout=300)
|
|
55
|
+
if not res.get("ok"):
|
|
56
|
+
return None
|
|
57
|
+
data = runner.extract_json(res.get("text") or "")
|
|
58
|
+
branches = (data or {}).get("branches") if isinstance(data, dict) else None
|
|
59
|
+
if not isinstance(branches, list):
|
|
60
|
+
return None
|
|
61
|
+
usable = [b for b in branches
|
|
62
|
+
if isinstance(b, dict) and str(b.get("beats") or "").strip()]
|
|
63
|
+
if len(usable) < 2:
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
pick = int(data.get("pick") or 0)
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
pick = 0
|
|
69
|
+
pick = max(0, min(pick, len(usable) - 1))
|
|
70
|
+
chosen = usable[pick]
|
|
71
|
+
try:
|
|
72
|
+
from . import store as _store
|
|
73
|
+
wd = _store.run_workdir(run_id) or ""
|
|
74
|
+
except Exception:
|
|
75
|
+
wd = ""
|
|
76
|
+
_append_audit(wd, i, usable, pick)
|
|
77
|
+
return (str(chosen["beats"]).strip()[:300],
|
|
78
|
+
str(chosen.get("hook") or "").strip()[:80] or None)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _append_audit(workdir, chapter, branches, pick):
|
|
82
|
+
"""全部分支追加 .codebee/branch-plans.md(失败静默——审计是增强)。"""
|
|
83
|
+
if not workdir:
|
|
84
|
+
return
|
|
85
|
+
try:
|
|
86
|
+
p = os.path.abspath(os.path.join(str(workdir), ".codebee",
|
|
87
|
+
"branch-plans.md"))
|
|
88
|
+
root = os.path.abspath(str(workdir))
|
|
89
|
+
if root not in p.split(os.sep)[:-len([".codebee", "branch-plans.md"])]:
|
|
90
|
+
if not p.startswith(root + os.sep):
|
|
91
|
+
return
|
|
92
|
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
93
|
+
with open(p, "a", encoding="utf-8") as f:
|
|
94
|
+
if f.tell() == 0:
|
|
95
|
+
f.write("# 多线剧情推演(每章分支计划与择优,供回看)\n\n")
|
|
96
|
+
f.write("## 第 %d 章\n" % chapter)
|
|
97
|
+
for k, b in enumerate(branches):
|
|
98
|
+
mark = "✅ " if k == pick else ""
|
|
99
|
+
f.write("- %s%s(钩子:%s)——%s\n" % (
|
|
100
|
+
mark, str(b.get("beats") or "")[:160],
|
|
101
|
+
str(b.get("hook") or "")[:60],
|
|
102
|
+
str(b.get("why") or "")[:120]))
|
|
103
|
+
f.write("\n")
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""章节安全落盘(tmp+原子改名,inkos 安全章节工作区借鉴)。
|
|
3
|
+
|
|
4
|
+
写一半崩溃/磁盘满不会留下半章正文冒充成稿——断点续跑按「文件够长」
|
|
5
|
+
判定,半文件会被误当合法稿复用;.tmp 写完 os.replace 原子落位,
|
|
6
|
+
失败清理残渣后原样抛出。路径守卫用 pathlib resolve+parents 惯用法
|
|
7
|
+
(同 _ms_io/_inside 口径)。pipeline._write_chapter 委托此函数。"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def atomic_write_chapter(workdir, i, text):
|
|
14
|
+
"""第 i 章安全落盘:tmp 写入 → 原子改名。返回最终路径。"""
|
|
15
|
+
root = Path(workdir).resolve()
|
|
16
|
+
p = (root / ("chapter-%02d.md" % int(i))).resolve()
|
|
17
|
+
# 守卫:目标必须在 workdir 之内且不得是 workdir 本身(".." 作 workdir
|
|
18
|
+
# 时 resolve 到父目录,p 仍是其中一章——但那不是调用方的工作目录,
|
|
19
|
+
# 一并拒绝:workdir 必须是已存在的目录)
|
|
20
|
+
if root not in p.parents or not root.is_dir():
|
|
21
|
+
raise ValueError("章节路径越界: chapter-%02d.md" % int(i))
|
|
22
|
+
tmp = Path(str(p) + ".tmp")
|
|
23
|
+
try:
|
|
24
|
+
tmp.write_text(str(text), encoding="utf-8", errors="replace")
|
|
25
|
+
tmp.replace(p)
|
|
26
|
+
except BaseException:
|
|
27
|
+
try:
|
|
28
|
+
tmp.unlink(missing_ok=True)
|
|
29
|
+
except OSError:
|
|
30
|
+
pass
|
|
31
|
+
raise
|
|
32
|
+
return p
|
package/app/core/jobs.py
CHANGED
|
@@ -949,7 +949,7 @@ def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
|
|
|
949
949
|
.replace("__OS__", _repair_os_label())
|
|
950
950
|
.replace("__ALLOW__", " / ".join(p.strip() for p in _repair_allow())))
|
|
951
951
|
step, log_abs = store.add_step(run_id, "ai-repair", agent["id"], agent.get("label"),
|
|
952
|
-
note="自动诊断修复")
|
|
952
|
+
note="自动诊断修复", model=agent.get("model") or "")
|
|
953
953
|
res = runner.run_agent(agent, prompt, readonly=True, timeout=300,
|
|
954
954
|
cancel_event=ev, log_path=str(log_abs))
|
|
955
955
|
try:
|