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.
@@ -0,0 +1,355 @@
1
+ # -*- coding: utf-8 -*-
2
+ """分卷(网文卷结构)纯函数:把「用户给的卷表」或「每卷章数」解析成一张
3
+ 确定性的卷规划表(plan),再据此回答「某章属于哪一卷、是不是卷末章、本批
4
+ 覆盖哪几卷」。
5
+
6
+ 为什么卷边界必须确定且可复现:本书是分批写成的(每批 1-20 章,续写批次
7
+ 共用同一工作目录、按全书章号衔接)。若让模型每批自由切卷,同一卷会在下一
8
+ 批被重新划分、甚至被劈成两半。本模块把卷归属变成章号的纯函数——同一份
9
+ 卷表/每卷章数下,任何一批、任何一次续写,卷归属完全一致。模型只负责卷名
10
+ 与卷弧光(用户已给卷名时原样沿用)。
11
+
12
+ 两种来源(优先级从高到低):
13
+ 1. 显式卷表 spec —— 新建任务时用户明确给出的分卷信息(表单字段或目标/
14
+ 上下文文字里的「第一卷 少年初入江湖 第1-20章」式描述)。
15
+ 2. 每卷章数 per —— 没给显式卷表时,按固定章数等长切卷。
16
+
17
+ 两者都没有 → 空 plan = 不分卷(向后兼容:旧任务 serial 里没有这两个字段,
18
+ 行为与分卷前完全一致)。
19
+
20
+ 纯函数、零依赖,故不参与分层(未分层模块按最高层处理,只被禁不禁人)。
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import re
25
+
26
+ MAX_PER = 200 # 每卷章数上限:再大就不是分卷而是「一卷到底」
27
+ MIN_PER = 2 # 每卷至少 2 章
28
+ MAX_VOLS = 200 # 卷数上限:防模型/用户给出病态长表
29
+ MAX_TITLE = 60
30
+
31
+
32
+ def norm_per(value):
33
+ """规范化每卷章数:非数字/<2/非法 → 0(不分卷);否则夹到 [2, 200]。"""
34
+ try:
35
+ n = int(value)
36
+ except (TypeError, ValueError):
37
+ return 0
38
+ if n < MIN_PER:
39
+ return 0
40
+ return min(MAX_PER, n)
41
+
42
+
43
+ def _to_int(value):
44
+ try:
45
+ return int(value)
46
+ except (TypeError, ValueError):
47
+ return None
48
+
49
+
50
+ _RANGE_RE = re.compile(r"(\d{1,5})\s*[-~—-到至]\s*(\d{1,5})")
51
+ _COUNT_RE = re.compile(r"(\d{1,5})\s*章")
52
+ # 章号表达式的完整形态:第1-20章 / 第1章 / 1-20章 / 20章(用于从卷名里剔除)
53
+ _CH_EXPR_RE = re.compile(
54
+ r"第\s*\d{1,5}\s*[-~—-到至]\s*\d{1,5}\s*章?"
55
+ r"|第\s*\d{1,5}\s*章"
56
+ r"|\d{1,5}\s*[-~—-到至]\s*\d{1,5}\s*章"
57
+ r"|\d{1,5}\s*章")
58
+ # 开放卷:「第21章起 / 21章开始 / 21章以后」= 该卷从 21 章铺到书末
59
+ _OPEN_RE = re.compile(r"第?\s*(\d{1,5})\s*章\s*(?:起|开始|以上|以后|往后|及以后)")
60
+ _VOL_RE = re.compile(r"第\s*([0-9一二三四五六七八九十百零两]+)\s*(?:卷|部|篇)"
61
+ r"|(?:^|[\s。;;,,::、])(?:卷|部|篇)\s*([0-9一二三四五六七八九十百零两]+)")
62
+ _BRACKETS_RE = re.compile(r"[《》〈〉「」『』\[\]【】()()\"'“”‘’]")
63
+ _TITLE_TRIM = " \t::,,、。;;!!??-—~~·/"
64
+ # 开放卷的残词:「21章起」被剔除章号后只剩「起」,整词丢弃;卷名里的「起」
65
+ # (如「风云再起」)因是词内字符不受影响
66
+ _OPEN_WORD_RE = re.compile(r"(?:^|\s)(?:起|开始|以上|以后|往后|及以后)(?=\s|$)")
67
+
68
+ _CN_NUM = {"零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5,
69
+ "六": 6, "七": 7, "八": 8, "九": 9, "十": 10, "百": 100}
70
+
71
+
72
+ def _cn2int(text):
73
+ """中文数字 → int(支持「一」「十二」「二十三」「一百」这类常见写法);
74
+ 失败返回 None。仅用于识别卷号,识别不出也不影响正文章节内容。"""
75
+ text = str(text or "").strip()
76
+ if not text:
77
+ return None
78
+ if text.isdigit():
79
+ return int(text)
80
+ total, section, seen = 0, 0, False
81
+ for ch in text:
82
+ if ch not in _CN_NUM:
83
+ return None
84
+ seen = True
85
+ val = _CN_NUM[ch]
86
+ if val == 10:
87
+ section = (section or 1) * 10
88
+ elif val == 100:
89
+ total += (section or 1) * 100
90
+ section = 0
91
+ else:
92
+ section += val
93
+ if not seen:
94
+ return None
95
+ return total + section
96
+
97
+
98
+ def norm_spec(raw):
99
+ """规范化显式卷表 → [{"title": str, "start": int|None, "end": int|None,
100
+ "chapters": int|None}]。
101
+
102
+ 接受多种形态(模型/前端/用户手写各有习惯):
103
+ [{"title": "卷一 少年初入江湖", "chapters": 20}, ...]
104
+ [{"title": "...", "start": 1, "end": 20}, ...]
105
+ [{"title": "...", "range": "1-20"}, ...]
106
+ [{"title": "...", "count": 20}, ...]
107
+ ["卷一 少年初入江湖", ...](纯字符串:只有卷名,边界靠 per 推)
108
+ 无 title 也无边界的条目丢弃;整体不可用返回 []。"""
109
+ if isinstance(raw, dict):
110
+ raw = raw.get("volumes") if isinstance(raw.get("volumes"), list) else None
111
+ if not isinstance(raw, list):
112
+ return []
113
+ out = []
114
+ for item in raw[:MAX_VOLS]:
115
+ if isinstance(item, str):
116
+ title = item.strip()[:MAX_TITLE]
117
+ if title:
118
+ out.append({"title": title, "start": None, "end": None,
119
+ "chapters": None})
120
+ continue
121
+ if not isinstance(item, dict):
122
+ continue
123
+ title = str(item.get("title") or item.get("name") or "").strip()[:MAX_TITLE]
124
+ start = _to_int(item.get("start") or item.get("from"))
125
+ end = _to_int(item.get("end") or item.get("to"))
126
+ rng = item.get("range")
127
+ if isinstance(rng, str):
128
+ m = _RANGE_RE.search(rng)
129
+ if m:
130
+ start, end = _to_int(m.group(1)), _to_int(m.group(2))
131
+ chapters = _to_int(item.get("chapters") or item.get("count")
132
+ or item.get("n"))
133
+ if chapters is not None and chapters < 1:
134
+ chapters = None
135
+ if start is not None and start < 1:
136
+ start = None
137
+ if end is not None and (end < 1 or (start is not None and end < start)):
138
+ end = None
139
+ if start is not None and end is None and chapters:
140
+ end = start + chapters - 1
141
+ if start is None and end is not None:
142
+ start = 1
143
+ if not (title or start or chapters):
144
+ continue
145
+ out.append({"title": title, "start": start, "end": end,
146
+ "chapters": chapters})
147
+ return out
148
+
149
+
150
+ def _clean_title(seg, m):
151
+ """从卷段里抠出卷名:优先取卷标记之后的文字(「第一卷 少年初入江湖 第1-20章」
152
+ → 少年初入江湖),剔除章号表达式、书名号与修饰标点;标记后为空才回退到
153
+ 标记之前(「少年初入江湖 第一卷 1-20章」这类倒装写法)。前后都空返回 "",
154
+ 交给模型/大纲补卷名。"""
155
+ def _tidy(s):
156
+ s = _BRACKETS_RE.sub(" ", s)
157
+ s = _CH_EXPR_RE.sub(" ", s)
158
+ s = _OPEN_WORD_RE.sub(" ", s)
159
+ s = re.sub(r"\s+", " ", s)
160
+ return s.strip(_TITLE_TRIM)
161
+ after = _tidy(seg[m.end():])
162
+ if after:
163
+ return after[:MAX_TITLE]
164
+ return _tidy(seg[:m.start()])[:MAX_TITLE]
165
+
166
+
167
+ def parse_spec_text(text):
168
+ """从目标/上下文文字里识别显式分卷信息(新建任务时用户在描述里直接写
169
+ 「第一卷 少年初入江湖 第1-20章;卷二 风云再起 21-40章」)。
170
+
171
+ 保守策略:必须出现 ≥1 个卷标记(第X卷/卷X/第X部/第X篇)才认为用户在
172
+ 分卷;且**每个卷段都必须带边界**(章号范围 / 章数 / 「N章起」开放标记)。
173
+ 缺边界的段一律判为「不是分卷信息」——「第一卷要吸引人」这类句子里的
174
+ 「第一卷」是修辞不是结构,绝不能据此把全书切成一个卷。正常写法都带边界
175
+ (「第一卷 少年初入江湖 20章」「第1-20章」「21章起」),因此不受影响。
176
+
177
+ 切段方式:按**卷标记出现位置**切(而非按行/分号),这样「卷一 甲 20章,
178
+ 卷二 乙 25章」这种同一行多卷也能正确拆开。"""
179
+ text = str(text or "")
180
+ if not text.strip():
181
+ return []
182
+ marks = list(_VOL_RE.finditer(text))
183
+ if not marks:
184
+ return []
185
+ # 同一卷号被重复提及时只认首次出现;能解析出卷号且全为升序时按卷号排序
186
+ segs, seen = [], set()
187
+ for i, m in enumerate(marks):
188
+ num = _cn2int(m.group(1) or m.group(2))
189
+ key = num if num else len(segs) + 1
190
+ if key in seen:
191
+ continue
192
+ seen.add(key)
193
+ end = marks[i + 1].start() if i + 1 < len(marks) else len(text)
194
+ segs.append((text[m.start():end], key))
195
+ out = []
196
+ for seg, _num in segs:
197
+ m = _VOL_RE.match(seg) # seg 以卷标记起头,相对位置 0
198
+ if not m:
199
+ continue
200
+ body = seg[m.end():]
201
+ op = _OPEN_RE.search(body) or _OPEN_RE.search(seg[:m.start()])
202
+ rng = _RANGE_RE.search(body)
203
+ if op:
204
+ start, end = _to_int(op.group(1)), None # 开放卷:铺到书末
205
+ chapters = None
206
+ elif rng:
207
+ start, end = _to_int(rng.group(1)), _to_int(rng.group(2))
208
+ chapters = (end - start + 1) if (start and end and end >= start) else None
209
+ else:
210
+ m2 = _COUNT_RE.search(body) or _COUNT_RE.search(seg[:m.start()])
211
+ chapters = _to_int(m2.group(1)) if m2 else None
212
+ start, end = None, None
213
+ if not (chapters or (start and end) or (op and start)):
214
+ # 该卷段没有可用的边界:整段判为不是分卷信息(见 docstring)
215
+ return []
216
+ out.append({"title": _clean_title(seg, m), "start": start,
217
+ "end": end, "chapters": chapters})
218
+ return out if out else []
219
+
220
+
221
+ def build_plan(spec, per, upto=0):
222
+ """卷规划表:按顺序铺开各卷的章号区间,返回
223
+ [{"vol": 1, "title": str, "first": 1, "last": 20}, ...](last=None 表示开放
224
+ 到书末)。
225
+
226
+ spec 优先(显式卷表);spec 用尽后若 per>0 继续按 per 等长续卷,否则把最后
227
+ 一卷视为开放卷(不再新开卷)。upto>0 时保证表至少覆盖到第 upto 章(续写批次
228
+ 需要的章号范围),不足则按 per(无 per 用末卷长度)继续补卷。
229
+ 不分卷返回 []。"""
230
+ per = norm_per(per)
231
+ spec = norm_spec(spec)
232
+ if not spec and not per:
233
+ return []
234
+ plan, prev_last = [], 0
235
+ def _push(title, first, last):
236
+ plan.append({"vol": len(plan) + 1, "title": title or "",
237
+ "first": first, "last": last})
238
+ for item in spec:
239
+ if len(plan) >= MAX_VOLS:
240
+ break
241
+ if item.get("start"):
242
+ first = item["start"]
243
+ else:
244
+ first = prev_last + 1
245
+ if item.get("end"):
246
+ last = item["end"]
247
+ elif item.get("chapters"):
248
+ last = first + item["chapters"] - 1
249
+ else:
250
+ last = None
251
+ if last is not None and last < first:
252
+ last = first
253
+ _push(item.get("title"), first, last)
254
+ prev_last = last if last is not None else first
255
+ if last is None:
256
+ # 开放卷:后面不再有卷(用户没给结尾),直接收工
257
+ return plan
258
+ # spec 之外的续卷:per 优先,否则沿用末卷长度(可预测,不会突然变成巨卷)
259
+ if plan:
260
+ fallback = per or (plan[-1]["last"] - plan[-1]["first"] + 1
261
+ if plan[-1]["last"] is not None else 0)
262
+ else:
263
+ fallback = per
264
+ if fallback and len(plan) < MAX_VOLS:
265
+ cursor = prev_last + 1
266
+ while (upto and cursor <= upto) or (not upto and not plan):
267
+ if len(plan) >= MAX_VOLS:
268
+ break
269
+ _push("", cursor, cursor + fallback - 1)
270
+ cursor += fallback
271
+ # upto 已知时,保证覆盖到 upto;未知时至少一卷
272
+ if upto and plan and plan[-1]["last"] is not None and plan[-1]["last"] < upto:
273
+ _push("", cursor, cursor + fallback - 1)
274
+ return plan
275
+
276
+
277
+ def find(plan, chapter):
278
+ """章号 → 所在卷条目(dict);不在任何卷内返回 None。"""
279
+ c = _to_int(chapter)
280
+ if c is None or c < 1 or not plan:
281
+ return None
282
+ for ent in plan:
283
+ last = ent.get("last")
284
+ if ent["first"] <= c and (last is None or c <= last):
285
+ return ent
286
+ return None
287
+
288
+
289
+ def plan_volumes(plan, start, n):
290
+ """本批 [start, start+n-1] 覆盖到的卷条目(升序去重);不分卷返回 []。"""
291
+ s, n = _to_int(start), _to_int(n)
292
+ if s is None or n is None or n <= 0 or not plan:
293
+ return []
294
+ out, seen = [], set()
295
+ for i in range(s, s + n):
296
+ ent = find(plan, i)
297
+ if ent and ent["vol"] not in seen:
298
+ seen.add(ent["vol"])
299
+ out.append(ent)
300
+ return out
301
+
302
+
303
+ def position(plan, chapter):
304
+ """章在本卷内的位置:(卷内第几章, 本卷共几章)。末卷开放时总数返回 0
305
+ (未知)。不分卷返回 (0, 0)。"""
306
+ ent = find(plan, chapter)
307
+ if not ent:
308
+ return (0, 0)
309
+ total = (ent["last"] - ent["first"] + 1) if ent["last"] is not None else 0
310
+ return (_to_int(chapter) - ent["first"] + 1, total)
311
+
312
+
313
+ def is_vol_end(plan, chapter):
314
+ """本章是否为卷末章(卷末要有本卷大高潮与卷末钩子)。开放末卷恒为 False。"""
315
+ ent = find(plan, chapter)
316
+ if not ent or ent["last"] is None:
317
+ return False
318
+ return _to_int(chapter) == ent["last"]
319
+
320
+
321
+ def fmt_plan(plan, upto=None):
322
+ """卷规划表 → 提示词/日志可读文本。upto 限定只列到该章为止的卷。"""
323
+ if not plan:
324
+ return ""
325
+ lines = []
326
+ for ent in plan:
327
+ if upto and ent["first"] > upto:
328
+ break
329
+ last = ent["last"]
330
+ rng = ("第 %d 章起(开放至书末)" % ent["first"]) if last is None \
331
+ else ("第 %d–%d 章" % (ent["first"], last))
332
+ title = ("《%s》" % ent["title"]) if ent.get("title") else "(未命名)"
333
+ lines.append("第 %d 卷 %s:%s" % (ent["vol"], title, rng))
334
+ return "\n".join(lines)
335
+
336
+
337
+ def merge_titles(plan, named):
338
+ """把模型/大纲给出的卷名合并进规划表(named 形如 {"1": {"title","arc"}} 或
339
+ {"1": "卷名"})。用户显式给的卷名优先,不被模型覆盖。返回新表。"""
340
+ if not isinstance(named, dict) or not plan:
341
+ return plan
342
+ out = []
343
+ for ent in plan:
344
+ e = dict(ent)
345
+ item = named.get(str(e["vol"])) if str(e["vol"]) in named else named.get(e["vol"])
346
+ if isinstance(item, str):
347
+ item = {"title": item}
348
+ if isinstance(item, dict):
349
+ if not e.get("title") and str(item.get("title") or "").strip():
350
+ e["title"] = str(item["title"]).strip()[:MAX_TITLE]
351
+ arc = str(item.get("arc") or "").strip()
352
+ if arc:
353
+ e["arc"] = arc[:400]
354
+ out.append(e)
355
+ return out
@@ -849,15 +849,20 @@ def fetch_modules(product_id):
849
849
  except ZenError as e:
850
850
  return {"ok": False,
851
851
  "error": "%s——也可能你的禅道没有该接口:请在禅道产品视图 URL 里查模块 ID 手工填写" % e}
852
- items = d.get("_list") if isinstance(d.get("_list"), list) else d.get("modules")
852
+ # 形状兼容器({_list} / {modules:[...]} / {id:{...}} 字典 / 裸数组)
853
+ items = _list_items(d, "modules")
854
+ if items is None:
855
+ items = d.get("_list") if isinstance(d.get("_list"), list) else None
853
856
  out = []
854
857
  for m in items or []:
855
858
  if isinstance(m, dict) and m.get("id"):
856
859
  out.append({"id": m.get("id"), "name": str(m.get("name") or "")})
857
- elif isinstance(m, dict) and str(m.get("id") or "") == "" and m.get("name"):
858
- continue
859
860
  if not out:
860
- return {"ok": False, "error": "模块清单为空或响应形状不认识——请手工填模块 ID"}
861
+ # 带响应形状摘要帮排查(顶层键名,不吐正文——用户实测反馈
862
+ # 「响应形状不认识」却看不到真实形状,没法报修)
863
+ keys = ",".join(sorted(str(k) for k in d.keys())) if isinstance(d, dict) else type(d).__name__
864
+ return {"ok": False,
865
+ "error": "模块清单为空或响应形状不认识(响应顶层键:%s)——请手工填模块 ID" % (keys or "非字典")}
861
866
  return {"ok": True, "modules": out[:200]}
862
867
 
863
868
 
package/app/main.py CHANGED
@@ -318,6 +318,11 @@ class Handler(BaseHTTPRequestHandler):
318
318
  # 偏好记忆:上次创建任务的类型/参数(新建表单预填用)
319
319
  from core import prefs
320
320
  return self._json(200, {"prefs": prefs.load()})
321
+ if path == "/api/hooks":
322
+ # 任务生命周期钩子配置(本机设置;只读列表)
323
+ from core import hooks
324
+ return self._json(200, {"hooks": hooks.load_list(),
325
+ "events": list(hooks.EVENTS)})
321
326
  if path == "/api/wxdigest/pythons":
322
327
  # 64 位 Python 候选(蜜蜂坞微信直连配置):扫描后台跑,此端点轮询
323
328
  from core import wxdigest
@@ -577,6 +582,21 @@ class Handler(BaseHTTPRequestHandler):
577
582
  return deny
578
583
  if path == "/api/tasks":
579
584
  return self._api_create_task()
585
+ if path == "/api/hooks/save":
586
+ # 生命周期钩子配置整表保存(本机设置)
587
+ from core import hooks
588
+ try:
589
+ items = hooks.save_list((self._body() or {}).get("hooks"))
590
+ return self._json(200, {"ok": True, "hooks": items})
591
+ except ValueError as e:
592
+ return self._json(400, {"error": str(e)})
593
+ if path == "/api/hooks/test":
594
+ # 设置页「测试」:手动跑一条钩子看输出(不写配置)
595
+ from core import hooks
596
+ body = self._body() or {}
597
+ ok, out = hooks.test_one(body.get("cmd") or "", body.get("event") or "task_start",
598
+ body.get("timeout_s"))
599
+ return self._json(200, {"ok": ok, "output": out})
580
600
  if path == "/api/tasks/clarify":
581
601
  return self._api_task_clarify()
582
602
  if path == "/api/hooks/run":
@@ -1778,6 +1798,15 @@ class Handler(BaseHTTPRequestHandler):
1778
1798
  attachments=saved_paths)
1779
1799
  if not msg:
1780
1800
  return self._json(400, {"error": "运行不存在或消息非法"})
1801
+ # message_submit 钩子(2026-09-22):stdout 注入文本随下一轮提示词头送达
1802
+ try:
1803
+ from core import hooks as _hooks
1804
+ hook_txt = _hooks.run_event("message_submit", text=text,
1805
+ task_id=run.get("task_id") or "", run_id=run_id)
1806
+ if hook_txt:
1807
+ store.append_run_inject(run_id, hook_txt)
1808
+ except Exception:
1809
+ pass # 钩子故障绝不挡消息投递
1781
1810
  # 终态连载 run 收到递话:自动起答疑轮(op=qa)。此前消息只会躺在信箱里
1782
1811
  # 无人消费——向已完结的连载任务「下达指令」= 石沉大海(2026-09-18 实案)
1783
1812
  task = store.get_task(run.get("task_id") or "") if run.get("task_id") else None
@@ -1980,6 +2009,15 @@ class Handler(BaseHTTPRequestHandler):
1980
2009
  ok, err, new_run = store.retry_task(task["id"])
1981
2010
  if not ok:
1982
2011
  return self._json(400, {"error": err or "无法续跑"})
2012
+ # message_submit 钩子(2026-09-22):注入文本转入新 run 的提示词头通道
2013
+ try:
2014
+ from core import hooks as _hooks
2015
+ hook_txt = _hooks.run_event("message_submit", text=text,
2016
+ task_id=task["id"], run_id=new_run["id"])
2017
+ if hook_txt:
2018
+ store.append_run_inject(new_run["id"], hook_txt)
2019
+ except Exception:
2020
+ pass
1983
2021
  if is_serial:
1984
2022
  # 答疑档:问题文本显式带上(真实调用失败会把信箱消息 drain 掉,
1985
2023
  # 换将重试时不能丢问题);retry_task 只认终态任务,active 已挡