codebee 0.1.47 → 0.1.49

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 CHANGED
@@ -6,6 +6,18 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
6
6
 
7
7
  ## 未发布
8
8
 
9
+ ## v0.1.49(2026-09-22)
10
+
11
+ ### 🛠 问题修复
12
+
13
+ - 修复设置 →「钩子」卡片样式坍缩:下拉框漏写闭合标签导致命令框与操作行被吞进内部,整卡挤成一行。
14
+
15
+ ## v0.1.48(2026-09-22)
16
+
17
+ ### ✨ 新功能
18
+
19
+ - 新增「钩子」:在任务生命周期的关键点挂你自己的本机脚本——任务开始(输出注入任务上下文)、消息送达(输出注入提示词头)、运行结束(回写知识库等副作用)。命令从标准输入收 JSON,输出 `{"inject": "文本"}` 即注入;失败/超时静默跳过,绝不挡主流程。设置 →「钩子」里管理,支持逐条开关、超时与一键测试。
20
+
9
21
  ## v0.1.47(2026-09-22)
10
22
 
11
23
  ### ✨ 新功能
@@ -8,6 +8,8 @@
8
8
  """
9
9
  from __future__ import annotations
10
10
 
11
+ import re
12
+
11
13
  # 译制腔/套话词表(按误导性排序,持续运维)
12
14
  AI_PHRASES = (
13
15
  "空气仿佛凝固", "眸中闪过一丝", "嘴角勾起一抹", "心中一动",
@@ -81,8 +83,73 @@ def narrative_analyze(text):
81
83
  return {"cats": cats, "per_kilo": per_kilo, "alert": alert, "ending_hits": ending_hits}
82
84
 
83
85
 
84
- def report_line(text):
85
- """生成注入评审提示词的报告行(措辞层 + 叙事架构层);全部无命中返回空串。"""
86
+ # ---------- 节奏/钩子密度(借鉴 web-novel-pacing-analyzer:连载作品的章级
87
+ # 节奏审计——读者留存看「每章有没有钩子、段落有没有淤积、是不是通篇独白」,
88
+ # 这些是能确定性统计的客观量。与套话检测同路:只给评审官一条参考线,
89
+ # 不直接扣分——短段落多的快节奏文也可能开篇没有冲突词。)
90
+ NARRATIVE_FLOWS = ("novel", "serial_novel", "video_script")
91
+
92
+ # 冲突/悬念信号词表:开篇用来看「抓不抓人」,章末用来看「留不留钩子」
93
+ HOOK_WORDS = (
94
+ "突然", "忽然", "竟然", "没想到", "万万没想到", "难道", "究竟", "为什么",
95
+ "是谁", "怎么回事", "就在这时", "就在此时", "然而", "可是", "却见", "却听",
96
+ "不对劲", "不对!", "脸色一变", "猛地", "陡然", "下一秒", "醒来", "睁开眼",
97
+ "电话响", "门被推开", "传来", "砸", "吼", "喊",
98
+ )
99
+ DIALOG_RE = re.compile(r"[「『“\"]")
100
+ OPENING_SCAN_CHARS = 300 # 开篇抓力只看前 300 字(黄金三章 / 短视频黄金 3 秒)
101
+ PACING_TAIL_CHARS = 200 # 章末钩子只看最后 200 字
102
+ PARA_LONG_CHARS = 400 # 单段超过此字数算「信息淤积」
103
+ PARA_AVG_ALERT = 300 # 段均长告警线
104
+ DIALOG_ALERT_RATIO = 0.12 # 含对话段占比低于此值算「通篇叙述独白」
105
+ PACING_MIN_CHARS = 400 # 太短(摘要/片段)不做节奏统计,避免噪声
106
+
107
+
108
+ def _paras(text):
109
+ """切段:空行或单换行都算分隔;丢掉标题行(# 开头)与空白。"""
110
+ out = []
111
+ for p in re.split(r"\n\s*\n|\n", text or ""):
112
+ p = p.strip()
113
+ if p and not p.startswith("#"):
114
+ out.append(p)
115
+ return out
116
+
117
+
118
+ def pacing_analyze(text):
119
+ """统计叙事节奏客观量。返回 {ok, paras, avg_len, longest, dialog_ratio,
120
+ open_hits, tail_hits, alerts, alert};文本过短时 ok=False。"""
121
+ text = text or ""
122
+ paras = _paras(text)
123
+ if len(text) < PACING_MIN_CHARS or not paras:
124
+ return {"ok": False}
125
+ lens = [len(p) for p in paras]
126
+ avg = round(sum(lens) * 1.0 / len(lens), 1)
127
+ longest = max(lens)
128
+ dialog = sum(1 for p in paras if DIALOG_RE.search(p))
129
+ ratio = round(dialog * 1.0 / len(paras), 3)
130
+ opening, tail = text[:OPENING_SCAN_CHARS], text[-PACING_TAIL_CHARS:]
131
+ open_hits = sum(1 for w in HOOK_WORDS if w in opening)
132
+ tail_hits = sum(1 for w in HOOK_WORDS if w in tail)
133
+ alerts = []
134
+ if avg >= PARA_AVG_ALERT:
135
+ alerts.append("段落偏长(均 %.0f 字),阅读有淤积感" % avg)
136
+ if longest >= PARA_LONG_CHARS:
137
+ alerts.append("存在超长段(最长 %d 字)" % longest)
138
+ if ratio < DIALOG_ALERT_RATIO:
139
+ alerts.append("对话段占比偏低(%.0f%%),易成通篇独白" % (ratio * 100))
140
+ if not tail_hits:
141
+ alerts.append("章末 200 字无悬念信号,缺钩子")
142
+ if not open_hits:
143
+ alerts.append("开篇 300 字无冲突信号,开头偏平")
144
+ return {"ok": True, "paras": len(paras), "avg_len": avg, "longest": longest,
145
+ "dialog_ratio": ratio, "open_hits": open_hits, "tail_hits": tail_hits,
146
+ "alerts": alerts, "alert": bool(alerts)}
147
+
148
+
149
+ def report_line(text, kind=None):
150
+ """生成注入评审提示词的报告行(措辞层 + 叙事架构层 + 节奏层);
151
+ 全部无命中返回空串。kind 为流程类型:只有叙事类流程才下发节奏检测
152
+ (邮件/汇报/翻译等非叙事文体统计段落节奏没有意义)。"""
86
153
  lines = []
87
154
  r = analyze(text)
88
155
  if r["hits"]:
@@ -102,4 +169,15 @@ def report_line(text):
102
169
  "请评审情节结构:主题是否被叙述者直接说破、情绪是否只有身体描写、"
103
170
  "结尾是否靠主角想通收束。")
104
171
  lines.append(line)
172
+ if kind in NARRATIVE_FLOWS:
173
+ pr = pacing_analyze(text)
174
+ if pr["ok"]:
175
+ line = ("- [节奏检测] 确定性统计:%d 段(均 %.0f 字 / 最长 %d 字),"
176
+ "含对话段 %.0f%%,开篇 300 字冲突信号 %d 处,章末 200 字悬念信号 %d 处。"
177
+ % (pr["paras"], pr["avg_len"], pr["longest"],
178
+ pr["dialog_ratio"] * 100, pr["open_hits"], pr["tail_hits"]))
179
+ if pr["alert"]:
180
+ line += ("提示:%s。连载留存取决于每章的钩子与段落节奏,"
181
+ "请评审官结合剧情判断是否真问题。" % "、".join(pr["alerts"]))
182
+ lines.append(line)
105
183
  return "\n".join(lines)
@@ -10,8 +10,13 @@ store.create_run → jobs.enqueue)拉起一次真实运行,不自己造运
10
10
  任务模型:
11
11
  {id, name, prompt, workdir, kind: daily|interval|weekly|once, time: "HH:MM",
12
12
  interval_hours, weekday(0-6 周一=0), run_at(once 用, ISO), flow(编排流程 id),
13
+ mode(编排模式), thinking(思考程度), direct_provider_id/direct_model(仅 direct 流程),
13
14
  enabled, created_at, last_run, next_run, run_count, last_status}
14
15
 
16
+ 运行偏好(mode/thinking/对话模型)与新建任务 Composer 的三颗胶囊同一套取值,
17
+ 到点拉起时原样透传给 store.create_task——定时任务不是另一条执行链,用户在这里
18
+ 选的编排强度与模型偏好必须与手动建任务完全等价。
19
+
15
20
  重启语义:错过的 once 不补跑(启动恢复时直接停用);daily/weekly/interval
16
21
  重算 next_run 到下一个未来时刻即可,不追赶停机期间错过的周期。
17
22
 
@@ -48,17 +53,24 @@ KINDS = ("daily", "interval", "weekly", "once")
48
53
  INTERVAL_MIN, INTERVAL_MAX = 1, 720 # interval_hours 合法区间(小时)
49
54
  DEFAULT_FLOW = "doc" # 未指定编排流程时的兜底:review 引擎产出文档,适配巡检/整理类提示词
50
55
 
56
+ # 运行偏好取值(必须与 store.create_task 的口径一致,否则到点拉起时被静默改档)
57
+ MODES = ("auto", "fast", "expert", "manual")
58
+ THINKINGS = ("auto", "low", "standard", "high")
59
+
51
60
  _TIME_RE = re.compile(r"\s*(\d{1,2}):(\d{1,2})\s*")
52
61
 
53
62
  # 字段缺省(加载历史文件/脏数据时兜底;enabled 兜底为 False,绝不意外触发)
54
63
  _DEFAULTS = {"id": "", "name": "", "prompt": "", "workdir": "", "kind": "", "time": "",
55
64
  "interval_hours": 0, "weekday": -1, "run_at": "", "flow": "",
65
+ "mode": "auto", "thinking": "standard",
66
+ "direct_provider_id": "", "direct_model": "",
56
67
  "enabled": False, "created_at": "", "last_run": "", "next_run": "",
57
68
  "run_count": 0, "last_status": ""}
58
69
 
59
70
  # 允许通过 update() 修改的字段(id/created_at/run_count 等运行痕迹不可改)
60
71
  _UPDATABLE = ("name", "prompt", "workdir", "kind", "time", "interval_hours",
61
- "weekday", "run_at", "flow", "enabled")
72
+ "weekday", "run_at", "flow", "mode", "thinking",
73
+ "direct_provider_id", "direct_model", "enabled")
62
74
 
63
75
 
64
76
  # ---------------------------------------------------------------- 时间工具
@@ -206,6 +218,37 @@ def _apply_schedule(t, check_flow=True):
206
218
  t["flow"] = DEFAULT_FLOW
207
219
 
208
220
 
221
+ def _flow_engine(flow_id):
222
+ """流程引擎名(查不到返回空串)。"""
223
+ from . import flows as flows_mod
224
+ f = flows_mod.get_flow(str(flow_id or "").strip())
225
+ return (f or {}).get("engine") or ""
226
+
227
+
228
+ def _apply_run_prefs(t):
229
+ """校验并归一运行偏好(编排模式/思考程度/对话模型),就地改写 t。必须在
230
+ _apply_schedule 之后调用——只有那里才把 flow 归一成最终值。
231
+
232
+ mode/thinking 非法值一律收敛到默认(auto/standard),不抛异常:这两个是
233
+ 「偏好」不是「配置」,脏值不该把保存卡死。对话模型只在流程引擎为 direct
234
+ 时有意义:非 direct 流程一律清空,避免换流程后残留的模型绑定在下一次
235
+ 触发时静默生效(用户看不到却在生效的配置,比没有更糟)。direct 流程下
236
+ 「有模型没厂商」则明确报错——猜一个厂商等于把模型请求打到别家。
237
+ """
238
+ mode = str(t.get("mode") or "").strip().lower()
239
+ t["mode"] = mode if mode in MODES else "auto"
240
+ thinking = str(t.get("thinking") or "").strip().lower()
241
+ t["thinking"] = thinking if thinking in THINKINGS else "standard"
242
+ pid = str(t.get("direct_provider_id") or "").strip()[:80]
243
+ model = str(t.get("direct_model") or "").strip()[:160]
244
+ if _flow_engine(t.get("flow")) != "direct":
245
+ t["direct_provider_id"], t["direct_model"] = "", ""
246
+ return
247
+ if model and not pid:
248
+ raise ValueError("指定对话模型时必须同时选择厂商")
249
+ t["direct_provider_id"], t["direct_model"] = pid, model
250
+
251
+
209
252
  # ---------------------------------------------------------------- 持久化
210
253
 
211
254
  def _list_locked():
@@ -298,7 +341,15 @@ def _launch_run(t):
298
341
  "title": ("%s %s" % (t.get("name") or "定时任务",
299
342
  time.strftime("%m-%d %H:%M"))).strip()[:40],
300
343
  "goal": t.get("prompt") or "",
301
- "workdir": (t.get("workdir") or "").strip()}
344
+ "workdir": (t.get("workdir") or "").strip(),
345
+ # 运行偏好与 Composer 同一套字段:create_task 里已支持,透传即可
346
+ "mode": t.get("mode") or "auto",
347
+ "thinking": t.get("thinking") or "standard"}
348
+ # direct 流程才有对话模型;非 direct 流程这两个键连传都不传(避免碰 store
349
+ # 里「有模型没厂商就报错」的校验,也避免给别的引擎塞无意义字段)
350
+ if t.get("direct_provider_id"):
351
+ payload["direct_provider_id"] = t["direct_provider_id"]
352
+ payload["direct_model"] = t.get("direct_model") or ""
302
353
  task = None
303
354
  run = None
304
355
  try:
@@ -470,6 +521,7 @@ def create(payload):
470
521
  t["kind"] = payload.get("kind") or "daily"
471
522
  _validate_core(t)
472
523
  _apply_schedule(t)
524
+ _apply_run_prefs(t) # 依赖 _apply_schedule 归一后的 flow 判引擎
473
525
  t["next_run"] = compute_next_run(t)
474
526
  with _LOCK:
475
527
  _TASKS[t["id"]] = t
@@ -492,6 +544,7 @@ def update(tid, patch):
492
544
  merged[k] = patch[k]
493
545
  _validate_core(merged, check_workdir=("workdir" in patch))
494
546
  _apply_schedule(merged, check_flow=("flow" in patch))
547
+ _apply_run_prefs(merged) # 改了 flow 就重判:非 direct 流程清掉残留模型
495
548
  merged["next_run"] = compute_next_run(merged)
496
549
  _TASKS[tid] = merged
497
550
  _save_locked()
package/app/core/flows.py CHANGED
@@ -13,7 +13,7 @@ import json
13
13
  import re
14
14
  import threading
15
15
 
16
- from . import paths
16
+ from . import paths, volumes
17
17
 
18
18
  _LOCK = threading.RLock()
19
19
  _FILE = paths.DATA_DIR / "flows.json"
@@ -149,10 +149,19 @@ def _norm_serial(serial):
149
149
  if not isinstance(serial, dict) or not serial.get("chapters"):
150
150
  return None
151
151
  try:
152
- return {"chapters": max(2, min(20, int(serial["chapters"]))),
153
- "words_per_chapter": max(500, min(8000, int(serial.get("words_per_chapter") or 2500)))}
152
+ out = {"chapters": max(2, min(20, int(serial["chapters"]))),
153
+ "words_per_chapter": max(500, min(8000, int(serial.get("words_per_chapter") or 2500)))}
154
154
  except Exception:
155
155
  return None
156
+ # 分卷(可选):显式卷表优先,其次每卷章数。流程模板里可以预置,
157
+ # 任务级 payload 仍可覆盖/关闭(0 = 不分卷)。
158
+ spec = volumes.norm_spec(serial.get("volumes"))
159
+ if spec:
160
+ out["volumes"] = spec
161
+ per = volumes.norm_per(serial.get("volume_chapters"))
162
+ if per:
163
+ out["volume_chapters"] = per
164
+ return out
156
165
 
157
166
 
158
167
  def _norm_rubric(rubric):
@@ -0,0 +1,156 @@
1
+ # -*- coding: utf-8 -*-
2
+ """任务生命周期钩子(借鉴 ZCode hooks,收窄为三个事件——2026-09-22 用户立项):
3
+
4
+ - task_start 任务开跑前(编排/直连都触发):stdout 可注入文本,追加进任务上下文
5
+ - message_submit 用户消息送达前:stdout 可注入文本,随消息进提示词头
6
+ - run_end 每轮运行结束:纯副作用型(回写知识库等),输出被忽略
7
+
8
+ 协议:钩子命令从 stdin 收 JSON(事件+上下文);stdout 若为 {"inject": "..."}
9
+ 取该字段为注入文本,否则把非空 stdout 整体当注入文本。
10
+ 失败/超时一律静默跳过(外部脚本不稳绝不拖死编排主流程)。
11
+
12
+ 命令执行:shlex 分词(保 Windows 反斜杠)后走参数列表,不经 shell——
13
+ 不支持管道/重定向等 shell 语法,需要时写 .py/.bat 包装脚本。
14
+ 配置:DATA_DIR/hooks.json,纯本机设置。"""
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import shlex
20
+ import subprocess
21
+
22
+ from . import paths
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+ EVENTS = ("task_start", "message_submit", "run_end")
27
+ _DEFAULT_TIMEOUT = 10
28
+ _FILE = None # 测试重绑(同 modelhub._FILE 惯例)
29
+
30
+ _CREATE_NO_WINDOW = 0x08000000 if hasattr(subprocess, "CREATE_NO_WINDOW") else 0
31
+
32
+
33
+ def _path():
34
+ return _FILE or (paths.DATA_DIR / "hooks.json")
35
+
36
+
37
+ def _default_list():
38
+ return []
39
+
40
+
41
+ def load_list():
42
+ """读钩子配置列表;损坏/缺省回落空表。"""
43
+ try:
44
+ data = json.loads(_path().read_text(encoding="utf-8"))
45
+ return data if isinstance(data, list) else _default_list()
46
+ except Exception:
47
+ return _default_list()
48
+
49
+
50
+ def save_list(items):
51
+ """整表保存(UI 全量提交)。字段归一 + 事件白名单。"""
52
+ if not isinstance(items, list):
53
+ raise ValueError("hooks 必须是数组")
54
+ out = []
55
+ for i, h in enumerate(items):
56
+ if not isinstance(h, dict):
57
+ continue
58
+ name = str(h.get("name") or "").strip()[:60]
59
+ event = str(h.get("event") or "").strip()
60
+ cmd = str(h.get("cmd") or "").strip()
61
+ if not cmd:
62
+ continue
63
+ if event not in EVENTS:
64
+ raise ValueError("第 %d 条钩子 event 非法(%s)" % (i + 1, event))
65
+ try:
66
+ timeout = max(1, min(120, int(h.get("timeout_s") or _DEFAULT_TIMEOUT)))
67
+ except (TypeError, ValueError):
68
+ timeout = _DEFAULT_TIMEOUT
69
+ out.append({"id": str(h.get("id") or ("hk-%d" % (i + 1)))[:40],
70
+ "name": name or ("钩子 %d" % (i + 1)),
71
+ "event": event, "cmd": cmd,
72
+ "enabled": bool(h.get("enabled", True)),
73
+ "timeout_s": timeout})
74
+ tmp = _path().with_suffix(".tmp")
75
+ tmp.write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8")
76
+ tmp.replace(_path())
77
+ return out
78
+
79
+
80
+ def _argv(cmd):
81
+ """命令串 → 参数列表。Windows 反斜杠路径保真(posix=False),支持引号分组;
82
+ 分词后剥掉 token 首尾引号(CreateProcess 不认字面引号路径)。"""
83
+ toks = shlex.split(str(cmd or ""), posix=False)
84
+ out = []
85
+ for t in toks:
86
+ if len(t) >= 2 and t[0] == t[-1] and t[0] in ('"', "'"):
87
+ t = t[1:-1]
88
+ if t:
89
+ out.append(t)
90
+ return out
91
+
92
+
93
+ def _run_one(h, payload):
94
+ """跑单条钩子。返回注入文本或 None(失败/超时/空输出)。"""
95
+ try:
96
+ argv = _argv(h.get("cmd"))
97
+ except ValueError as e:
98
+ log.warning("hook[%s %s] 命令解析失败:%s", h.get("name"), h.get("event"), str(e)[:120])
99
+ return None
100
+ if not argv:
101
+ return None
102
+ try:
103
+ proc = subprocess.run(
104
+ argv, shell=False,
105
+ input=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
106
+ capture_output=True, timeout=int(h.get("timeout_s") or _DEFAULT_TIMEOUT),
107
+ creationflags=_CREATE_NO_WINDOW)
108
+ except subprocess.TimeoutExpired:
109
+ log.warning("hook[%s %s] 超时,已跳过", h.get("name"), h.get("event"))
110
+ return None
111
+ except Exception as e:
112
+ log.warning("hook[%s %s] 执行失败:%s", h.get("name"), h.get("event"), str(e)[:120])
113
+ return None
114
+ out = (proc.stdout or b"").decode("utf-8", "replace").strip()
115
+ if not out:
116
+ return None
117
+ try:
118
+ data = json.loads(out)
119
+ if isinstance(data, dict):
120
+ return str(data.get("inject") or "") or None
121
+ except Exception:
122
+ pass
123
+ return out
124
+
125
+
126
+ def run_event(event, ctx=None, task_id=None, run_id=None, text=None):
127
+ """触发事件:依次跑启用的钩子,合并注入文本。任何异常不外抛。
128
+
129
+ ctx 附带任务/运行摘要;text 仅 message_submit 时传用户消息原文。
130
+ 返回拼接好的注入文本(可为空串)。"""
131
+ if event not in EVENTS:
132
+ return ""
133
+ try:
134
+ items = [h for h in load_list() if h.get("enabled") and h.get("event") == event]
135
+ except Exception:
136
+ return ""
137
+ if not items:
138
+ return ""
139
+ payload = {"event": event, "task_id": task_id or "", "run_id": run_id or "",
140
+ "text": text or ""}
141
+ if ctx is not None:
142
+ payload["ctx"] = ctx
143
+ outs = []
144
+ for h in items:
145
+ piece = _run_one(h, payload)
146
+ if piece:
147
+ outs.append(piece.strip())
148
+ return "\n\n".join(outs)
149
+
150
+
151
+ def test_one(cmd, event="task_start", timeout_s=None):
152
+ """设置页「测试」:手动跑一条钩子,返回 (ok, 输出/错误人话)。"""
153
+ h = {"name": "test", "event": event if event in EVENTS else "task_start",
154
+ "cmd": cmd, "timeout_s": timeout_s or _DEFAULT_TIMEOUT}
155
+ piece = _run_one(h, {"event": h["event"], "task_id": "", "run_id": "", "text": ""})
156
+ return True, piece if piece else "(无输出,未注入)"