codebee 0.1.103 → 0.1.105

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 (128) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -21
  3. package/README.md +5 -3
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/acp_client.py +457 -457
  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/backend_profiles.py +188 -188
  13. package/app/core/backup.py +470 -470
  14. package/app/core/beekeeper.py +183 -183
  15. package/app/core/benchstore.py +102 -102
  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/capability.py +144 -144
  20. package/app/core/chaptersafe.py +32 -32
  21. package/app/core/checkpoints.py +48 -5
  22. package/app/core/cleanup.py +303 -303
  23. package/app/core/command_auth.py +302 -302
  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_log.py +128 -128
  30. package/app/core/env_scrub.py +85 -85
  31. package/app/core/errorlog.py +137 -137
  32. package/app/core/eval_matrix.py +91 -91
  33. package/app/core/evaluation.py +174 -174
  34. package/app/core/failure_notify.py +290 -290
  35. package/app/core/flow_graph.py +33 -33
  36. package/app/core/goal_service.py +167 -167
  37. package/app/core/health.py +389 -389
  38. package/app/core/history.py +38 -38
  39. package/app/core/hooks.py +162 -162
  40. package/app/core/knowledge_pipeline.py +86 -86
  41. package/app/core/manager.py +2386 -2385
  42. package/app/core/mocks.py +64 -64
  43. package/app/core/notify.py +201 -201
  44. package/app/core/novel_quality.py +285 -181
  45. package/app/core/openapi.py +246 -217
  46. package/app/core/operations.py +228 -228
  47. package/app/core/paihang.py +145 -145
  48. package/app/core/pipeline.py +81 -11
  49. package/app/core/planner.py +7 -0
  50. package/app/core/portguard.py +111 -111
  51. package/app/core/presence.py +77 -77
  52. package/app/core/preview.py +293 -293
  53. package/app/core/project_memory.py +252 -206
  54. package/app/core/prompt_guard.py +78 -78
  55. package/app/core/publish/__init__.py +7 -7
  56. package/app/core/publish/auto.py +659 -659
  57. package/app/core/publish/chaprows.py +138 -138
  58. package/app/core/publish/flows-fanqie-calibrated.json +442 -442
  59. package/app/core/publish/manager.py +1185 -1185
  60. package/app/core/publish/qimao.py +193 -193
  61. package/app/core/publish/volumes.py +108 -108
  62. package/app/core/publish/ws.py +139 -139
  63. package/app/core/quality_gate.py +201 -201
  64. package/app/core/redact.py +38 -38
  65. package/app/core/registry.py +35 -0
  66. package/app/core/repeat_guard.py +129 -129
  67. package/app/core/retrieval.py +137 -137
  68. package/app/core/review_requests.py +119 -0
  69. package/app/core/runner.py +5 -1
  70. package/app/core/sandbox_runtime.py +95 -95
  71. package/app/core/selfupdate.py +384 -384
  72. package/app/core/session_log.py +161 -161
  73. package/app/core/sessions.py +312 -312
  74. package/app/core/settings.py +215 -215
  75. package/app/core/settings_schema.py +282 -282
  76. package/app/core/share_page.py +114 -114
  77. package/app/core/skill_scan.py +139 -139
  78. package/app/core/skillpacks/market/character-bible.md +66 -66
  79. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  80. package/app/core/skillpacks/market/git-workflow.md +57 -57
  81. package/app/core/skillpacks/market/release-notes.md +72 -72
  82. package/app/core/skillpacks/market/weekly-report.md +71 -71
  83. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  84. package/app/core/step_runner.py +132 -132
  85. package/app/core/story_tracking.py +275 -275
  86. package/app/core/task_compile.py +199 -199
  87. package/app/core/telemetry.py +292 -292
  88. package/app/core/tlsctx.py +64 -64
  89. package/app/core/token_meter.py +147 -147
  90. package/app/core/tool_outputs.py +107 -107
  91. package/app/core/tracing.py +207 -207
  92. package/app/core/upstream.py +41 -41
  93. package/app/core/usage.py +1038 -1038
  94. package/app/core/volumes.py +389 -389
  95. package/app/core/webhooks.py +89 -89
  96. package/app/core/wxdigest.py +865 -865
  97. package/app/core/zentao.py +2692 -2692
  98. package/app/main.py +43 -0
  99. package/app/mcp_server.py +244 -244
  100. package/app/pick_dialog.py +110 -110
  101. package/app/ui/app.js +21 -2
  102. package/app/ui/artdiff.js +74 -74
  103. package/app/ui/hive3d.js +54 -31
  104. package/app/ui/i18n.js +32 -1
  105. package/app/ui/icons/bee.svg +79 -79
  106. package/app/ui/index.html +1 -0
  107. package/app/ui/manifest.json +16 -16
  108. package/app/ui/qrcode.js +2297 -2297
  109. package/bin/tutti.js +147 -147
  110. package/package.json +1 -1
  111. package/tools/wx_reader.py +261 -261
  112. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  113. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  114. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  115. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  116. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  117. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  118. package/app/.playwright-cli/run-detail-after.png +0 -0
  119. package/app/output/_createbook_body.json +0 -1
  120. package/app/output/_probe_fq_click.py +0 -50
  121. package/app/output/_probe_fq_form.py +0 -42
  122. package/app/output/_probe_fq_modal.py +0 -55
  123. package/app/output/_probe_fq_points.py +0 -71
  124. package/app/output/_probe_fq_submit.py +0 -34
  125. package/app/output/_probe_fq_watch.py +0 -62
  126. package/app/output/workbench-clean-preview.jpg +0 -0
  127. package/app/output/workbench-reference-render.png +0 -0
  128. package/app/output/workbench-shell.png +0 -0
@@ -1,181 +1,285 @@
1
- # -*- coding: utf-8 -*-
2
- """Deterministic quality checks for signing-oriented Chinese web fiction.
3
-
4
- The LLM remains responsible for literary judgment. This module supplies
5
- stable rubric defaults and small text signals so reviewers can cite evidence
6
- for the common rejection reasons instead of relying on vague impressions.
7
- """
8
- from __future__ import annotations
9
-
10
- import re
11
-
12
-
13
- SIGNING_RUBRIC = [
14
- "开篇吸引力",
15
- "情节推进",
16
- "文风统一",
17
- "情感细腻度",
18
- "节奏控制",
19
- ]
20
-
21
- OPENING_DIM = "开篇吸引力"
22
- SIGNING_CHECKPOINT_WORDS = 20000 # 项目检查点默认值;不是通用平台规则,调用方应核对当前后台
23
-
24
- _SENTENCE_RE = re.compile(r"[^。!?!?;;\n]+[。!?!?;;]?")
25
- _DIALOGUE_RE = re.compile(r"[“‘\"「『](.*?)[”’\"」』]", re.S)
26
- _EMOTION_RE = re.compile(r"哭|泪|笑|怒|怕|颤|抖|咬|攥|握紧|心跳|呼吸|喉咙|指甲|沉默")
27
-
28
-
29
- def rubric_for(task):
30
- """Return the configured rubric, or the signing rubric for serial novels."""
31
- task = task if isinstance(task, dict) else {}
32
- raw = task.get("rubric")
33
- if isinstance(raw, str):
34
- raw = [x.strip() for x in raw.replace(",", ",").split(",") if x.strip()]
35
- if isinstance(raw, (list, tuple)):
36
- values = [str(x).strip() for x in raw if str(x).strip()]
37
- if values:
38
- return values[:8]
39
- if str(task.get("type") or "").lower() == "serial_novel":
40
- return list(SIGNING_RUBRIC)
41
- return []
42
-
43
-
44
- def opening_requirements(chapter, target_words=None):
45
- """Return concise, chapter-aware acceptance requirements for prompts."""
46
- try:
47
- chapter = int(chapter)
48
- except (TypeError, ValueError):
49
- chapter = 1
50
- if chapter == 1:
51
- return ("首章观察点(非硬门槛):记录读者何时能辨认视角人物、故事承诺与正在发生的事;"
52
- "核心卖点是否通过具体场景兑现;铺垫是否符合题材且有阅读价值;结尾是否形成自然的继续阅读理由。"
53
- "根据文本效果和作者目标评估,不因未在固定字数内出现冲突/卖点/钩子而自动判失败。")
54
- return ("本章观察点:核对与前文的承接、局面/人物/信息/情绪的变化及本章叙事功能;"
55
- "结尾是否提供符合本章节奏的收束或继续阅读理由。不强制每章反转或悬念断章。")
56
-
57
-
58
- def signal_summary(text, chapter=None):
59
- """Extract explainable text signals; no claim is made that they are scores."""
60
- text = str(text or "").replace("\r\n", "\n").strip()
61
- paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
62
- sentences = [s.strip() for s in _SENTENCE_RE.findall(text) if s.strip()]
63
- sentence_lengths = [len(re.sub(r"\s+", "", s)) for s in sentences]
64
- dialogue_chars = sum(len(x) for x in _DIALOGUE_RE.findall(text))
65
- emotion_hits = len(_EMOTION_RE.findall(text))
66
- chars = len(re.sub(r"\s+", "", text))
67
- avg = round(sum(sentence_lengths) / len(sentence_lengths), 1) if sentence_lengths else 0.0
68
- long_sentences = sum(1 for n in sentence_lengths if n >= 55)
69
- hints = []
70
- if avg >= 35 or long_sentences:
71
- hints.append("存在偏长句,复查长定语、重复说明与动作拆分")
72
- if len(paragraphs) >= 4 and chars / len(paragraphs) > 220:
73
- hints.append("段落偏长,复查手机阅读下的节奏与信息密度")
74
- # Dialogue/emotion-action counts are descriptive signals only. Their absence
75
- # is not a quality defect: narrative voice, genre, and scene purpose vary.
76
- return {
77
- "chapter": chapter,
78
- "chars": chars,
79
- "paragraphs": len(paragraphs),
80
- "sentences": len(sentences),
81
- "avg_sentence_chars": avg,
82
- "long_sentences": long_sentences,
83
- "dialogue_chars": dialogue_chars,
84
- "emotion_action_hits": emotion_hits,
85
- "hints": hints[:6],
86
- }
87
-
88
-
89
- def format_signal_summary(summary):
90
- """Render signal_summary output as a compact prompt block."""
91
- if not isinstance(summary, dict):
92
- return ""
93
- parts = [
94
- "文本信号(仅作证据,不替代文学判断):约 %s 字;%s 段;%s 句;平均句长 %s 字;长句 %s;对白 %s 字;情绪动作线索 %s 处。"
95
- % (summary.get("chars", 0), summary.get("paragraphs", 0),
96
- summary.get("sentences", 0), summary.get("avg_sentence_chars", 0),
97
- summary.get("long_sentences", 0), summary.get("dialogue_chars", 0),
98
- summary.get("emotion_action_hits", 0))]
99
- hints = summary.get("hints") or []
100
- if hints:
101
- parts.append("确定性复查提示:" + ";".join(str(x) for x in hints))
102
- return "\n".join(parts)
103
-
104
-
105
- def style_anchor_block(anchor):
106
- """Render the book-level style anchor as a prompt block; empty when absent."""
107
- anchor = str(anchor or "").strip()
108
- if not anchor:
109
- return ""
110
- return ("## 全书文风锚(大纲裁定,所有章节起草/修订/评审必须遵守;"
111
- "与其冲突处以本锚为准)\n%s\n" % anchor[:400])
112
-
113
-
114
- def fingerprint(summary):
115
- """Normalize a signal_summary into per-1000-char style metrics."""
116
- if not isinstance(summary, dict):
117
- return {}
118
- chars = max(1, int(summary.get("chars") or 0))
119
- return {
120
- "avg_sentence_chars": float(summary.get("avg_sentence_chars") or 0.0),
121
- "dialogue_per_k": round(1000.0 * float(summary.get("dialogue_chars") or 0) / chars, 1),
122
- "emotion_per_k": round(1000.0 * float(summary.get("emotion_action_hits") or 0) / chars, 1),
123
- }
124
-
125
-
126
- def _median(values):
127
- xs = sorted(float(v) for v in values if isinstance(v, (int, float)))
128
- if not xs:
129
- return 0.0
130
- mid = len(xs) // 2
131
- return xs[mid] if len(xs) % 2 else round((xs[mid - 1] + xs[mid]) / 2.0, 1)
132
-
133
-
134
- def style_deviation_notes(baselines, summary, min_baseline=2):
135
- """Cross-chapter style drift warnings from deterministic fingerprints.
136
-
137
- baselines: signal_summary dicts of the book's previous chapters. Needs at
138
- least ``min_baseline`` chapters before speaking (开篇几章没有稳定基线);
139
- all notes are evidence hints for reviewers/revisers, never scores.
140
- """
141
- notes = []
142
- base = [fingerprint(b) for b in (baselines or []) if isinstance(b, dict)]
143
- base = [b for b in base if b]
144
- if len(base) < min_baseline:
145
- return notes
146
- cur = fingerprint(summary)
147
- if not cur:
148
- return notes
149
- avg_base = _median([b["avg_sentence_chars"] for b in base])
150
- if avg_base >= 12 and cur["avg_sentence_chars"] > avg_base * 1.45:
151
- notes.append("本章平均句长 %s 字,此前各章中位 %s 字——句式明显变长,"
152
- "复查长定语、说明性插叙与节奏拖沓"
153
- % (cur["avg_sentence_chars"], avg_base))
154
- elif avg_base >= 12 and cur["avg_sentence_chars"] < avg_base * 0.62:
155
- notes.append("本章平均句长 %s 字,此前各章中位 %s 字——句式明显变碎,"
156
- "复查是否为凑字数拆散动作与因果"
157
- % (cur["avg_sentence_chars"], avg_base))
158
- dial_base = _median([b["dialogue_per_k"] for b in base])
159
- if dial_base >= 40 and cur["dialogue_per_k"] < dial_base * 0.45:
160
- notes.append("对白占比骤降(本章 %s/千字,此前中位 %s/千字)——"
161
- "复查是否换成了大段叙述,文风与推进方式是否漂移"
162
- % (cur["dialogue_per_k"], dial_base))
163
- emo_base = _median([b["emotion_per_k"] for b in base])
164
- if emo_base >= 1.5 and cur["emotion_per_k"] < emo_base * 0.4:
165
- notes.append("情绪动作密度骤降(本章 %s/千字,此前中位 %s/千字)——"
166
- "复查复杂情感是否退回「很伤心/很愤怒」式概括"
167
- % (cur["emotion_per_k"], emo_base))
168
- return notes
169
-
170
-
171
- def opening_below(means, threshold):
172
- """True when the signing opening dimension is scored below the line."""
173
- try:
174
- value = float((means or {}).get(OPENING_DIM))
175
- except (TypeError, ValueError):
176
- return False
177
- try:
178
- line = float(threshold)
179
- except (TypeError, ValueError):
180
- return False
181
- return value < line
1
+ # -*- coding: utf-8 -*-
2
+ """Deterministic quality checks for signing-oriented Chinese web fiction.
3
+
4
+ The LLM remains responsible for literary judgment. This module supplies
5
+ stable rubric defaults and small text signals so reviewers can cite evidence
6
+ for the common rejection reasons instead of relying on vague impressions.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+
13
+ SIGNING_RUBRIC = [
14
+ "开篇吸引力",
15
+ "情节推进",
16
+ "文风统一",
17
+ "情感细腻度",
18
+ "节奏控制",
19
+ ]
20
+
21
+ OPENING_DIM = "开篇吸引力"
22
+ SIGNING_CHECKPOINT_WORDS = 20000 # 项目检查点默认值;不是通用平台规则,调用方应核对当前后台
23
+
24
+ _SENTENCE_RE = re.compile(r"[^。!?!?;;\n]+[。!?!?;;]?")
25
+ _DIALOGUE_RE = re.compile(r"[“‘\"「『](.*?)[”’\"」』]", re.S)
26
+ _EMOTION_RE = re.compile(r"哭|泪|笑|怒|怕|颤|抖|咬|攥|握紧|心跳|呼吸|喉咙|指甲|沉默")
27
+
28
+ # 逐字重复检测阈值(2026-10-11 连载实案校准):整段 ≥40 字重复两遍是 113/118 章
29
+ # 实际事故形态;完整对白复现(≥6 字)同理——有意复诵必有动作/归属铺垫,评审员
30
+ # 对 113 章「这杆子歪着」的判词就是「若有意让角色复诵咀嚼,缺少铺垫」。叙述性
31
+ # 短句复沓(「早上探,往里。」5 字)是白描节奏手段,落在阈值之下不误伤。
32
+ _DUP_MIN_PARA_CHARS = 40
33
+ _DUP_MIN_SENT_CHARS = 12
34
+ _DUP_MIN_DIALOGUE_CHARS = 6
35
+ _DUP_MAX_NOTES = 4
36
+
37
+
38
+ def duplicate_notes(text):
39
+ """章内逐字重复检测(确定性,成稿事故级)。
40
+
41
+ 抓三类:整段逐字重复、整句(≥12 字去空白)逐字重复、完整对白(≥6 字去空白)
42
+ 逐字重复。返回结构化证据列表 [{kind, count, quote}],供落盘验收打回重起草与
43
+ 评审提示词引用;短句复沓等有意的节奏手段不在阈值之内,不构成证据。
44
+ """
45
+ text = str(text or "").replace("\r\n", "\n").strip()
46
+ if not text:
47
+ return []
48
+ notes = []
49
+ # ① 整段逐字重复(去空白比对;间隔段落/连排都算)
50
+ para_counts = {}
51
+ for p in re.split(r"\n\s*\n", text):
52
+ p = re.sub(r"\s+", "", p)
53
+ if len(p) >= _DUP_MIN_PARA_CHARS:
54
+ para_counts[p] = para_counts.get(p, 0) + 1
55
+ dup_paras = [p for p, c in sorted(para_counts.items(), key=lambda kv: -kv[1])
56
+ if c >= 2]
57
+ for p in dup_paras[:2]:
58
+ notes.append({"kind": "整段重复", "count": para_counts[p], "quote": p[:40]})
59
+ # ② 整句逐字重复(已被整段重复覆盖的句子不再单列,避免一条事故报一串)
60
+ sent_counts = {}
61
+ for s in _SENTENCE_RE.findall(text):
62
+ s = re.sub(r"\s+", "", s)
63
+ if len(s) >= _DUP_MIN_SENT_CHARS:
64
+ sent_counts[s] = sent_counts.get(s, 0) + 1
65
+ for s, c in sorted(sent_counts.items(), key=lambda kv: -kv[1]):
66
+ if c < 2 or len(notes) >= _DUP_MAX_NOTES:
67
+ continue
68
+ if any(s in p for p in dup_paras):
69
+ continue
70
+ notes.append({"kind": "整句重复", "count": c, "quote": s[:40]})
71
+ # ③ 完整对白逐字重复(阈值更低:成稿事故最常以「对白整句连排两遍」出现)
72
+ seen_dialogue = {n["quote"] for n in notes}
73
+ dial_counts = {}
74
+ for d in _DIALOGUE_RE.findall(text):
75
+ d = re.sub(r"\s+", "", d)
76
+ if len(d) >= _DUP_MIN_DIALOGUE_CHARS:
77
+ dial_counts[d] = dial_counts.get(d, 0) + 1
78
+ for d, c in sorted(dial_counts.items(), key=lambda kv: -kv[1]):
79
+ if c < 2 or len(notes) >= _DUP_MAX_NOTES:
80
+ continue
81
+ if any(d in q for q in seen_dialogue):
82
+ continue
83
+ notes.append({"kind": "对白重复", "count": c, "quote": d[:40]})
84
+ return notes
85
+
86
+
87
+ def format_duplicate_notes(notes):
88
+ """Render duplicate_notes evidence as human-readable lines."""
89
+ lines = []
90
+ for n in notes if isinstance(notes, list) else []:
91
+ try:
92
+ lines.append("%s %d 处:「%s」" % (n.get("kind", "重复"),
93
+ int(n.get("count") or 0),
94
+ str(n.get("quote") or "")[:40]))
95
+ except (TypeError, ValueError):
96
+ continue
97
+ return lines
98
+
99
+
100
+ def rubric_for(task):
101
+ """Return the configured rubric, or the signing rubric for serial novels."""
102
+ task = task if isinstance(task, dict) else {}
103
+ raw = task.get("rubric")
104
+ if isinstance(raw, str):
105
+ raw = [x.strip() for x in raw.replace(",", ",").split(",") if x.strip()]
106
+ if isinstance(raw, (list, tuple)):
107
+ values = [str(x).strip() for x in raw if str(x).strip()]
108
+ if values:
109
+ return values[:8]
110
+ if str(task.get("type") or "").lower() == "serial_novel":
111
+ return list(SIGNING_RUBRIC)
112
+ return []
113
+
114
+
115
+ def opening_requirements(chapter, target_words=None):
116
+ """Return concise, chapter-aware acceptance requirements for prompts."""
117
+ try:
118
+ chapter = int(chapter)
119
+ except (TypeError, ValueError):
120
+ chapter = 1
121
+ if chapter == 1:
122
+ return ("首章观察点(非硬门槛):记录读者何时能辨认视角人物、故事承诺与正在发生的事;"
123
+ "核心卖点是否通过具体场景兑现;铺垫是否符合题材且有阅读价值;结尾是否形成自然的继续阅读理由。"
124
+ "根据文本效果和作者目标评估,不因未在固定字数内出现冲突/卖点/钩子而自动判失败。")
125
+ return ("本章观察点:核对与前文的承接、局面/人物/信息/情绪的变化及本章叙事功能;"
126
+ "结尾是否提供符合本章节奏的收束或继续阅读理由。不强制每章反转或悬念断章。")
127
+
128
+
129
+ def signal_summary(text, chapter=None):
130
+ """Extract explainable text signals; no claim is made that they are scores."""
131
+ text = str(text or "").replace("\r\n", "\n").strip()
132
+ paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
133
+ sentences = [s.strip() for s in _SENTENCE_RE.findall(text) if s.strip()]
134
+ sentence_lengths = [len(re.sub(r"\s+", "", s)) for s in sentences]
135
+ dialogue_chars = sum(len(x) for x in _DIALOGUE_RE.findall(text))
136
+ emotion_hits = len(_EMOTION_RE.findall(text))
137
+ chars = len(re.sub(r"\s+", "", text))
138
+ avg = round(sum(sentence_lengths) / len(sentence_lengths), 1) if sentence_lengths else 0.0
139
+ long_sentences = sum(1 for n in sentence_lengths if n >= 55)
140
+ hints = []
141
+ if avg >= 35 or long_sentences:
142
+ hints.append("存在偏长句,复查长定语、重复说明与动作拆分")
143
+ if len(paragraphs) >= 4 and chars / len(paragraphs) > 220:
144
+ hints.append("段落偏长,复查手机阅读下的节奏与信息密度")
145
+ # 逐字重复(整段/整句/完整对白)是成稿事故而非文风选择,直接点名证据;
146
+ # 起草验收闸(pipeline._chapter_state)用同一判定打回,评审面据此复核
147
+ for line in format_duplicate_notes(duplicate_notes(text)):
148
+ hints.append("逐字重复(成稿事故,须删并保留信息量最高的一处):" + line)
149
+ # Dialogue/emotion-action counts are descriptive signals only. Their absence
150
+ # is not a quality defect: narrative voice, genre, and scene purpose vary.
151
+ return {
152
+ "chapter": chapter,
153
+ "chars": chars,
154
+ "paragraphs": len(paragraphs),
155
+ "sentences": len(sentences),
156
+ "avg_sentence_chars": avg,
157
+ "long_sentences": long_sentences,
158
+ "dialogue_chars": dialogue_chars,
159
+ "emotion_action_hits": emotion_hits,
160
+ "hints": hints[:6],
161
+ }
162
+
163
+
164
+ def format_signal_summary(summary):
165
+ """Render signal_summary output as a compact prompt block."""
166
+ if not isinstance(summary, dict):
167
+ return ""
168
+ parts = [
169
+ "文本信号(仅作证据,不替代文学判断):约 %s 字;%s 段;%s 句;平均句长 %s 字;长句 %s;对白 %s 字;情绪动作线索 %s 处。"
170
+ % (summary.get("chars", 0), summary.get("paragraphs", 0),
171
+ summary.get("sentences", 0), summary.get("avg_sentence_chars", 0),
172
+ summary.get("long_sentences", 0), summary.get("dialogue_chars", 0),
173
+ summary.get("emotion_action_hits", 0))]
174
+ hints = summary.get("hints") or []
175
+ if hints:
176
+ parts.append("确定性复查提示:" + ";".join(str(x) for x in hints))
177
+ return "\n".join(parts)
178
+
179
+
180
+ def style_anchor_block(anchor):
181
+ """Render the book-level style anchor as a prompt block; empty when absent."""
182
+ anchor = str(anchor or "").strip()
183
+ if not anchor:
184
+ return ""
185
+ return ("## 全书文风锚(大纲裁定,所有章节起草/修订/评审必须遵守;"
186
+ "与其冲突处以本锚为准)\n%s\n" % anchor[:400])
187
+
188
+
189
+ def reuse_guard_block(samples):
190
+ """Render prior chapters' opening/closing excerpts as a negative-example block.
191
+
192
+ samples: [{"chapter": int, "head": str, "tail": str}] — 起草面据此避开同款
193
+ 开笔句式与同型收束(跨章复用红线:同一动作句式至多两次,第二次必须换载体
194
+ 或升级信息量)。无样例(首批第 1 章等)返回空串,不占提示词字节。
195
+ """
196
+ rows = []
197
+ for s in samples if isinstance(samples, list) else []:
198
+ if not isinstance(s, dict):
199
+ continue
200
+ try:
201
+ ch = int(s.get("chapter"))
202
+ except (TypeError, ValueError):
203
+ continue
204
+ head = str(s.get("head") or "").strip()
205
+ tail = str(s.get("tail") or "").strip()
206
+ if head:
207
+ rows.append("- 第 %d 章开笔:「%s」" % (ch, head[:60]))
208
+ if tail:
209
+ rows.append("- 第 %d 章收束:「…%s」" % (ch, tail[-70:]))
210
+ if not rows:
211
+ return ""
212
+ return ("## 跨章复用红线(起草前必读)\n"
213
+ "此前各章已用的开笔/收束形态如下——本章不得再以同款句式开笔或同型"
214
+ "句式收束;同一动作句式全书至多两次,第二次必须换载体或升级信息量,"
215
+ "连读时章尾节奏不得趋同:\n" + "\n".join(rows) + "\n")
216
+
217
+
218
+ def fingerprint(summary):
219
+ """Normalize a signal_summary into per-1000-char style metrics."""
220
+ if not isinstance(summary, dict):
221
+ return {}
222
+ chars = max(1, int(summary.get("chars") or 0))
223
+ return {
224
+ "avg_sentence_chars": float(summary.get("avg_sentence_chars") or 0.0),
225
+ "dialogue_per_k": round(1000.0 * float(summary.get("dialogue_chars") or 0) / chars, 1),
226
+ "emotion_per_k": round(1000.0 * float(summary.get("emotion_action_hits") or 0) / chars, 1),
227
+ }
228
+
229
+
230
+ def _median(values):
231
+ xs = sorted(float(v) for v in values if isinstance(v, (int, float)))
232
+ if not xs:
233
+ return 0.0
234
+ mid = len(xs) // 2
235
+ return xs[mid] if len(xs) % 2 else round((xs[mid - 1] + xs[mid]) / 2.0, 1)
236
+
237
+
238
+ def style_deviation_notes(baselines, summary, min_baseline=2):
239
+ """Cross-chapter style drift warnings from deterministic fingerprints.
240
+
241
+ baselines: signal_summary dicts of the book's previous chapters. Needs at
242
+ least ``min_baseline`` chapters before speaking (开篇几章没有稳定基线);
243
+ all notes are evidence hints for reviewers/revisers, never scores.
244
+ """
245
+ notes = []
246
+ base = [fingerprint(b) for b in (baselines or []) if isinstance(b, dict)]
247
+ base = [b for b in base if b]
248
+ if len(base) < min_baseline:
249
+ return notes
250
+ cur = fingerprint(summary)
251
+ if not cur:
252
+ return notes
253
+ avg_base = _median([b["avg_sentence_chars"] for b in base])
254
+ if avg_base >= 12 and cur["avg_sentence_chars"] > avg_base * 1.45:
255
+ notes.append("本章平均句长 %s 字,此前各章中位 %s 字——句式明显变长,"
256
+ "复查长定语、说明性插叙与节奏拖沓"
257
+ % (cur["avg_sentence_chars"], avg_base))
258
+ elif avg_base >= 12 and cur["avg_sentence_chars"] < avg_base * 0.62:
259
+ notes.append("本章平均句长 %s 字,此前各章中位 %s 字——句式明显变碎,"
260
+ "复查是否为凑字数拆散动作与因果"
261
+ % (cur["avg_sentence_chars"], avg_base))
262
+ dial_base = _median([b["dialogue_per_k"] for b in base])
263
+ if dial_base >= 40 and cur["dialogue_per_k"] < dial_base * 0.45:
264
+ notes.append("对白占比骤降(本章 %s/千字,此前中位 %s/千字)——"
265
+ "复查是否换成了大段叙述,文风与推进方式是否漂移"
266
+ % (cur["dialogue_per_k"], dial_base))
267
+ emo_base = _median([b["emotion_per_k"] for b in base])
268
+ if emo_base >= 1.5 and cur["emotion_per_k"] < emo_base * 0.4:
269
+ notes.append("情绪动作密度骤降(本章 %s/千字,此前中位 %s/千字)——"
270
+ "复查复杂情感是否退回「很伤心/很愤怒」式概括"
271
+ % (cur["emotion_per_k"], emo_base))
272
+ return notes
273
+
274
+
275
+ def opening_below(means, threshold):
276
+ """True when the signing opening dimension is scored below the line."""
277
+ try:
278
+ value = float((means or {}).get(OPENING_DIM))
279
+ except (TypeError, ValueError):
280
+ return False
281
+ try:
282
+ line = float(threshold)
283
+ except (TypeError, ValueError):
284
+ return False
285
+ return value < line