codebee 0.1.101 → 0.1.103

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 (134) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -21
  3. package/README.md +3 -6
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/acceptance.py +100 -100
  12. package/app/core/acp_client.py +457 -426
  13. package/app/core/aiflavor.py +197 -197
  14. package/app/core/analytics.py +38 -38
  15. package/app/core/artifacts.py +148 -148
  16. package/app/core/assets.py +110 -110
  17. package/app/core/attachments.py +623 -623
  18. package/app/core/audit.py +30 -30
  19. package/app/core/backend_profiles.py +188 -188
  20. package/app/core/backup.py +470 -470
  21. package/app/core/beekeeper.py +183 -183
  22. package/app/core/benchstore.py +102 -102
  23. package/app/core/board.py +21 -17
  24. package/app/core/bookmeta.py +1036 -1036
  25. package/app/core/bookmeta_catalog.py +180 -180
  26. package/app/core/branching.py +137 -137
  27. package/app/core/builtin_agent.py +13 -4
  28. package/app/core/capability.py +144 -144
  29. package/app/core/catalog.py +28 -6
  30. package/app/core/chaptersafe.py +32 -32
  31. package/app/core/checkpoints.py +117 -31
  32. package/app/core/cleanup.py +303 -303
  33. package/app/core/command_auth.py +302 -302
  34. package/app/core/compaction.py +201 -201
  35. package/app/core/contracts.py +255 -255
  36. package/app/core/covergen.py +356 -356
  37. package/app/core/defectretro.py +43 -43
  38. package/app/core/diagnostics.py +114 -114
  39. package/app/core/dispatch.py +1 -1
  40. package/app/core/dispatch_log.py +128 -128
  41. package/app/core/env_scrub.py +85 -85
  42. package/app/core/errorlog.py +137 -137
  43. package/app/core/eval_matrix.py +91 -82
  44. package/app/core/evaluation.py +174 -174
  45. package/app/core/failure_notify.py +290 -290
  46. package/app/core/flow_graph.py +33 -33
  47. package/app/core/goal_service.py +167 -167
  48. package/app/core/health.py +389 -389
  49. package/app/core/history.py +38 -38
  50. package/app/core/hooks.py +162 -162
  51. package/app/core/knowledge_pipeline.py +86 -86
  52. package/app/core/manager.py +2385 -2385
  53. package/app/core/mcp_client.py +7 -2
  54. package/app/core/mocks.py +64 -64
  55. package/app/core/notify.py +201 -201
  56. package/app/core/novel_quality.py +181 -181
  57. package/app/core/openapi.py +217 -209
  58. package/app/core/operations.py +228 -228
  59. package/app/core/paihang.py +145 -145
  60. package/app/core/pipeline.py +6 -3
  61. package/app/core/policy.py +9 -0
  62. package/app/core/portguard.py +111 -111
  63. package/app/core/presence.py +77 -77
  64. package/app/core/preview.py +293 -293
  65. package/app/core/project_memory.py +206 -206
  66. package/app/core/prompt_guard.py +78 -78
  67. package/app/core/publish/__init__.py +7 -7
  68. package/app/core/publish/auto.py +659 -562
  69. package/app/core/publish/chaprows.py +138 -138
  70. package/app/core/publish/fanqie.py +16 -10
  71. package/app/core/publish/flow.py +29 -1
  72. package/app/core/publish/flows-fanqie-calibrated.json +442 -442
  73. package/app/core/publish/manager.py +1185 -1177
  74. package/app/core/publish/qimao.py +193 -193
  75. package/app/core/publish/volumes.py +108 -108
  76. package/app/core/publish/ws.py +139 -139
  77. package/app/core/quality_gate.py +201 -201
  78. package/app/core/redact.py +38 -38
  79. package/app/core/repeat_guard.py +129 -129
  80. package/app/core/retrieval.py +137 -137
  81. package/app/core/sandbox_runtime.py +95 -0
  82. package/app/core/selfupdate.py +384 -384
  83. package/app/core/session_log.py +161 -161
  84. package/app/core/sessions.py +312 -312
  85. package/app/core/settings.py +215 -215
  86. package/app/core/settings_schema.py +282 -282
  87. package/app/core/share_page.py +114 -114
  88. package/app/core/skill_scan.py +139 -139
  89. package/app/core/skillpacks/market/character-bible.md +66 -66
  90. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  91. package/app/core/skillpacks/market/git-workflow.md +57 -57
  92. package/app/core/skillpacks/market/release-notes.md +72 -72
  93. package/app/core/skillpacks/market/weekly-report.md +71 -71
  94. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  95. package/app/core/step_runner.py +132 -132
  96. package/app/core/store.py +13 -3
  97. package/app/core/story_tracking.py +275 -275
  98. package/app/core/task_compile.py +199 -199
  99. package/app/core/telemetry.py +292 -292
  100. package/app/core/tlsctx.py +64 -64
  101. package/app/core/token_meter.py +147 -147
  102. package/app/core/tool_outputs.py +107 -107
  103. package/app/core/tracing.py +207 -207
  104. package/app/core/upstream.py +41 -41
  105. package/app/core/usage.py +1038 -1038
  106. package/app/core/volumes.py +389 -389
  107. package/app/core/webhooks.py +89 -89
  108. package/app/core/wxdigest.py +865 -865
  109. package/app/core/zentao.py +2692 -2692
  110. package/app/main.py +1 -0
  111. package/app/mcp_server.py +244 -244
  112. package/app/output/_createbook_body.json +1 -0
  113. package/app/output/_probe_fq_click.py +50 -0
  114. package/app/output/_probe_fq_form.py +42 -0
  115. package/app/output/_probe_fq_modal.py +55 -0
  116. package/app/output/_probe_fq_points.py +71 -0
  117. package/app/output/_probe_fq_submit.py +34 -0
  118. package/app/output/_probe_fq_watch.py +62 -0
  119. package/app/output/workbench-clean-preview.jpg +0 -0
  120. package/app/output/workbench-reference-render.png +0 -0
  121. package/app/output/workbench-shell.png +0 -0
  122. package/app/pick_dialog.py +110 -110
  123. package/app/ui/app.js +165 -29
  124. package/app/ui/artdiff.js +74 -74
  125. package/app/ui/hive3d.js +491 -223
  126. package/app/ui/i18n.js +18 -1
  127. package/app/ui/icons/bee.svg +79 -79
  128. package/app/ui/index.html +24 -4
  129. package/app/ui/manifest.json +16 -16
  130. package/app/ui/qrcode.js +2297 -2297
  131. package/app/ui/style.css +548 -18
  132. package/bin/tutti.js +147 -147
  133. package/package.json +1 -1
  134. package/tools/wx_reader.py +261 -261
@@ -1,181 +1,181 @@
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
+
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