promptfigure 0.2.0
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/README.md +95 -0
- package/adapters/claude-code/SKILL.md +381 -0
- package/adapters/claude-code/install.mjs +9 -0
- package/adapters/codex/marketplace.json +14 -0
- package/adapters/codex/promptfigure/.codex-plugin/plugin.json +6 -0
- package/adapters/codex/promptfigure/skills/promptfigure-local/SKILL.md +381 -0
- package/bin/pf.mjs +1953 -0
- package/package.json +44 -0
- package/scripts/build-adapters.mjs +80 -0
- package/scripts/test-e2e.mjs +117 -0
- package/skill/promptfigure-local/SKILL.md +381 -0
- package/src/anchor.mjs +63 -0
- package/src/config.mjs +75 -0
- package/src/craft-rules.mjs +158 -0
- package/src/craft.mjs +600 -0
- package/src/doc/docx.mjs +69 -0
- package/src/doc/index.mjs +35 -0
- package/src/doc/para.mjs +54 -0
- package/src/doc/tex.mjs +161 -0
- package/src/doc/texbuild.mjs +158 -0
- package/src/docsearch.mjs +96 -0
- package/src/entity-pair.mjs +17 -0
- package/src/events.mjs +52 -0
- package/src/extract.mjs +124 -0
- package/src/figure-catalog.mjs +326 -0
- package/src/journal.mjs +67 -0
- package/src/ledger.mjs +43 -0
- package/src/next.mjs +125 -0
- package/src/plan.mjs +112 -0
- package/src/png-trim.mjs +217 -0
- package/src/quality.mjs +325 -0
- package/src/ratio.mjs +87 -0
- package/src/render.mjs +247 -0
- package/src/review.mjs +48 -0
- package/src/server.mjs +218 -0
- package/src/store.mjs +91 -0
- package/src/vectorize.mjs +54 -0
- package/tray/pf-tray.py +265 -0
- package/web/app.js +613 -0
- package/web/index.html +73 -0
- package/web/probe.html +36 -0
- package/web/style.css +208 -0
package/src/quality.mjs
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// quality.mjs — 🔴 craft 输入质量门(2026-09-21 用户反馈"别人的 AI 用着质量就不行"):
|
|
2
|
+
// 上一轮修了"冷启动会用",这轮修"用得好"。弱 AI 随手编实体、名词堆要点、漏实体——
|
|
3
|
+
// 规则层原来的 ⚠️ 走 stderr,弱模型根本无视。质量分 + blocker 拒绝让劣质输入**出不了提示词文件**。
|
|
4
|
+
//
|
|
5
|
+
// 原则(全部来自实测事故,编号可溯源;2026-09-23 用户拍板"标准太低"整体收紧;
|
|
6
|
+
// 2026-09-23 松绑轮:分层放行——收紧后新手 AI 被连环拒到用不了,改两档):
|
|
7
|
+
// 🔴 结构性必废/编造类 = 永远 blocker(两档都拦):
|
|
8
|
+
// intent 空/纯图型名、实体空、空阶段、0 阶段主链图、对照格式残缺、全部实体对不上原文
|
|
9
|
+
// 🟡 质量降级类 = 宽松档(缺省)只警告不拦,--strict / PF_STRICT=1 恢复硬拦:
|
|
10
|
+
// intent<6 词、实体 1-2 个、万能占位实体、阶段 1-2 个、孤标签阶段、零 show 句、
|
|
11
|
+
// 要点整句、实体覆盖率不足
|
|
12
|
+
// 理由:降级类出的是"不够好的图",用户在 QA 层看得见、能返工;硬拦则新手 AI
|
|
13
|
+
// 连续 exit!=0 直接放弃("别人用不了"实测反馈)。质量追求者开 strict。
|
|
14
|
+
// 纯函数:CLI 与测试共用;打分在 CLI 层调用,不动 craftPrompt 库函数。
|
|
15
|
+
|
|
16
|
+
import { parseStages } from "./craft.mjs";
|
|
17
|
+
|
|
18
|
+
const WORDS = (s) => {
|
|
19
|
+
const t = String(s || "").trim();
|
|
20
|
+
if (!t) return 0;
|
|
21
|
+
// 🔴 多语言感知(2026-09-22 第二轮):原版只数拉丁词 + 汉字/2 —— 韩语/俄语/西语等
|
|
22
|
+
// 带空格文字全被算 0 词,intent 被 <6 门槛误拦。改为:按空白 token 数,
|
|
23
|
+
// 其中汉字/假名密集 token 按 ceil(字数/2) 折算(无空格文字),其余 token 算 1 词。
|
|
24
|
+
let w = 0;
|
|
25
|
+
for (const tok of t.split(/\s+/).filter(Boolean)) {
|
|
26
|
+
const cjkish = (tok.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/gu) || []).length;
|
|
27
|
+
if (cjkish > 0) w += Math.ceil(cjkish / 2);
|
|
28
|
+
else if (/\p{L}|\p{N}/u.test(tok)) w += 1;
|
|
29
|
+
}
|
|
30
|
+
return w;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// ---------- splitEntityPair:实体双语对照(2026-09-22 用户拍板补差距②) ----------
|
|
34
|
+
// 实测痛点:中文用户把实体译成英文喂 craft(standard 乱码率低),但译完溯源就断——
|
|
35
|
+
// checkEntitySource 只能对"脚本一致"的实体对账,译文侧全部落 skipped(fail open)。
|
|
36
|
+
// 解法:实体支持「英文标签|原文词」对照格式(如 "Weighted Box Fusion|加权框融合")——
|
|
37
|
+
// - 溯源对账查 **原文侧**(确定性,不再是 skipped)
|
|
38
|
+
// - 卡面/提示词用 **英文侧**(standard 不乱码)
|
|
39
|
+
// 实现放叶子模块 entity-pair.mjs(craft.mjs 也要用,放这里会成环),这里 re-export。
|
|
40
|
+
export { splitEntityPair } from "./entity-pair.mjs";
|
|
41
|
+
import { splitEntityPair } from "./entity-pair.mjs";
|
|
42
|
+
|
|
43
|
+
export function scoreCraft({ intent = "", entities = [], stages = "", figureType = "", hasAt = true, strict = false } = {}) {
|
|
44
|
+
const blockers = [];
|
|
45
|
+
const codes = []; // 🔴 机器可读的拒绝原因码(硬拦的)
|
|
46
|
+
const softCodes = []; // 松绑轮:降级为警告的原因码(宽松档)
|
|
47
|
+
const notes = [];
|
|
48
|
+
const block = (code, msg) => { codes.push(code); blockers.push(msg); };
|
|
49
|
+
// 🟡 质量降级类:宽松档(缺省)进 notes 不拦;strict 档硬拦。softCode 仍记录供测试/诊断。
|
|
50
|
+
const soft = (code, msg) => {
|
|
51
|
+
if (strict) { codes.push(code); blockers.push(msg); }
|
|
52
|
+
else { softCodes.push(code); notes.push(msg); }
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// ---- 原文锚点(2026-09-21 "不读文章"后门封堵)----
|
|
56
|
+
// 不带 --at 时实体溯源整段被跳过 = "没读原文也能出图"的后门。CLI 传 hasAt: !!--at。
|
|
57
|
+
if (!hasAt) {
|
|
58
|
+
block(
|
|
59
|
+
"no-at",
|
|
60
|
+
'缺 --at 原文锚点——实体溯源无法进行(不读原文出图 = 编造,V4-V8 事故根源)。' +
|
|
61
|
+
'先 pf doc outline 看章节树(或 pf doc search "<关键词>" / pf doc data 找结果数据),再 pf distill --at "<章节 段号>" 抽素材。'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---- intent ----
|
|
66
|
+
if (!String(intent).trim()) {
|
|
67
|
+
block("intent-empty", "intent 为空——图意说不清,模型必编造。用一句话写清:这张图要让读者记住什么。");
|
|
68
|
+
} else {
|
|
69
|
+
if (WORDS(intent) < 6) {
|
|
70
|
+
soft("intent-short", `intent 只有 ${WORDS(intent)} 个词(<6)——写清"什么方法/什么流程/给读者什么结论",别只丢一个名词短语。`);
|
|
71
|
+
}
|
|
72
|
+
// 🔴 2026-09-23 收紧:intent 只是一串图型名词("方法框架图"/"pipeline diagram")= 没说图意,
|
|
73
|
+
// 模型只能靠编。剥掉图型词后剩不到 2 个实义词就拦。
|
|
74
|
+
const stripped = String(intent)
|
|
75
|
+
// 拉丁图型词/万能词:按词边界剥(pipeline 在 "TTA pipeline" 里也会被剥——剩余词够就不拦)
|
|
76
|
+
.replace(/\b(flow ?chart|diagram|schematic|framework diagram|architecture diagram|pipeline diagram|overview|figure|fig\.?|chart|plot|graph|illustration|pipeline|framework|architecture|workflow|method|approach|scheme)\b/gi, " ")
|
|
77
|
+
// 中文图型词/万能词:直接子串剥
|
|
78
|
+
.replace(/(流程图|框架图|架构图|结构图|示意图|原理图|机制图|模型图|流程图示|数据流图|对比图|结果图|总览图|概念图|时序图|思维导图|工作流|网络结构|流程|框架|架构|示意|图示|结构|方法)/g, " ");
|
|
79
|
+
if (WORDS(stripped) < 2) {
|
|
80
|
+
block("intent-generic", `intent「${intent}」只是图型名/名词堆砌——没说这张图具体要表达什么,模型必编造。写清:什么方法、什么流程、给读者什么结论。`);
|
|
81
|
+
} else if (WORDS(stripped) < 4) {
|
|
82
|
+
notes.push(`intent 里图型词/泛词占比过高(剥掉后剩 "${stripped.trim().replace(/\s+/g, " ")}")——再补一句"这张图要让读者记住什么",模型才知道怎么画。`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- 实体清单检查(含残缺对照 / 重复充数 / 万能占位词)----
|
|
87
|
+
const rawEnts = (Array.isArray(entities) ? entities : String(entities).split(","))
|
|
88
|
+
.map((s) => s.trim()).filter(Boolean);
|
|
89
|
+
// 🔴 2026-09-23 收紧:重复实体按同一实体计数("A,A,A" 冒充 3 个实体的绕门封掉),重复本身记 note
|
|
90
|
+
const entKey = (e) => { const p = splitEntityPair(e); return (p.src || p.label).toLowerCase().replace(/\s+/g, " "); };
|
|
91
|
+
const seenKeys = new Map();
|
|
92
|
+
const ents = [];
|
|
93
|
+
for (const e of rawEnts) {
|
|
94
|
+
const k = entKey(e);
|
|
95
|
+
if (seenKeys.has(k)) continue;
|
|
96
|
+
seenKeys.set(k, e);
|
|
97
|
+
ents.push(e);
|
|
98
|
+
}
|
|
99
|
+
const dupCount = rawEnts.length - ents.length;
|
|
100
|
+
if (dupCount > 0) notes.push(`实体清单有 ${dupCount} 个重复项(已按去重后 ${ents.length} 个计)——同一个名词写两遍不会让图更完整。`);
|
|
101
|
+
if (ents.length === 0) {
|
|
102
|
+
block("entities-empty", "entities 为空——图上无命名实体,将大量依赖占位符(Gene 1 / Step 1)。回原文逐个核对名词模块。");
|
|
103
|
+
} else if (ents.length < 3) {
|
|
104
|
+
soft("entities-few", `实体只有 ${ents.length} 个(<3)——回原文逐个核对名词模块再定稿;分组结构(多数据集/类别/基线)整组丢失是最常见漏法。`);
|
|
105
|
+
}
|
|
106
|
+
// 🔴 2026-09-23 收紧:万能占位实体(弱 AI 拿"Model/Data/Result"凑数)——这种词画进图里就是占位符图
|
|
107
|
+
const GENERIC_ENTS = new Set([
|
|
108
|
+
"model", "method", "approach", "framework", "result", "results", "data", "experiment", "experiments",
|
|
109
|
+
"analysis", "system", "input", "output", "module", "figure", "fig 1", "figure 1", "step 1", "step 2",
|
|
110
|
+
"模型", "方法", "结果", "数据", "实验", "系统", "分析", "输入", "输出", "模块", "图1", "图 1", "步骤1", "步骤 1",
|
|
111
|
+
]);
|
|
112
|
+
const genericEnts = ents.filter((e) => GENERIC_ENTS.has(splitEntityPair(e).label.toLowerCase().trim()) || GENERIC_ENTS.has(splitEntityPair(e).src || ""));
|
|
113
|
+
if (genericEnts.length === ents.length && ents.length > 0) {
|
|
114
|
+
// 全部实体都是万能占位词 = 明显没读原文,两档都拦
|
|
115
|
+
block("entity-generic", `这些实体是万能占位词(${genericEnts.join("、")})——"Model/Data/Result"这类词哪个论文都能用,说明没回原文抽真名词。用 pf distill 输出里的真实名词模块替换(如 "Weighted Box Fusion|加权框融合")。若论文里的模块真就叫这个名(极少数),加 --force 放行,理由落审计。`);
|
|
116
|
+
} else if (genericEnts.length) {
|
|
117
|
+
// 混用(部分真词 + 部分占位词):宽松档警告(图能出,质量打折),strict 档拦
|
|
118
|
+
soft("entity-generic", `这些实体是万能占位词(${genericEnts.join("、")})——图能出,但占位词标签会让读者觉得是模板图。建议用 pf distill 的真实名词模块替换。`);
|
|
119
|
+
}
|
|
120
|
+
// 🔴 残缺对照(渗透实测:"Label|" 一侧为空 → 英文侧连 "|" 印进卡面、原文侧静默丢)
|
|
121
|
+
const badPairs = ents.filter((e) => e.includes("|") && splitEntityPair(e).invalid);
|
|
122
|
+
if (badPairs.length) {
|
|
123
|
+
block("entity-pair-bad", `这些实体对照格式残缺(${badPairs.join("、")})——对照格式必须两侧都有:「英文标签|原文词」。一侧写不出来就别用对照(但纯英文实体会无法自动对账)。`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---- stages ----
|
|
127
|
+
const stageList = parseStages(stages);
|
|
128
|
+
// 🔴 2026-09-23 收紧:flowchart 也是主链图(CLI 缺省图型就是它)——和 pipeline/architecture/
|
|
129
|
+
// dataflow/mechanism 一样要求 ≥3 阶段;原来是 length>0 才查,"一个阶段都不给"反而漏过。
|
|
130
|
+
const chainTypes = new Set(["pipeline", "architecture", "dataflow", "mechanism", "flowchart"]);
|
|
131
|
+
if (chainTypes.has(figureType) && stageList.length === 0) {
|
|
132
|
+
block("stages-none", `${figureType} 类图一个阶段都没给——图模型只能按图型惯例摆空架子,每个区域画什么全靠编。给主链至少 3 步(--stages "标题 | show 画法句 || …")。`);
|
|
133
|
+
} else if (chainTypes.has(figureType) && stageList.length < 3) {
|
|
134
|
+
soft("stages-few", `${figureType} 类图只有 ${stageList.length} 个阶段(<3)——主链建议至少 3 步(输入→核心变换→输出),碎成两块读者看不懂流程。`);
|
|
135
|
+
}
|
|
136
|
+
const isShowBullet = (b) => /^(show|draw|画|示意|绘制)/i.test(b.trim());
|
|
137
|
+
const empty = stageList.filter((s) => !s.bullets.length);
|
|
138
|
+
if (stageList.length > 0 && empty.length) {
|
|
139
|
+
block("stage-empty", `阶段「${empty.map((s) => s.title).join("、")}」没有内容要点——空标题盒子必出。每阶段给 2-4 条:1-2 条 show 画法 + 0-2 条 ≤5 词短标签。`);
|
|
140
|
+
}
|
|
141
|
+
// 🔴 单条非画法要点 = 孤标签盒子(松绑轮:降级为警告——盒子有内容,只是可能缺细节)
|
|
142
|
+
const thin = stageList.filter((s) => s.bullets.length === 1 && !isShowBullet(s.bullets[0]));
|
|
143
|
+
if (stageList.length > 0 && thin.length) {
|
|
144
|
+
soft("stage-thin", `阶段「${thin.map((s) => s.title).join("、")}」只有 1 条短标签、没有画法句——补一条 show 画法句细节会好很多(模型现在只能按标签猜着画)。`);
|
|
145
|
+
}
|
|
146
|
+
// 全图没有一条 show 画法句 → 模型只能拿到标签自己摆布局(松绑轮:降级为警告——内容还在,布局质量打折)
|
|
147
|
+
if (chainTypes.has(figureType) && stageList.length > 0) {
|
|
148
|
+
const showTotal = stageList.reduce((n, s) => n + s.bullets.filter(isShowBullet).length, 0);
|
|
149
|
+
if (showTotal === 0) {
|
|
150
|
+
soft("no-show", `全部阶段都没有 show 画法句——模型会按标签自己发明布局。每个阶段写 1 条 show 开头的画法句(画什么、摆在哪、怎么连)细节会好很多。`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---- 要点是完整句(V11 事故;2026-09-22 多语言化;2026-09-23 阈值 14→10 收紧)----
|
|
155
|
+
// 语言无关的两条判据:① 非画法句超 10 词;② 带空格文字(韩/俄/西…)整句通常 <14 词 ——
|
|
156
|
+
// 补"句末标点结尾且 >5 词"判据(标签不该带句号)。韩语"다"结尾无标点的场景交给 Q2 + AI 判断层。
|
|
157
|
+
const sentencey = [];
|
|
158
|
+
for (const s of stageList) {
|
|
159
|
+
for (const b of s.bullets) {
|
|
160
|
+
// show/draw 开头的画法句豁免(那是给模型的画法指令,不是要印的文字)
|
|
161
|
+
if (isShowBullet(b)) continue;
|
|
162
|
+
const longSentence = WORDS(b) > 10;
|
|
163
|
+
const punctuated = /[.。.!?!?…؛۔]$/.test(b.trim()) && WORDS(b) > 5;
|
|
164
|
+
if (longSentence || punctuated) sentencey.push(b);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (sentencey.length) {
|
|
168
|
+
soft("bullet-sentence", `这些框内要点是完整句(${sentencey.map((b) => `「${b.slice(0, 40)}…」`).join(";")})——图模型可能把它们原样印进卡面(V11 事故)。要点改 ≤5 词短标签;说明挪进 show 句。QA 核验 Q2 会再抓一次。`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---- 实体覆盖率(对照格式按英文侧查 stages——卡面印的是英文标签)----
|
|
172
|
+
if (ents.length > 0 && stageList.length > 0) {
|
|
173
|
+
const hay = stages.toLowerCase();
|
|
174
|
+
// 命中 = 整串子串,或实体的全部词元都出现(容忍词序变化:"Noisy Boxes" ≈ "noisy candidate boxes")
|
|
175
|
+
const covered = (e) => {
|
|
176
|
+
const el = splitEntityPair(e).label.toLowerCase();
|
|
177
|
+
if (hay.includes(el)) return true;
|
|
178
|
+
const toks = el.split(/\s+/).filter((t) => t.length >= 3);
|
|
179
|
+
return toks.length > 0 && toks.every((t) => hay.includes(t));
|
|
180
|
+
};
|
|
181
|
+
const missing = ents.filter((e) => !covered(e));
|
|
182
|
+
// 🔴 红队实测(2026-09-23 fuzz):10KB 实体整串打印刷屏——统一截断展示
|
|
183
|
+
const show = (list) => list.map((e) => (e.length > 40 ? e.slice(0, 40) + "…" : e)).slice(0, 5).join("、");
|
|
184
|
+
const coverage = 1 - missing.length / ents.length;
|
|
185
|
+
// 松绑轮分层:≥50% 列了却没画的降级为警告(图能出、缺元素 QA 层可见);<50% = 一半内容没画,两档都拦
|
|
186
|
+
if (coverage < 0.5) {
|
|
187
|
+
block("entity-coverage", `实体覆盖率 ${(coverage * 100) | 0}%(<50%)——这些实体列了却没画进任何阶段:${show(missing)}${missing.length > 5 ? " 等" : ""}。要么在 stages 里给它们位置,要么从清单删掉。`);
|
|
188
|
+
} else if (coverage < 0.8) {
|
|
189
|
+
soft("entity-coverage", `实体覆盖率 ${(coverage * 100) | 0}%——这些实体列了却没画进任何阶段:${show(missing)}。要么在 stages 里给它们位置,要么从清单删掉(缺元素 QA 层 Q4 会抓)。`);
|
|
190
|
+
} else if (missing.length > 0) {
|
|
191
|
+
notes.push(`实体未出现在 stages(确认是否有意省略):${show(missing)}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- 软性提醒 ----
|
|
196
|
+
if (stageList.length > 8) notes.push(`阶段 ${stageList.length} 个(>8)——双栏横幅塞不下,读者扫不完;考虑合并或拆两张。`);
|
|
197
|
+
for (const s of stageList) {
|
|
198
|
+
if (s.bullets.length > 6) notes.push(`阶段「${s.title}」要点 ${s.bullets.length} 条(>6)——卡面会挤爆,砍到 2-4 条。`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const score = Math.max(0, 100 - blockers.length * 15 - notes.length * 5);
|
|
202
|
+
return { score, blockers, codes, softCodes, notes, stageCount: stageList.length, entityCount: ents.length };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------- fixExamples:拒绝原因 → 改法示范(2026-09-22 小白增强) ----------
|
|
206
|
+
// 实测弱 AI 看"规则描述"不会改,看"❌/✅ 对照示范"才会改。按 code 出对照示例,
|
|
207
|
+
// 纯函数,CLI 在 craft 拒绝路径打印。
|
|
208
|
+
export function fixExamples(codes = []) {
|
|
209
|
+
const E = {
|
|
210
|
+
"no-at": '🧭 示范: 先跑 pf distill --at "§3.2 ¶2"(位置从 pf doc outline 选)——之后 craft 把同一个 --at 原样带上,实体从 distill 输出里挑',
|
|
211
|
+
"intent-empty": '🧭 intent 示范 ✅ "Two-stage defect detection pipeline: coarse filter discards background, fine grader scores candidates"(说清结构+给读者的结论)',
|
|
212
|
+
"intent-short": '🧭 intent 示范 ❌ "检测流程图" ✅ "两级检测流程:粗筛先丢背景块,精判给剩余候选打分"(写清方法/流程/结论,别只给名词短语)',
|
|
213
|
+
"intent-generic": '🧭 intent 示范 ❌ "方法框架图" / "pipeline diagram"(纯图型名=没说图意)✅ "UAPL 纯化框架:TTA 扩展假设、WBF 合并证据、CAC-T 剔除上下文不一致框"(图型词之外至少说出方法名+要表达的结构/结论)',
|
|
214
|
+
"entities-empty": '🧭 entities 示范: 回 pf distill 的候选实体里挑真实名词模块,如 ✅ --entities "Input Image,Coarse Filter,Fine Grader"(禁止自己编;想用英文标签但原文是中文 → 对照格式 "Coarse Filter|粗筛模块")',
|
|
215
|
+
"entities-few": '🧭 entities 示范: ❌ --entities "Model,Input" ✅ --entities "Input Image,Coarse Filter|粗筛模块,Fine Grader|精判模块"(回原文逐个数名词模块;多数据集/多基线是整组出现的,漏一组图就不完整)',
|
|
216
|
+
"entity-generic": '🧭 实体示范 ❌ --entities "Model,Data,Result"(万能占位词,哪篇论文都能用=没读原文)✅ --entities "Input Image,Weighted Box Fusion|加权框融合,CAC-T"(从 pf distill 输出里挑论文里真实存在的名词模块)',
|
|
217
|
+
"entity-pair-bad": '🧭 对照格式示范 ❌ "Label|"(一侧为空)✅ "Coarse Filter|粗筛模块"(英文标签|原文词,两侧都必须有;没有对应原文词的标签直接删掉)',
|
|
218
|
+
"stages-few": '🧭 stages 示范(≥3 阶段): ✅ "Input Image | show 3 rail-surface thumbnails || Coarse Filter | discard background patches; keep candidates || Fine Grader | transformer scoring; per-patch score"',
|
|
219
|
+
"stage-empty": '🧭 每阶段给 2-4 条要点: ✅ "标题 | show 画法句(1-2条); 短标签; 短标签" —— 只有标题的空盒子必出废图',
|
|
220
|
+
"stage-thin": '🧭 孤标签盒子示范 ❌ "粗筛 | keep candidates"(只有 1 条标签,模型不知道怎么画)✅ "粗筛 | show background patches falling through a sieve-like gate; keep candidates"(补 1 条 show 画法句)',
|
|
221
|
+
"no-show": '🧭 画法句示范: 每个阶段至少 1 条 show 开头的句子,❌ "编码器 | 64 通道; 残差连接" ✅ "编码器 | show a stack of 3 blocks labeled with channel numbers, arrows flowing left to right; 残差连接"',
|
|
222
|
+
"bullet-sentence": '🧭 要点示范 ❌ "connected components merge by IoU voting" ✅ "show merge arrows; IoU voting"(≤5 词短标签;说明挪进 show 开头的画法句)',
|
|
223
|
+
"entity-coverage": '🧭 列了就要画: 把点名缺失的实体写进对应阶段的要点/标题里;确实不画的从 --entities 删掉',
|
|
224
|
+
};
|
|
225
|
+
return [...new Set(codes)].map((c) => E[c]).filter(Boolean);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------- isQuantifiedNote:QA PASS 依据必须量化(2026-09-23 收紧轮) ----------
|
|
229
|
+
// "看起来没问题"/"全项通过"这类无数字 note = 凭印象核验 = 无效核验(V11 放水教训)。
|
|
230
|
+
// PASS 写回与审批门禁共用本判定:note 必须含至少一个数字(元素个数/色相数/留白占比/字号 px…)。
|
|
231
|
+
export function isQuantifiedNote(note) {
|
|
232
|
+
return /\d/.test(String(note || ""));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------- qaState:QA 记录是否对最新版有效 ----------
|
|
236
|
+
// 🔴 审批门禁的底座(2026-09-21 "别放水"定规):approve 必须有**最新版本**的 QA 记录。
|
|
237
|
+
// 出新版本后旧 QA 自动作废(render.mjs 会清 fig.qa),没有新核验就不许通过。
|
|
238
|
+
export function qaState(fig) {
|
|
239
|
+
const vCount = fig?.versions?.length || 0;
|
|
240
|
+
const qa = fig?.qa || null;
|
|
241
|
+
const fresh = !!qa && Number(qa.version) === vCount;
|
|
242
|
+
return { qa, fresh, version: vCount, verdict: fresh ? qa.verdict : null };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------- checkEntitySource:实体溯源核对(craft --at 时可用) ----------
|
|
246
|
+
// 🔴 2026-09-21 "别人 AI 用着质量不行"第 2 层:quality 门只查「实体进了 stages」,
|
|
247
|
+
// 查不了「实体是不是编的」。--at 给了锚点时,把实体拿回原文对账——
|
|
248
|
+
// 全部实体(可核对子集中)都不在原文出现 = 疑似编造,blocker 拒绝出提示词。
|
|
249
|
+
// 纯函数:CLI 与测试共用。语言规则(实测弱模型会把中文实体译成英文再喂 craft):
|
|
250
|
+
// - 实体是「英文标签|原文词」对照格式 → 查**原文侧**(确定性对账,这格式就是为此设计)
|
|
251
|
+
// - 实体含 CJK 且原文含 CJK → 子串匹配
|
|
252
|
+
// - 实体纯拉丁且原文纯拉丁 → 整串大小写不敏感子串,或全部 ≥3 字符词元命中(容忍词序)
|
|
253
|
+
// - 脚本不一致(实体已翻译且没给对照)→ 无法确定性核对,跳过不算违规(只提示 AI 自行确认)
|
|
254
|
+
export function checkEntitySource({ entities = [], sourceText = "" } = {}) {
|
|
255
|
+
const src = String(sourceText || "");
|
|
256
|
+
const srcLower = src.toLowerCase();
|
|
257
|
+
const srcHasCJK = /[\u4e00-\u9fff]/.test(src);
|
|
258
|
+
const checked = [];
|
|
259
|
+
const skipped = [];
|
|
260
|
+
const missing = [];
|
|
261
|
+
for (const raw of Array.isArray(entities) ? entities : String(entities).split(/[,,]/)) {
|
|
262
|
+
let e = String(raw).trim();
|
|
263
|
+
if (!e) continue;
|
|
264
|
+
// 🔴 红队实测(2026-09-23 审计 B):带引号实体会被这里用原始串对账直接判"编造",
|
|
265
|
+
// 永远到不了 craft 的剥引号层——对账前先剥引号(与 craftPrompt 归一行为一致)
|
|
266
|
+
const quoteStripped = /^".*"$/.test(e) || /^“.*”$/.test(e);
|
|
267
|
+
if (quoteStripped) e = e.slice(1, -1).trim();
|
|
268
|
+
const pair = splitEntityPair(e);
|
|
269
|
+
// 对照格式:溯源对账查原文侧(显示名用 英文标签|原文 方便人看)
|
|
270
|
+
if (pair.src) {
|
|
271
|
+
const s = pair.src;
|
|
272
|
+
const sCJK = /[\u4e00-\u9fff]/.test(s);
|
|
273
|
+
if (sCJK !== srcHasCJK) { skipped.push(e); continue; } // 对照侧连语言都对不上 = 填错了
|
|
274
|
+
checked.push(e);
|
|
275
|
+
if (sCJK) { if (!src.includes(s)) missing.push(e); continue; }
|
|
276
|
+
const sl = s.toLowerCase();
|
|
277
|
+
if (srcLower.includes(sl)) continue;
|
|
278
|
+
const stoks = sl.split(/\s+/).filter((t) => t.length >= 3);
|
|
279
|
+
if (!(stoks.length > 0 && stoks.every((t) => srcLower.includes(t)))) missing.push(e);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const eCJK = /[\u4e00-\u9fff]/.test(e);
|
|
283
|
+
if (eCJK !== srcHasCJK) { skipped.push(e); continue; }
|
|
284
|
+
checked.push(e);
|
|
285
|
+
if (eCJK) {
|
|
286
|
+
if (!src.includes(e)) missing.push(e);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const el = e.toLowerCase();
|
|
290
|
+
if (srcLower.includes(el)) continue;
|
|
291
|
+
const toks = el.split(/\s+/).filter((t) => t.length >= 3);
|
|
292
|
+
if (!(toks.length > 0 && toks.every((t) => srcLower.includes(t)))) missing.push(e);
|
|
293
|
+
}
|
|
294
|
+
const result = { checked, skipped, missing, blocker: null, note: null };
|
|
295
|
+
// 🔴 渗透实测(2026-09-22 子智能体 B):对照格式是**确定性声明**——原文侧没对上 = 伪造出处
|
|
296
|
+
// ("Universe|阶段"蹭一个无关真词当保护伞)。逐条硬拦,不许降级成提示。
|
|
297
|
+
const pairMissing = missing.filter((e) => splitEntityPair(e).src);
|
|
298
|
+
if (pairMissing.length > 0) {
|
|
299
|
+
result.blocker =
|
|
300
|
+
`这些双语对照实体的原文侧没有在原文出现(${pairMissing.join("、")})——对照格式 = 确定性声明,对不上就是伪造出处。` +
|
|
301
|
+
`回 pf distill --at "<章节 段号>" 核对真实原文词再填;没有对应原文词的标签删掉。`;
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
if (checked.length >= 2 && missing.length === checked.length) {
|
|
305
|
+
result.blocker =
|
|
306
|
+
`全部实体(${checked.join("、")})都没有在原文出现——疑似编造实体(V4-V8 事故:没读原文直接编内容)。` +
|
|
307
|
+
`回 pf distill --at "<章节 段号>" 从原文抽候选实体,逐个核对后再 craft。`;
|
|
308
|
+
} else if (checked.length > 0 && missing.length > 0) {
|
|
309
|
+
result.note = `这些实体没在原文对上(确认拼写/是否真的来自原文):${missing.join("、")}`;
|
|
310
|
+
}
|
|
311
|
+
// 🔴 2026-09-21 子智能体 F 实测:混合场景(部分实体已译英文)只提示 missing 的、
|
|
312
|
+
// 跳过的闭口不提——AI 以为全部对过账。跳过的也要点名,把确认责任说清。
|
|
313
|
+
if (checked.length > 0 && skipped.length > 0) {
|
|
314
|
+
result.note = (result.note ? result.note + " " : "") +
|
|
315
|
+
`另有 ${skipped.length} 个实体语言与原文不同、插件无法自动对账(${skipped.join("、")})——改用对照格式「英文标签|原文词」即可自动对账,或每个都说得出原文出处(§¶)。`;
|
|
316
|
+
}
|
|
317
|
+
if (checked.length === 0 && skipped.length > 0) {
|
|
318
|
+
// 🔴 红队实测:这条软提示是"fail open"——翻译合法但编造也长这样。措辞必须压实:
|
|
319
|
+
result.note =
|
|
320
|
+
`实体与原文语言不同(${skipped.join("、")},疑似已译成英文标签),插件无法自动对账。` +
|
|
321
|
+
`出路:改用对照格式 --entities "Weighted Box Fusion|加权框融合,…"(英文标签|原文词)即可自动对账;` +
|
|
322
|
+
`坚持不给对照就每个实体说得出原文出处(§¶),说不出来的删掉——qa 核验 Q4 会按原文再抓一次编造。`;
|
|
323
|
+
}
|
|
324
|
+
return result;
|
|
325
|
+
}
|
package/src/ratio.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// ratio.mjs — 画布比(aspect ratio)单一来源模块
|
|
2
|
+
//
|
|
3
|
+
// 🔴 2026-09-21 V10 事故根因:craft 对 double-column 写死 "roughly 16:5" 画布句,
|
|
4
|
+
// 而 render 实际用了 16:9 → 模型把宽幅内容硬塞进 16:9 画布,视觉上被横向压缩。
|
|
5
|
+
// 教训:**画布比只能有一个来源,且必须拼接、不许写死**。
|
|
6
|
+
//
|
|
7
|
+
// 机制(工作流级,不靠宿主 AI 记性):
|
|
8
|
+
// craft --ratio "16:9" → 提示词里的画布句由本模块从 ratio 数字拼接生成(横/竖/方自动判向)
|
|
9
|
+
// → --out 同时写 <同名>.craft.json 伴随文件(携带 suggestedRatio)
|
|
10
|
+
// render --prompt-file → 自动读伴随 JSON 继承 suggestedRatio(显式 --ratio 仍可覆盖,不一致给警告)
|
|
11
|
+
// render 落盘后 → 解析 PNG IHDR 实测像素比,与请求比偏差 >5% 记 ratio_mismatch 事件
|
|
12
|
+
|
|
13
|
+
import { readFileSync as fsRead } from "node:fs";
|
|
14
|
+
|
|
15
|
+
// ---------- 解析 "16:9" / "3:4" / "1.5:1" ----------
|
|
16
|
+
export function parseRatio(r) {
|
|
17
|
+
const m = String(r || "").trim().match(/^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/);
|
|
18
|
+
if (!m) return null;
|
|
19
|
+
const w = parseFloat(m[1]);
|
|
20
|
+
const h = parseFloat(m[2]);
|
|
21
|
+
if (!w || !h) return null; // 0 或 NaN 都拒绝
|
|
22
|
+
return { w, h, text: `${m[1]}:${m[2]}` };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------- 版式预设 → 缺省画布比(可被显式 --ratio 覆盖)----------
|
|
26
|
+
export function defaultRatioForPreset(preset) {
|
|
27
|
+
return { "double-column": "16:9", slide: "16:9", "single-column": "3:4" }[String(preset || "").trim()] || null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------- 画布句:从 ratio + 阶段数拼接(横/竖/方自动判向)----------
|
|
31
|
+
// 这就是 craft 里唯一该出现的画布约束句 —— 比例数字永远来自参数,不写死。
|
|
32
|
+
//
|
|
33
|
+
// 🔴 2026-09-21 V12 复盘(第二起画布事故,方向相反):渲染端只有固定档(premium 16:9 /
|
|
34
|
+
// standard 方图),16:9 装 7 阶段横排 = 上下空白带 >50%,比 V10 的压缩更隐蔽。
|
|
35
|
+
// 渲染端给不了更扁的比例(21:9 实测回落方图,ratio_mismatch 当场抓住)——
|
|
36
|
+
// 所以宽画布必须同时命令「内容带撑满画布高度」,把留白压进 1/10~1/3 区间。
|
|
37
|
+
export function canvasSentence({ ratio, stageCount = 0 } = {}) {
|
|
38
|
+
const r = parseRatio(ratio);
|
|
39
|
+
if (!r) return null;
|
|
40
|
+
const orient = r.w > r.h * 1.15
|
|
41
|
+
? "wide horizontal banner"
|
|
42
|
+
: r.h > r.w * 1.15
|
|
43
|
+
? "portrait canvas"
|
|
44
|
+
: "square-ish canvas";
|
|
45
|
+
const fit = stageCount >= 3
|
|
46
|
+
? ` The ${stageCount} stages share the full canvas width evenly with visible margins — nothing may be cropped or cut off at any edge.`
|
|
47
|
+
: " ALL content must fit inside the canvas with visible margins — nothing may be cropped or cut off at any edge.";
|
|
48
|
+
const fill = r.w > r.h * 1.15
|
|
49
|
+
? " The content band must FILL the canvas height: blocks span nearly top to bottom, leaving only narrow calm margins (about 10% of the canvas height each) above and below — large empty bands are a defect."
|
|
50
|
+
: "";
|
|
51
|
+
return `Canvas shape: ${orient} with a strict ${r.text} width-to-height aspect ratio.${fit}${fill}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------- 伴随文件路径:<同名>.craft.json(p.txt → p.craft.json)----------
|
|
55
|
+
export function craftSidecarPath(promptFile) {
|
|
56
|
+
return String(promptFile || "").replace(/\.[^\\/]+$/, "") + ".craft.json";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------- 读伴随文件(不存在/损坏 → null,不炸)----------
|
|
60
|
+
export function readCraftSidecar(promptFile) {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(fsRead(craftSidecarPath(promptFile)));
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------- PNG 尺寸解析(IHDR 定长头,零依赖)----------
|
|
69
|
+
export function pngDims(buf) {
|
|
70
|
+
try {
|
|
71
|
+
if (!buf || buf.length < 24) return null;
|
|
72
|
+
if (buf.readUInt32BE(0) !== 0x89504e47) return null; // \x89PNG
|
|
73
|
+
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------- 实测像素比 vs 请求比 核验(偏差 >5% 视为不符)----------
|
|
80
|
+
export function ratioMismatch(ratio, dims) {
|
|
81
|
+
const r = parseRatio(ratio);
|
|
82
|
+
if (!r || !dims?.w || !dims?.h) return null;
|
|
83
|
+
const target = r.w / r.h;
|
|
84
|
+
const actual = dims.w / dims.h;
|
|
85
|
+
const dev = Math.abs(actual - target) / target;
|
|
86
|
+
return dev > 0.05 ? { target, actual: Number(actual.toFixed(3)), dev: Number((dev * 100).toFixed(1)) } : null;
|
|
87
|
+
}
|