codebee 0.1.95 → 0.1.96

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.
Files changed (138) hide show
  1. package/CHANGELOG.md +725 -720
  2. package/LICENSE +21 -21
  3. package/README.md +515 -509
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/agent_context.py +100 -0
  6. package/app/core/aiflavor.py +197 -197
  7. package/app/core/analytics.py +38 -38
  8. package/app/core/artifacts.py +148 -148
  9. package/app/core/assets.py +110 -110
  10. package/app/core/attachments.py +623 -623
  11. package/app/core/audit.py +30 -30
  12. package/app/core/backup.py +470 -470
  13. package/app/core/beekeeper.py +183 -183
  14. package/app/core/benchstore.py +102 -95
  15. package/app/core/board.py +209 -209
  16. package/app/core/bookmeta.py +1036 -1036
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/builtin_agent.py +1680 -1680
  20. package/app/core/capability.py +144 -144
  21. package/app/core/chaptersafe.py +32 -32
  22. package/app/core/checkpoints.py +143 -0
  23. package/app/core/cleanup.py +303 -303
  24. package/app/core/compaction.py +201 -201
  25. package/app/core/contracts.py +255 -255
  26. package/app/core/covergen.py +356 -356
  27. package/app/core/defectretro.py +43 -43
  28. package/app/core/diagnostics.py +114 -114
  29. package/app/core/dispatch.py +198 -191
  30. package/app/core/dispatch_log.py +128 -128
  31. package/app/core/env_scrub.py +85 -85
  32. package/app/core/errorlog.py +137 -137
  33. package/app/core/eval_matrix.py +82 -0
  34. package/app/core/evalbench.py +184 -91
  35. package/app/core/evaluation.py +174 -174
  36. package/app/core/failure_notify.py +290 -290
  37. package/app/core/flow_graph.py +33 -0
  38. package/app/core/goal_service.py +167 -167
  39. package/app/core/health.py +389 -389
  40. package/app/core/history.py +38 -38
  41. package/app/core/hooks.py +162 -162
  42. package/app/core/jobs.py +25 -4
  43. package/app/core/knowledge_pipeline.py +86 -0
  44. package/app/core/manager.py +2232 -2232
  45. package/app/core/mocks.py +64 -64
  46. package/app/core/notify.py +201 -201
  47. package/app/core/novel_quality.py +181 -189
  48. package/app/core/openapi.py +177 -154
  49. package/app/core/operations.py +228 -228
  50. package/app/core/paihang.py +145 -145
  51. package/app/core/pipeline.py +43 -19
  52. package/app/core/planner.py +17 -11
  53. package/app/core/policy.py +52 -0
  54. package/app/core/portguard.py +111 -111
  55. package/app/core/presence.py +77 -77
  56. package/app/core/preview.py +293 -293
  57. package/app/core/prompt_guard.py +78 -78
  58. package/app/core/publish/__init__.py +7 -7
  59. package/app/core/publish/auto.py +26 -3
  60. package/app/core/publish/browser.py +655 -655
  61. package/app/core/publish/chaprows.py +138 -0
  62. package/app/core/publish/fanqie.py +210 -165
  63. package/app/core/publish/flow.py +753 -640
  64. package/app/core/publish/flows-fanqie-calibrated.json +349 -336
  65. package/app/core/publish/ledger.py +13 -1
  66. package/app/core/publish/manager.py +1150 -1118
  67. package/app/core/publish/qimao.py +176 -176
  68. package/app/core/publish/volumes.py +108 -0
  69. package/app/core/publish/ws.py +139 -139
  70. package/app/core/quality_gate.py +194 -194
  71. package/app/core/redact.py +38 -38
  72. package/app/core/repeat_guard.py +129 -129
  73. package/app/core/retrieval.py +137 -137
  74. package/app/core/router.py +398 -398
  75. package/app/core/runner.py +2238 -2238
  76. package/app/core/selfupdate.py +384 -384
  77. package/app/core/session_log.py +161 -161
  78. package/app/core/sessions.py +312 -312
  79. package/app/core/settings.py +20 -0
  80. package/app/core/settings_schema.py +270 -270
  81. package/app/core/share_page.py +114 -114
  82. package/app/core/skill_scan.py +139 -139
  83. package/app/core/skillpacks/fanqie-novel.md +8 -6
  84. package/app/core/skillpacks/market/character-bible.md +66 -66
  85. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  86. package/app/core/skillpacks/market/git-workflow.md +57 -57
  87. package/app/core/skillpacks/market/release-notes.md +72 -72
  88. package/app/core/skillpacks/market/weekly-report.md +71 -71
  89. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  90. package/app/core/skillpacks/qimao-signing.md +14 -11
  91. package/app/core/skills.py +32 -21
  92. package/app/core/step_runner.py +132 -132
  93. package/app/core/store.py +9 -0
  94. package/app/core/story_tracking.py +275 -275
  95. package/app/core/task_compile.py +199 -199
  96. package/app/core/telemetry.py +292 -292
  97. package/app/core/tlsctx.py +64 -64
  98. package/app/core/token_meter.py +147 -147
  99. package/app/core/tracing.py +207 -144
  100. package/app/core/upstream.py +41 -41
  101. package/app/core/usage.py +1038 -1038
  102. package/app/core/volumes.py +389 -389
  103. package/app/core/webhooks.py +89 -0
  104. package/app/core/wxdigest.py +858 -858
  105. package/app/core/zentao.py +2692 -2692
  106. package/app/main.py +104 -9
  107. package/app/mcp_server.py +244 -244
  108. package/app/pick_dialog.py +110 -110
  109. package/app/ui/app.js +286 -18
  110. package/app/ui/artdiff.js +74 -74
  111. package/app/ui/assets/community-qr.png +0 -0
  112. package/app/ui/assets/donate-alipay.jpg +0 -0
  113. package/app/ui/assets/donate-qr.jpg +0 -0
  114. package/app/ui/board.html +162 -162
  115. package/app/ui/board.js +305 -305
  116. package/app/ui/i18n.js +33 -0
  117. package/app/ui/icons/bee.svg +79 -79
  118. package/app/ui/index.html +32 -3
  119. package/app/ui/manifest.json +16 -16
  120. package/app/ui/qrcode.js +2297 -2297
  121. package/app/ui/style.css +196 -2
  122. package/bin/tutti.js +147 -147
  123. package/package.json +48 -48
  124. package/tools/wx_reader.py +261 -261
  125. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  126. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  127. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  128. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  129. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  130. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  131. package/app/.playwright-cli/run-detail-after.png +0 -0
  132. package/app/output/_createbook_body.json +0 -1
  133. package/app/output/_probe_fq_click.py +0 -50
  134. package/app/output/_probe_fq_form.py +0 -42
  135. package/app/output/_probe_fq_modal.py +0 -55
  136. package/app/output/_probe_fq_points.py +0 -71
  137. package/app/output/_probe_fq_submit.py +0 -34
  138. package/app/output/_probe_fq_watch.py +0 -62
@@ -1,389 +1,389 @@
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
356
-
357
-
358
- def norm_volumes(raw, per, start, n, plan=None):
359
- """收大纲 JSON 里模型给的卷名/卷弧光,只留本批章号真正覆盖到的卷
360
- (模型可能多写、乱写卷号,一律按卷规划表过滤;卷边界不采信模型)。
361
-
362
- raw 形如 {"1": {"title","arc"}} 或 {"1": "卷名"}(键为卷号字符串)。
363
- 卷边界优先用调用方现成的 plan(显式卷表时与主链推导完全一致),
364
- 缺省按 per 等长推导。返回 {卷号str: {"title":..., "arc":...}};
365
- raw 不合规或本批卷号一个都没命中返回 None(调用方用既有卷名兜底)。"""
366
- if not isinstance(raw, dict):
367
- return None
368
- s, cnt = _to_int(start), _to_int(n)
369
- if s is None or cnt is None or cnt <= 0:
370
- return None
371
- if plan is None:
372
- plan = build_plan(None, per, upto=s + cnt - 1)
373
- hits = {e["vol"] for e in plan_volumes(plan, s, cnt)}
374
- if not hits:
375
- return None
376
- out = {}
377
- for key, item in raw.items():
378
- vol = _to_int(str(key).strip())
379
- if vol is None or vol not in hits:
380
- continue
381
- if isinstance(item, str):
382
- item = {"title": item}
383
- if not isinstance(item, dict):
384
- continue
385
- title = str(item.get("title") or "").strip()[:MAX_TITLE]
386
- arc = str(item.get("arc") or "").strip()[:400]
387
- if title or arc:
388
- out[str(vol)] = {"title": title, "arc": arc}
389
- return out or None
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
356
+
357
+
358
+ def norm_volumes(raw, per, start, n, plan=None):
359
+ """收大纲 JSON 里模型给的卷名/卷弧光,只留本批章号真正覆盖到的卷
360
+ (模型可能多写、乱写卷号,一律按卷规划表过滤;卷边界不采信模型)。
361
+
362
+ raw 形如 {"1": {"title","arc"}} 或 {"1": "卷名"}(键为卷号字符串)。
363
+ 卷边界优先用调用方现成的 plan(显式卷表时与主链推导完全一致),
364
+ 缺省按 per 等长推导。返回 {卷号str: {"title":..., "arc":...}};
365
+ raw 不合规或本批卷号一个都没命中返回 None(调用方用既有卷名兜底)。"""
366
+ if not isinstance(raw, dict):
367
+ return None
368
+ s, cnt = _to_int(start), _to_int(n)
369
+ if s is None or cnt is None or cnt <= 0:
370
+ return None
371
+ if plan is None:
372
+ plan = build_plan(None, per, upto=s + cnt - 1)
373
+ hits = {e["vol"] for e in plan_volumes(plan, s, cnt)}
374
+ if not hits:
375
+ return None
376
+ out = {}
377
+ for key, item in raw.items():
378
+ vol = _to_int(str(key).strip())
379
+ if vol is None or vol not in hits:
380
+ continue
381
+ if isinstance(item, str):
382
+ item = {"title": item}
383
+ if not isinstance(item, dict):
384
+ continue
385
+ title = str(item.get("title") or "").strip()[:MAX_TITLE]
386
+ arc = str(item.get("arc") or "").strip()[:400]
387
+ if title or arc:
388
+ out[str(vol)] = {"title": title, "arc": arc}
389
+ return out or None