codebee 0.1.62 → 0.1.64

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 (101) hide show
  1. package/CHANGELOG.md +507 -490
  2. package/LICENSE +21 -21
  3. package/README.md +487 -486
  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/aiflavor.py +197 -197
  12. package/app/core/artifacts.py +148 -0
  13. package/app/core/attachments.py +532 -532
  14. package/app/core/automation.py +758 -758
  15. package/app/core/backup.py +470 -470
  16. package/app/core/beekeeper.py +183 -183
  17. package/app/core/board.py +209 -209
  18. package/app/core/bookmeta.py +215 -19
  19. package/app/core/bookmeta_catalog.py +180 -180
  20. package/app/core/branching.py +137 -137
  21. package/app/core/builtin_agent.py +1191 -1191
  22. package/app/core/capability.py +144 -144
  23. package/app/core/catalog.py +19 -14
  24. package/app/core/chaptersafe.py +32 -32
  25. package/app/core/cleanup.py +303 -303
  26. package/app/core/compaction.py +186 -186
  27. package/app/core/covergen.py +356 -356
  28. package/app/core/diagnostics.py +114 -114
  29. package/app/core/dispatch.py +173 -173
  30. package/app/core/dispatch_log.py +113 -113
  31. package/app/core/env_scrub.py +85 -85
  32. package/app/core/error_codes.py +64 -64
  33. package/app/core/errorlog.py +137 -137
  34. package/app/core/flows.py +355 -355
  35. package/app/core/goal_service.py +167 -167
  36. package/app/core/health.py +341 -341
  37. package/app/core/history.py +38 -38
  38. package/app/core/hooks.py +156 -156
  39. package/app/core/jobs.py +1022 -1022
  40. package/app/core/manager.py +1996 -1996
  41. package/app/core/market.py +362 -362
  42. package/app/core/market_remote.py +66 -66
  43. package/app/core/mocks.py +64 -64
  44. package/app/core/modelhub.py +3332 -3266
  45. package/app/core/notify.py +106 -106
  46. package/app/core/paihang.py +109 -109
  47. package/app/core/paths.py +2 -2
  48. package/app/core/pipeline.py +4483 -4426
  49. package/app/core/planner.py +27 -27
  50. package/app/core/portguard.py +111 -111
  51. package/app/core/preview.py +293 -293
  52. package/app/core/publish/__init__.py +7 -7
  53. package/app/core/publish/auto.py +367 -367
  54. package/app/core/publish/browser.py +533 -533
  55. package/app/core/publish/fanqie.py +128 -128
  56. package/app/core/publish/flow.py +406 -406
  57. package/app/core/publish/ledger.py +198 -198
  58. package/app/core/publish/manager.py +590 -590
  59. package/app/core/publish/qimao.py +141 -141
  60. package/app/core/publish/ws.py +139 -139
  61. package/app/core/redact.py +38 -38
  62. package/app/core/repeat_guard.py +124 -124
  63. package/app/core/router.py +20 -3
  64. package/app/core/runner.py +2019 -1991
  65. package/app/core/selfupdate.py +384 -384
  66. package/app/core/session_log.py +161 -161
  67. package/app/core/sessions.py +312 -312
  68. package/app/core/settings.py +149 -149
  69. package/app/core/settings_schema.py +262 -262
  70. package/app/core/share_page.py +114 -114
  71. package/app/core/skill_scan.py +106 -106
  72. package/app/core/skillpacks/fanqie-novel.md +80 -80
  73. package/app/core/skillpacks/market/character-bible.md +66 -66
  74. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  75. package/app/core/skillpacks/market/git-workflow.md +57 -57
  76. package/app/core/skillpacks/market/release-notes.md +72 -72
  77. package/app/core/skillpacks/market/weekly-report.md +71 -71
  78. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  79. package/app/core/step_runner.py +132 -132
  80. package/app/core/store.py +13 -70
  81. package/app/core/task_compile.py +199 -199
  82. package/app/core/telemetry.py +292 -292
  83. package/app/core/tlsctx.py +64 -64
  84. package/app/core/token_meter.py +147 -147
  85. package/app/core/volumes.py +355 -355
  86. package/app/core/wxdigest.py +858 -858
  87. package/app/core/zentao.py +2447 -2090
  88. package/app/main.py +2832 -2826
  89. package/app/pick_dialog.py +110 -110
  90. package/app/ui/app.js +13977 -13977
  91. package/app/ui/board.html +162 -162
  92. package/app/ui/board.js +305 -305
  93. package/app/ui/i18n.js +2711 -2711
  94. package/app/ui/icons/bee.svg +79 -79
  95. package/app/ui/index.html +1 -1
  96. package/app/ui/manifest.json +16 -16
  97. package/app/ui/qrcode.js +2297 -2297
  98. package/app/ui/style.css +6125 -6125
  99. package/bin/tutti.js +147 -147
  100. package/package.json +41 -41
  101. package/tools/wx_reader.py +261 -261
@@ -1,197 +1,197 @@
1
- # -*- coding: utf-8 -*-
2
- """AI 味确定性检测(借鉴 oh-story-claudecode 的去AI味思路)。
3
-
4
- 模型评审是主观分,这里补一道客观参考线:统计译制腔/套话高频词的
5
- 密度,产出报告行注入评审提示词,让评审官把「套话密度」纳入评分
6
- 参考。只做参考不做门禁——「仿佛」「缓缓」在正常小说里也常见,
7
- 误伤风险由评审官(能读懂上下文)兜底,脚本只负责指认位置与密度。
8
- """
9
- from __future__ import annotations
10
-
11
- import re
12
-
13
- # 译制腔/套话词表(按误导性排序,持续运维)
14
- AI_PHRASES = (
15
- "空气仿佛凝固", "眸中闪过一丝", "嘴角勾起一抹", "心中一动",
16
- "值得注意的是", "总而言之", "综上所述", "不难发现",
17
- "在这一刻", "仿佛在诉说着", "无声地诉说着", "见证着",
18
- "深深地看了一眼", "似乎在", "空气仿佛", "一抹微笑",
19
- "不禁", "仿佛",
20
- )
21
-
22
- # 密度告警线(每千字命中次数):超过即提示评审官重点关注
23
- ALERT_PER_KILO = 8.0
24
-
25
- # 叙事架构层信号(借鉴 sepia 2.7k★/StoryScope 研究 2026:AI 小说 93.2% 靠
26
- # 叙事架构特征检出,人工改写措辞后检出率仅从 95.5% 降到 93.9%——措辞层
27
- # 改不掉的架构级指纹才是真破绽)。三类可确定性检测的架构信号:
28
- NARRATIVE_TELLS = {
29
- "顿悟说教": ("终于明白", "这才明白", "明白了,", "意识到,自己", "懂得了",
30
- "原来,成长", "原来,生活", "原来,所谓"),
31
- "情绪身体化": ("心脏猛地", "指尖冰凉", "指尖发凉", "喉咙发紧", "喉头发紧",
32
- "胃里一阵", "胃部一阵", "后背一凉", "血液仿佛", "呼吸一滞"),
33
- "成长式收束": ("释然", "和解", "放下了", "接纳了", "与自己和解", "轻轻松了口气",
34
- "内心归于平静"),
35
- }
36
- # 架构信号告警线比措辞层低:这些表达在好小说里本就该稀缺
37
- NARRATIVE_ALERT_PER_KILO = 2.0
38
- # 「成长式收束」只在结尾才构成架构指纹(中段出现多半是剧情词),只扫尾部
39
- ENDING_SCAN_CHARS = 600
40
-
41
-
42
- def analyze(text):
43
- """统计套话命中。返回 {hits: {短语: 次数}, per_kilo: 每千字密度, alert: bool}。"""
44
- text = text or ""
45
- total = len(text)
46
- hits = {}
47
- for p in AI_PHRASES:
48
- p = p.strip()
49
- if not p:
50
- continue
51
- n = text.count(p)
52
- if n:
53
- hits[p] = n
54
- n_hits = sum(hits.values())
55
- per_kilo = round(n_hits * 1000.0 / total, 2) if total else 0.0
56
- return {"hits": hits, "per_kilo": per_kilo, "alert": per_kilo >= ALERT_PER_KILO}
57
-
58
-
59
- def narrative_analyze(text):
60
- """统计叙事架构层信号。返回 {cats: {类: 次数}, per_kilo, alert, ending_hits}。
61
-
62
- - 前两类全篇统计;「成长式收束」只统计末尾 ENDING_SCAN_CHARS 字
63
- (中段的「和解/放下」是剧情词,结尾的才是成长式收束指纹)。
64
- - per_kilo 为三类合计密度;alert 判据:合计 ≥ 告警线 或 收束类命中 ≥ 2。
65
- """
66
- text = text or ""
67
- total = len(text)
68
- cats, ending_hits = {}, 0
69
- if total:
70
- tail = text[-ENDING_SCAN_CHARS:]
71
- for cat, phrases in NARRATIVE_TELLS.items():
72
- n = 0
73
- for p in phrases:
74
- if cat == "成长式收束":
75
- n += tail.count(p)
76
- else:
77
- n += text.count(p)
78
- if n:
79
- cats[cat] = n
80
- ending_hits = cats.get("成长式收束", 0)
81
- per_kilo = round(sum(cats.values()) * 1000.0 / total, 2) if (total and cats) else 0.0
82
- alert = bool(cats) and (per_kilo >= NARRATIVE_ALERT_PER_KILO or ending_hits >= 2)
83
- return {"cats": cats, "per_kilo": per_kilo, "alert": alert, "ending_hits": ending_hits}
84
-
85
-
86
- # ---------- 节奏/钩子密度(借鉴 web-novel-pacing-analyzer:连载作品的章级
87
- # 节奏审计——读者留存看「每章有没有钩子、段落有没有淤积、是不是通篇独白」,
88
- # 这些是能确定性统计的客观量。与套话检测同路:只给评审官一条参考线,
89
- # 不直接扣分——短段落多的快节奏文也可能开篇没有冲突词。)
90
- NARRATIVE_FLOWS = ("novel", "serial_novel", "video_script")
91
-
92
- # 冲突/悬念信号词表:开篇用来看「抓不抓人」,章末用来看「留不留钩子」
93
- HOOK_WORDS = (
94
- "突然", "忽然", "竟然", "没想到", "万万没想到", "难道", "究竟", "为什么",
95
- "是谁", "怎么回事", "就在这时", "就在此时", "然而", "可是", "却见", "却听",
96
- "不对劲", "不对!", "脸色一变", "猛地", "陡然", "下一秒", "醒来", "睁开眼",
97
- "电话响", "门被推开", "传来", "砸", "吼", "喊",
98
- )
99
- DIALOG_RE = re.compile(r"[「『“\"]")
100
- OPENING_SCAN_CHARS = 300 # 开篇抓力只看前 300 字(黄金三章 / 短视频黄金 3 秒)
101
- PACING_TAIL_CHARS = 200 # 章末钩子只看最后 200 字
102
- PARA_LONG_CHARS = 400 # 单段超过此字数算「信息淤积」
103
- PARA_AVG_ALERT = 300 # 段均长告警线
104
- DIALOG_ALERT_RATIO = 0.12 # 含对话段占比低于此值算「通篇叙述独白」
105
- PACING_MIN_CHARS = 400 # 太短(摘要/片段)不做节奏统计,避免噪声
106
-
107
-
108
- def _paras(text):
109
- """切段:空行或单换行都算分隔;丢掉标题行(# 开头)与空白。"""
110
- out = []
111
- for p in re.split(r"\n\s*\n|\n", text or ""):
112
- p = p.strip()
113
- if p and not p.startswith("#"):
114
- out.append(p)
115
- return out
116
-
117
-
118
- def pacing_analyze(text):
119
- """统计叙事节奏客观量。返回 {ok, paras, avg_len, longest, dialog_ratio,
120
- open_hits, tail_hits, alerts, alert};文本过短时 ok=False。"""
121
- text = text or ""
122
- paras = _paras(text)
123
- if len(text) < PACING_MIN_CHARS or not paras:
124
- return {"ok": False}
125
- lens = [len(p) for p in paras]
126
- avg = round(sum(lens) * 1.0 / len(lens), 1)
127
- longest = max(lens)
128
- dialog = sum(1 for p in paras if DIALOG_RE.search(p))
129
- ratio = round(dialog * 1.0 / len(paras), 3)
130
- opening, tail = text[:OPENING_SCAN_CHARS], text[-PACING_TAIL_CHARS:]
131
- open_hits = sum(1 for w in HOOK_WORDS if w in opening)
132
- tail_hits = sum(1 for w in HOOK_WORDS if w in tail)
133
- alerts = []
134
- if avg >= PARA_AVG_ALERT:
135
- alerts.append("段落偏长(均 %.0f 字),阅读有淤积感" % avg)
136
- if longest >= PARA_LONG_CHARS:
137
- alerts.append("存在超长段(最长 %d 字)" % longest)
138
- if ratio < DIALOG_ALERT_RATIO:
139
- alerts.append("对话段占比偏低(%.0f%%),易成通篇独白" % (ratio * 100))
140
- if not tail_hits:
141
- alerts.append("章末 200 字无悬念信号,缺钩子")
142
- if not open_hits:
143
- alerts.append("开篇 300 字无冲突信号,开头偏平")
144
- return {"ok": True, "paras": len(paras), "avg_len": avg, "longest": longest,
145
- "dialog_ratio": ratio, "open_hits": open_hits, "tail_hits": tail_hits,
146
- "alerts": alerts, "alert": bool(alerts)}
147
-
148
-
149
- def inject_into_prompt(tpl, text, kind=None):
150
- """把确定性检测块插进评审提示词,返回新提示词(无命中原样返回)。
151
-
152
- 位置固定为「## 待评审稿件」之前——与技能包/圣经/知识库注入同一位置,
153
- 保持前缀稳定(§07 T1.2' 前缀缓存)。单稿件评审与连载逐章评审共用本
154
- 函数:此前连载逐章完全没有这条参考线(旗舰场景漏挂)。
155
- """
156
- det = report_line(text, kind)
157
- if not det:
158
- return tpl
159
- return tpl.replace("## 待评审稿件",
160
- "## 确定性检测结果(供评审参考)\n%s\n\n## 待评审稿件" % det, 1)
161
-
162
-
163
- def report_line(text, kind=None):
164
- """生成注入评审提示词的报告行(措辞层 + 叙事架构层 + 节奏层);
165
- 全部无命中返回空串。kind 为流程类型:只有叙事类流程才下发节奏检测
166
- (邮件/汇报/翻译等非叙事文体统计段落节奏没有意义)。"""
167
- lines = []
168
- r = analyze(text)
169
- if r["hits"]:
170
- top = "、".join("「%s」×%d" % (k, v)
171
- for k, v in sorted(r["hits"].items(), key=lambda x: -x[1])[:8])
172
- line = "- [AI味检测] 确定性统计:套话密度 %.1f/千字(%s)。" % (r["per_kilo"], top)
173
- if r["alert"]:
174
- line += "密度超过告警线 %.0f/千字,请重点评审译制腔与套话问题。" % ALERT_PER_KILO
175
- lines.append(line)
176
- nr = narrative_analyze(text)
177
- if nr["cats"]:
178
- top = "、".join("「%s」×%d" % (k, v) for k, v in nr["cats"].items())
179
- line = ("- [叙事架构信号] 确定性统计(措辞改写不掉的架构级指纹):"
180
- "%s,合计 %.1f/千字。" % (top, nr["per_kilo"]))
181
- if nr["alert"]:
182
- line += ("出现架构级 AI 指纹(顿悟说教/情绪只写身体反应/成长式收束),"
183
- "请评审情节结构:主题是否被叙述者直接说破、情绪是否只有身体描写、"
184
- "结尾是否靠主角想通收束。")
185
- lines.append(line)
186
- if kind in NARRATIVE_FLOWS:
187
- pr = pacing_analyze(text)
188
- if pr["ok"]:
189
- line = ("- [节奏检测] 确定性统计:%d 段(均 %.0f 字 / 最长 %d 字),"
190
- "含对话段 %.0f%%,开篇 300 字冲突信号 %d 处,章末 200 字悬念信号 %d 处。"
191
- % (pr["paras"], pr["avg_len"], pr["longest"],
192
- pr["dialog_ratio"] * 100, pr["open_hits"], pr["tail_hits"]))
193
- if pr["alert"]:
194
- line += ("提示:%s。连载留存取决于每章的钩子与段落节奏,"
195
- "请评审官结合剧情判断是否真问题。" % "、".join(pr["alerts"]))
196
- lines.append(line)
197
- return "\n".join(lines)
1
+ # -*- coding: utf-8 -*-
2
+ """AI 味确定性检测(借鉴 oh-story-claudecode 的去AI味思路)。
3
+
4
+ 模型评审是主观分,这里补一道客观参考线:统计译制腔/套话高频词的
5
+ 密度,产出报告行注入评审提示词,让评审官把「套话密度」纳入评分
6
+ 参考。只做参考不做门禁——「仿佛」「缓缓」在正常小说里也常见,
7
+ 误伤风险由评审官(能读懂上下文)兜底,脚本只负责指认位置与密度。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ # 译制腔/套话词表(按误导性排序,持续运维)
14
+ AI_PHRASES = (
15
+ "空气仿佛凝固", "眸中闪过一丝", "嘴角勾起一抹", "心中一动",
16
+ "值得注意的是", "总而言之", "综上所述", "不难发现",
17
+ "在这一刻", "仿佛在诉说着", "无声地诉说着", "见证着",
18
+ "深深地看了一眼", "似乎在", "空气仿佛", "一抹微笑",
19
+ "不禁", "仿佛",
20
+ )
21
+
22
+ # 密度告警线(每千字命中次数):超过即提示评审官重点关注
23
+ ALERT_PER_KILO = 8.0
24
+
25
+ # 叙事架构层信号(借鉴 sepia 2.7k★/StoryScope 研究 2026:AI 小说 93.2% 靠
26
+ # 叙事架构特征检出,人工改写措辞后检出率仅从 95.5% 降到 93.9%——措辞层
27
+ # 改不掉的架构级指纹才是真破绽)。三类可确定性检测的架构信号:
28
+ NARRATIVE_TELLS = {
29
+ "顿悟说教": ("终于明白", "这才明白", "明白了,", "意识到,自己", "懂得了",
30
+ "原来,成长", "原来,生活", "原来,所谓"),
31
+ "情绪身体化": ("心脏猛地", "指尖冰凉", "指尖发凉", "喉咙发紧", "喉头发紧",
32
+ "胃里一阵", "胃部一阵", "后背一凉", "血液仿佛", "呼吸一滞"),
33
+ "成长式收束": ("释然", "和解", "放下了", "接纳了", "与自己和解", "轻轻松了口气",
34
+ "内心归于平静"),
35
+ }
36
+ # 架构信号告警线比措辞层低:这些表达在好小说里本就该稀缺
37
+ NARRATIVE_ALERT_PER_KILO = 2.0
38
+ # 「成长式收束」只在结尾才构成架构指纹(中段出现多半是剧情词),只扫尾部
39
+ ENDING_SCAN_CHARS = 600
40
+
41
+
42
+ def analyze(text):
43
+ """统计套话命中。返回 {hits: {短语: 次数}, per_kilo: 每千字密度, alert: bool}。"""
44
+ text = text or ""
45
+ total = len(text)
46
+ hits = {}
47
+ for p in AI_PHRASES:
48
+ p = p.strip()
49
+ if not p:
50
+ continue
51
+ n = text.count(p)
52
+ if n:
53
+ hits[p] = n
54
+ n_hits = sum(hits.values())
55
+ per_kilo = round(n_hits * 1000.0 / total, 2) if total else 0.0
56
+ return {"hits": hits, "per_kilo": per_kilo, "alert": per_kilo >= ALERT_PER_KILO}
57
+
58
+
59
+ def narrative_analyze(text):
60
+ """统计叙事架构层信号。返回 {cats: {类: 次数}, per_kilo, alert, ending_hits}。
61
+
62
+ - 前两类全篇统计;「成长式收束」只统计末尾 ENDING_SCAN_CHARS 字
63
+ (中段的「和解/放下」是剧情词,结尾的才是成长式收束指纹)。
64
+ - per_kilo 为三类合计密度;alert 判据:合计 ≥ 告警线 或 收束类命中 ≥ 2。
65
+ """
66
+ text = text or ""
67
+ total = len(text)
68
+ cats, ending_hits = {}, 0
69
+ if total:
70
+ tail = text[-ENDING_SCAN_CHARS:]
71
+ for cat, phrases in NARRATIVE_TELLS.items():
72
+ n = 0
73
+ for p in phrases:
74
+ if cat == "成长式收束":
75
+ n += tail.count(p)
76
+ else:
77
+ n += text.count(p)
78
+ if n:
79
+ cats[cat] = n
80
+ ending_hits = cats.get("成长式收束", 0)
81
+ per_kilo = round(sum(cats.values()) * 1000.0 / total, 2) if (total and cats) else 0.0
82
+ alert = bool(cats) and (per_kilo >= NARRATIVE_ALERT_PER_KILO or ending_hits >= 2)
83
+ return {"cats": cats, "per_kilo": per_kilo, "alert": alert, "ending_hits": ending_hits}
84
+
85
+
86
+ # ---------- 节奏/钩子密度(借鉴 web-novel-pacing-analyzer:连载作品的章级
87
+ # 节奏审计——读者留存看「每章有没有钩子、段落有没有淤积、是不是通篇独白」,
88
+ # 这些是能确定性统计的客观量。与套话检测同路:只给评审官一条参考线,
89
+ # 不直接扣分——短段落多的快节奏文也可能开篇没有冲突词。)
90
+ NARRATIVE_FLOWS = ("novel", "serial_novel", "video_script")
91
+
92
+ # 冲突/悬念信号词表:开篇用来看「抓不抓人」,章末用来看「留不留钩子」
93
+ HOOK_WORDS = (
94
+ "突然", "忽然", "竟然", "没想到", "万万没想到", "难道", "究竟", "为什么",
95
+ "是谁", "怎么回事", "就在这时", "就在此时", "然而", "可是", "却见", "却听",
96
+ "不对劲", "不对!", "脸色一变", "猛地", "陡然", "下一秒", "醒来", "睁开眼",
97
+ "电话响", "门被推开", "传来", "砸", "吼", "喊",
98
+ )
99
+ DIALOG_RE = re.compile(r"[「『“\"]")
100
+ OPENING_SCAN_CHARS = 300 # 开篇抓力只看前 300 字(黄金三章 / 短视频黄金 3 秒)
101
+ PACING_TAIL_CHARS = 200 # 章末钩子只看最后 200 字
102
+ PARA_LONG_CHARS = 400 # 单段超过此字数算「信息淤积」
103
+ PARA_AVG_ALERT = 300 # 段均长告警线
104
+ DIALOG_ALERT_RATIO = 0.12 # 含对话段占比低于此值算「通篇叙述独白」
105
+ PACING_MIN_CHARS = 400 # 太短(摘要/片段)不做节奏统计,避免噪声
106
+
107
+
108
+ def _paras(text):
109
+ """切段:空行或单换行都算分隔;丢掉标题行(# 开头)与空白。"""
110
+ out = []
111
+ for p in re.split(r"\n\s*\n|\n", text or ""):
112
+ p = p.strip()
113
+ if p and not p.startswith("#"):
114
+ out.append(p)
115
+ return out
116
+
117
+
118
+ def pacing_analyze(text):
119
+ """统计叙事节奏客观量。返回 {ok, paras, avg_len, longest, dialog_ratio,
120
+ open_hits, tail_hits, alerts, alert};文本过短时 ok=False。"""
121
+ text = text or ""
122
+ paras = _paras(text)
123
+ if len(text) < PACING_MIN_CHARS or not paras:
124
+ return {"ok": False}
125
+ lens = [len(p) for p in paras]
126
+ avg = round(sum(lens) * 1.0 / len(lens), 1)
127
+ longest = max(lens)
128
+ dialog = sum(1 for p in paras if DIALOG_RE.search(p))
129
+ ratio = round(dialog * 1.0 / len(paras), 3)
130
+ opening, tail = text[:OPENING_SCAN_CHARS], text[-PACING_TAIL_CHARS:]
131
+ open_hits = sum(1 for w in HOOK_WORDS if w in opening)
132
+ tail_hits = sum(1 for w in HOOK_WORDS if w in tail)
133
+ alerts = []
134
+ if avg >= PARA_AVG_ALERT:
135
+ alerts.append("段落偏长(均 %.0f 字),阅读有淤积感" % avg)
136
+ if longest >= PARA_LONG_CHARS:
137
+ alerts.append("存在超长段(最长 %d 字)" % longest)
138
+ if ratio < DIALOG_ALERT_RATIO:
139
+ alerts.append("对话段占比偏低(%.0f%%),易成通篇独白" % (ratio * 100))
140
+ if not tail_hits:
141
+ alerts.append("章末 200 字无悬念信号,缺钩子")
142
+ if not open_hits:
143
+ alerts.append("开篇 300 字无冲突信号,开头偏平")
144
+ return {"ok": True, "paras": len(paras), "avg_len": avg, "longest": longest,
145
+ "dialog_ratio": ratio, "open_hits": open_hits, "tail_hits": tail_hits,
146
+ "alerts": alerts, "alert": bool(alerts)}
147
+
148
+
149
+ def inject_into_prompt(tpl, text, kind=None):
150
+ """把确定性检测块插进评审提示词,返回新提示词(无命中原样返回)。
151
+
152
+ 位置固定为「## 待评审稿件」之前——与技能包/圣经/知识库注入同一位置,
153
+ 保持前缀稳定(§07 T1.2' 前缀缓存)。单稿件评审与连载逐章评审共用本
154
+ 函数:此前连载逐章完全没有这条参考线(旗舰场景漏挂)。
155
+ """
156
+ det = report_line(text, kind)
157
+ if not det:
158
+ return tpl
159
+ return tpl.replace("## 待评审稿件",
160
+ "## 确定性检测结果(供评审参考)\n%s\n\n## 待评审稿件" % det, 1)
161
+
162
+
163
+ def report_line(text, kind=None):
164
+ """生成注入评审提示词的报告行(措辞层 + 叙事架构层 + 节奏层);
165
+ 全部无命中返回空串。kind 为流程类型:只有叙事类流程才下发节奏检测
166
+ (邮件/汇报/翻译等非叙事文体统计段落节奏没有意义)。"""
167
+ lines = []
168
+ r = analyze(text)
169
+ if r["hits"]:
170
+ top = "、".join("「%s」×%d" % (k, v)
171
+ for k, v in sorted(r["hits"].items(), key=lambda x: -x[1])[:8])
172
+ line = "- [AI味检测] 确定性统计:套话密度 %.1f/千字(%s)。" % (r["per_kilo"], top)
173
+ if r["alert"]:
174
+ line += "密度超过告警线 %.0f/千字,请重点评审译制腔与套话问题。" % ALERT_PER_KILO
175
+ lines.append(line)
176
+ nr = narrative_analyze(text)
177
+ if nr["cats"]:
178
+ top = "、".join("「%s」×%d" % (k, v) for k, v in nr["cats"].items())
179
+ line = ("- [叙事架构信号] 确定性统计(措辞改写不掉的架构级指纹):"
180
+ "%s,合计 %.1f/千字。" % (top, nr["per_kilo"]))
181
+ if nr["alert"]:
182
+ line += ("出现架构级 AI 指纹(顿悟说教/情绪只写身体反应/成长式收束),"
183
+ "请评审情节结构:主题是否被叙述者直接说破、情绪是否只有身体描写、"
184
+ "结尾是否靠主角想通收束。")
185
+ lines.append(line)
186
+ if kind in NARRATIVE_FLOWS:
187
+ pr = pacing_analyze(text)
188
+ if pr["ok"]:
189
+ line = ("- [节奏检测] 确定性统计:%d 段(均 %.0f 字 / 最长 %d 字),"
190
+ "含对话段 %.0f%%,开篇 300 字冲突信号 %d 处,章末 200 字悬念信号 %d 处。"
191
+ % (pr["paras"], pr["avg_len"], pr["longest"],
192
+ pr["dialog_ratio"] * 100, pr["open_hits"], pr["tail_hits"]))
193
+ if pr["alert"]:
194
+ line += ("提示:%s。连载留存取决于每章的钩子与段落节奏,"
195
+ "请评审官结合剧情判断是否真问题。" % "、".join(pr["alerts"]))
196
+ lines.append(line)
197
+ return "\n".join(lines)
@@ -0,0 +1,148 @@
1
+ """工作目录成品扫描:剪枝遍历 + 短 TTL 缓存 + 在飞合并。
2
+
3
+ 历史实现用 Path.rglob("*") 全量展开再逐文件 is_file()/stat() 过滤,几万文件的
4
+ 工作目录单次要 1.6~4 秒;而详情页/侧栏在任务运行中是轮询调用——每次轮询都是
5
+ 一次全仓扫描,UI 整体被拖卡(2026-09-24 详情页卡顿案)。这里改为 os.walk
6
+ 剪枝(跳过类目录整个不进入),并给重复轮询加 (workdir, t0, 任务状态) 键的短 TTL 缓存
7
+ 与在飞合并:并发同键只扫一次,后到者等结果。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import threading
13
+ import time
14
+
15
+ # 过滤语义与历史 rglob 版逐条对齐:
16
+ # - SKIP_DIRS:任意路径段(含文件名本身)命中即剔除;
17
+ # - BUILD_DIRS:仅祖先目录命中即剔除(文件名叫 dist 不受影响);
18
+ # - 点开头:仅祖先目录算「隐藏」(根下的 .gitignore 这类隐藏文件仍是候选)。
19
+ SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".idea", ".vscode"}
20
+
21
+ # 构建产物/依赖缓存目录:里面的文件是工具链再生成的,不是任务成品
22
+ BUILD_DIRS = {
23
+ "target", "build", "dist", "out", "bin", "obj", # 通用构建输出
24
+ "surefire", "failsafe-reports", "test-output", "reports", # 测试/报告输出
25
+ ".next", ".nuxt", ".output", ".gradle", ".gradle-home", # 前端/Gradle
26
+ "__MACOSX",
27
+ }
28
+
29
+ # CLI 自身的历史/评审中间件不属于用户成果。它们通常落在工作目录根部,
30
+ # 只按隐藏目录过滤会漏掉 Aider 的 .aider.* 与流程生成的 *_review.json。
31
+ _PROCESS_ARTIFACT_NAMES = {
32
+ ".aider.chat.history.md", ".aider.input.history", ".aider.input.history.md",
33
+ ".aider.tags.cache.v4", ".aider.tags.cache.v3", ".aider.conf.yml",
34
+ }
35
+ _PROCESS_ARTIFACT_SUFFIXES = ("_review.json", ".review.json")
36
+ _PROCESS_ARTIFACT_PREFIXES = ("tutti_prompt_", "_tutti_prompt_")
37
+
38
+ SCAN_CAP = 800 # 防超大目录拖垮接口;截断后再排序取最新
39
+ CACHE_TTL_RUNNING = 2.0 # 任务在跑:轮询热,新鲜期要短
40
+ CACHE_TTL_IDLE = 10.0 # 终态任务:详情反复开关/检查器同刷,可稍长
41
+ _CACHE_KEEP = 128 # 缓存条目上限(按时间淘汰最旧)
42
+
43
+ _now = time.monotonic # 模块级别名,测试可替换时钟
44
+
45
+ _LOCK = threading.Lock()
46
+ _CACHE = {} # (root, t0, running) -> (时间戳, files);files 恒为全量候选
47
+ _INFLIGHT = {} # (root, t0, running) -> Lock;同键并发只跑一次扫描
48
+
49
+
50
+ def _is_process_artifact(rel_name):
51
+ """判断工作目录文件是否为 CLI/评审过程产物,而非可交付成果。"""
52
+ name = str(rel_name or "").replace("\\", "/")
53
+ base = name.rsplit("/", 1)[-1].lower()
54
+ if base in _PROCESS_ARTIFACT_NAMES:
55
+ return True
56
+ if base.startswith(".") and base.startswith(".aider"):
57
+ return True
58
+ if base.endswith(_PROCESS_ARTIFACT_SUFFIXES):
59
+ return True
60
+ if base.startswith(_PROCESS_ARTIFACT_PREFIXES):
61
+ return True
62
+ return False
63
+
64
+
65
+ def _walk_files(root, t0):
66
+ """剪枝遍历:跳过类目录整个不进入;返回 mtime>=t0 的候选(无序)。"""
67
+ files = []
68
+ for dp, dirnames, filenames in os.walk(root):
69
+ dirnames[:] = [d for d in dirnames
70
+ if d not in SKIP_DIRS and d not in BUILD_DIRS
71
+ and not d.startswith(".")]
72
+ done = False
73
+ for name in filenames:
74
+ if name in SKIP_DIRS: # 历史语义:文件名本身命中也剔除
75
+ continue
76
+ try:
77
+ st = os.stat(os.path.join(dp, name))
78
+ except OSError:
79
+ continue
80
+ if st.st_mtime < t0:
81
+ continue
82
+ rel_dir = os.path.relpath(dp, root)
83
+ rel = name if rel_dir == "." else \
84
+ rel_dir.replace("\\", "/") + "/" + name
85
+ if _is_process_artifact(rel):
86
+ continue
87
+ files.append({"name": rel,
88
+ "size": st.st_size, "mtime": int(st.st_mtime)})
89
+ if len(files) >= SCAN_CAP:
90
+ done = True
91
+ break
92
+ if done:
93
+ break
94
+ return files
95
+
96
+
97
+ def _cache_get(key, ttl):
98
+ with _LOCK:
99
+ ent = _CACHE.get(key)
100
+ if ent and _now() - ent[0] < ttl:
101
+ return ent[1]
102
+ return None
103
+
104
+
105
+ def _cache_put(key, files):
106
+ with _LOCK:
107
+ _CACHE[key] = (_now(), files)
108
+ while len(_CACHE) > _CACHE_KEEP:
109
+ _CACHE.pop(min(_CACHE, key=lambda k: _CACHE[k][0]), None)
110
+
111
+
112
+ def _inflight_lock(key):
113
+ with _LOCK:
114
+ lock = _INFLIGHT.get(key)
115
+ if lock is None:
116
+ lock = _INFLIGHT[key] = threading.Lock()
117
+ return lock
118
+
119
+
120
+ def scan(root, t0, limit=200, running=False, cache=False):
121
+ """扫工作目录成品,按 mtime 新→旧取前 limit 条,返回文件字典列表。
122
+
123
+ cache=True 时同键 (root, t0, running) 在 TTL 内直接复用上次结果,运行中
124
+ 任务用短 TTL;无论是否缓存,同键在飞扫描都会合并——轮询方只会等一次扫描。
125
+ """
126
+ # 运行态和终态必须分开缓存:任务刚结束时不能沿用运行中缓存里的旧
127
+ # 文件列表,否则终态较长 TTL 会把最后写入的产物遮住。
128
+ key = (str(root), t0, bool(running))
129
+ ttl = CACHE_TTL_RUNNING if running else CACHE_TTL_IDLE
130
+ if cache:
131
+ hit = _cache_get(key, ttl)
132
+ if hit is not None:
133
+ return hit[:limit]
134
+ klock = _inflight_lock(key)
135
+ try:
136
+ with klock:
137
+ if cache: # 排到锁时他人可能已扫完入缓存
138
+ hit = _cache_get(key, ttl)
139
+ if hit is not None:
140
+ return hit[:limit]
141
+ files = sorted(_walk_files(root, t0), key=lambda f: -f["mtime"])
142
+ if cache:
143
+ _cache_put(key, files)
144
+ finally:
145
+ with _LOCK:
146
+ if _INFLIGHT.get(key) is klock:
147
+ _INFLIGHT.pop(key, None)
148
+ return files[:limit]