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.
- package/CHANGELOG.md +725 -720
- package/LICENSE +21 -21
- package/README.md +515 -509
- package/app/core/acceptance.py +100 -100
- package/app/core/agent_context.py +100 -0
- package/app/core/aiflavor.py +197 -197
- package/app/core/analytics.py +38 -38
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -110
- package/app/core/attachments.py +623 -623
- package/app/core/audit.py +30 -30
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/benchstore.py +102 -95
- package/app/core/board.py +209 -209
- package/app/core/bookmeta.py +1036 -1036
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +1680 -1680
- package/app/core/capability.py +144 -144
- package/app/core/chaptersafe.py +32 -32
- package/app/core/checkpoints.py +143 -0
- package/app/core/cleanup.py +303 -303
- package/app/core/compaction.py +201 -201
- package/app/core/contracts.py +255 -255
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -43
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +198 -191
- package/app/core/dispatch_log.py +128 -128
- package/app/core/env_scrub.py +85 -85
- package/app/core/errorlog.py +137 -137
- package/app/core/eval_matrix.py +82 -0
- package/app/core/evalbench.py +184 -91
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flow_graph.py +33 -0
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +389 -389
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/jobs.py +25 -4
- package/app/core/knowledge_pipeline.py +86 -0
- package/app/core/manager.py +2232 -2232
- package/app/core/mocks.py +64 -64
- package/app/core/notify.py +201 -201
- package/app/core/novel_quality.py +181 -189
- package/app/core/openapi.py +177 -154
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +145 -145
- package/app/core/pipeline.py +43 -19
- package/app/core/planner.py +17 -11
- package/app/core/policy.py +52 -0
- package/app/core/portguard.py +111 -111
- package/app/core/presence.py +77 -77
- package/app/core/preview.py +293 -293
- package/app/core/prompt_guard.py +78 -78
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +26 -3
- package/app/core/publish/browser.py +655 -655
- package/app/core/publish/chaprows.py +138 -0
- package/app/core/publish/fanqie.py +210 -165
- package/app/core/publish/flow.py +753 -640
- package/app/core/publish/flows-fanqie-calibrated.json +349 -336
- package/app/core/publish/ledger.py +13 -1
- package/app/core/publish/manager.py +1150 -1118
- package/app/core/publish/qimao.py +176 -176
- package/app/core/publish/volumes.py +108 -0
- package/app/core/publish/ws.py +139 -139
- package/app/core/quality_gate.py +194 -194
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +129 -129
- package/app/core/retrieval.py +137 -137
- package/app/core/router.py +398 -398
- package/app/core/runner.py +2238 -2238
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +20 -0
- package/app/core/settings_schema.py +270 -270
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +139 -139
- package/app/core/skillpacks/fanqie-novel.md +8 -6
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/skillpacks/qimao-signing.md +14 -11
- package/app/core/skills.py +32 -21
- package/app/core/step_runner.py +132 -132
- package/app/core/store.py +9 -0
- package/app/core/story_tracking.py +275 -275
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/tracing.py +207 -144
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1038 -1038
- package/app/core/volumes.py +389 -389
- package/app/core/webhooks.py +89 -0
- package/app/core/wxdigest.py +858 -858
- package/app/core/zentao.py +2692 -2692
- package/app/main.py +104 -9
- package/app/mcp_server.py +244 -244
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +286 -18
- package/app/ui/artdiff.js +74 -74
- package/app/ui/assets/community-qr.png +0 -0
- package/app/ui/assets/donate-alipay.jpg +0 -0
- package/app/ui/assets/donate-qr.jpg +0 -0
- package/app/ui/board.html +162 -162
- package/app/ui/board.js +305 -305
- package/app/ui/i18n.js +33 -0
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +32 -3
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +196 -2
- package/bin/tutti.js +147 -147
- package/package.json +48 -48
- package/tools/wx_reader.py +261 -261
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
- package/app/output/_createbook_body.json +0 -1
- package/app/output/_probe_fq_click.py +0 -50
- package/app/output/_probe_fq_form.py +0 -42
- package/app/output/_probe_fq_modal.py +0 -55
- package/app/output/_probe_fq_points.py +0 -71
- package/app/output/_probe_fq_submit.py +0 -34
- package/app/output/_probe_fq_watch.py +0 -62
package/app/core/aiflavor.py
CHANGED
|
@@ -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)
|
package/app/core/analytics.py
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
"""Task cost/quality rollups for operator views."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
from collections import defaultdict
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def summary(store):
|
|
8
|
-
by_task = defaultdict(lambda: {"runs": 0, "done": 0, "failed": 0,
|
|
9
|
-
"tokens": 0, "cost_usd": 0.0, "quality_sum": 0.0,
|
|
10
|
-
"quality_count": 0})
|
|
11
|
-
for run in store.list_runs(limit=None):
|
|
12
|
-
tid = run.get("task_id") or "_unowned"
|
|
13
|
-
row = by_task[tid]
|
|
14
|
-
row["runs"] += 1
|
|
15
|
-
row["done"] += int(run.get("status") == "done")
|
|
16
|
-
row["failed"] += int(run.get("status") == "failed")
|
|
17
|
-
row["tokens"] += int(run.get("tokens") or 0)
|
|
18
|
-
row["cost_usd"] += float(run.get("cost_usd") or 0)
|
|
19
|
-
verdict = run.get("verdict") or {}
|
|
20
|
-
quality = verdict.get("overall")
|
|
21
|
-
if isinstance(quality, (int, float)):
|
|
22
|
-
row["quality_sum"] += float(quality)
|
|
23
|
-
row["quality_count"] += 1
|
|
24
|
-
rows = []
|
|
25
|
-
for task_id, row in by_task.items():
|
|
26
|
-
item = dict(row)
|
|
27
|
-
item["task_id"] = task_id
|
|
28
|
-
item["cost_usd"] = round(item["cost_usd"], 6)
|
|
29
|
-
item["quality_avg"] = round(item.pop("quality_sum") / item.pop("quality_count"), 2) \
|
|
30
|
-
if item["quality_count"] else None
|
|
31
|
-
item.pop("quality_count", None)
|
|
32
|
-
rows.append(item)
|
|
33
|
-
rows.sort(key=lambda x: (-x["cost_usd"], x["task_id"]))
|
|
34
|
-
return {"tasks": rows, "totals": {
|
|
35
|
-
"runs": sum(x["runs"] for x in rows),
|
|
36
|
-
"tokens": sum(x["tokens"] for x in rows),
|
|
37
|
-
"cost_usd": round(sum(x["cost_usd"] for x in rows), 6),
|
|
38
|
-
}}
|
|
1
|
+
"""Task cost/quality rollups for operator views."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def summary(store):
|
|
8
|
+
by_task = defaultdict(lambda: {"runs": 0, "done": 0, "failed": 0,
|
|
9
|
+
"tokens": 0, "cost_usd": 0.0, "quality_sum": 0.0,
|
|
10
|
+
"quality_count": 0})
|
|
11
|
+
for run in store.list_runs(limit=None):
|
|
12
|
+
tid = run.get("task_id") or "_unowned"
|
|
13
|
+
row = by_task[tid]
|
|
14
|
+
row["runs"] += 1
|
|
15
|
+
row["done"] += int(run.get("status") == "done")
|
|
16
|
+
row["failed"] += int(run.get("status") == "failed")
|
|
17
|
+
row["tokens"] += int(run.get("tokens") or 0)
|
|
18
|
+
row["cost_usd"] += float(run.get("cost_usd") or 0)
|
|
19
|
+
verdict = run.get("verdict") or {}
|
|
20
|
+
quality = verdict.get("overall")
|
|
21
|
+
if isinstance(quality, (int, float)):
|
|
22
|
+
row["quality_sum"] += float(quality)
|
|
23
|
+
row["quality_count"] += 1
|
|
24
|
+
rows = []
|
|
25
|
+
for task_id, row in by_task.items():
|
|
26
|
+
item = dict(row)
|
|
27
|
+
item["task_id"] = task_id
|
|
28
|
+
item["cost_usd"] = round(item["cost_usd"], 6)
|
|
29
|
+
item["quality_avg"] = round(item.pop("quality_sum") / item.pop("quality_count"), 2) \
|
|
30
|
+
if item["quality_count"] else None
|
|
31
|
+
item.pop("quality_count", None)
|
|
32
|
+
rows.append(item)
|
|
33
|
+
rows.sort(key=lambda x: (-x["cost_usd"], x["task_id"]))
|
|
34
|
+
return {"tasks": rows, "totals": {
|
|
35
|
+
"runs": sum(x["runs"] for x in rows),
|
|
36
|
+
"tokens": sum(x["tokens"] for x in rows),
|
|
37
|
+
"cost_usd": round(sum(x["cost_usd"] for x in rows), 6),
|
|
38
|
+
}}
|