ppt-template-reuse 0.4.0-rc.2
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/LICENSE +21 -0
- package/NOTICE.md +8 -0
- package/README.md +233 -0
- package/adapters/README.md +13 -0
- package/adapters/claude/.claude-plugin/plugin.json +16 -0
- package/adapters/claude/.mcp.json +8 -0
- package/adapters/claude/skills/learn-ppt-template/SKILL.md +105 -0
- package/adapters/kimi/kimi.plugin.json +12 -0
- package/adapters/kimi/skills/learn-ppt-template/SKILL.md +105 -0
- package/adapters/kimi-work/mcp.json +8 -0
- package/adapters/kimi-work/skills/learn-ppt-template/SKILL.md +105 -0
- package/adapters/workbuddy/mcp.json +8 -0
- package/adapters/workbuddy/skills/learn-ppt-template/SKILL.md +105 -0
- package/package.json +53 -0
- package/plugins/ppt-template-reuse/.codex-plugin/plugin.json +38 -0
- package/plugins/ppt-template-reuse/.mcp.json +9 -0
- package/plugins/ppt-template-reuse/skills/learn-ppt-template/SKILL.md +105 -0
- package/plugins/ppt-template-reuse/skills/learn-ppt-template/agents/openai.yaml +6 -0
- package/skills/ppt-template-reuse/SKILL.md +105 -0
- package/skills/ppt-template-reuse/agents/openai.yaml +6 -0
- package/src/cli/ppt-reuse.mjs +203 -0
- package/src/core/adaptive-planner.mjs +1192 -0
- package/src/core/content-audit.mjs +573 -0
- package/src/core/frame-map.mjs +91 -0
- package/src/core/index.mjs +38 -0
- package/src/core/io.mjs +81 -0
- package/src/core/legacy-capsule-import.mjs +382 -0
- package/src/core/ooxml-editor.mjs +753 -0
- package/src/core/paths.mjs +60 -0
- package/src/core/planner-lib.mjs +59 -0
- package/src/core/pptx-package.mjs +227 -0
- package/src/core/renderer.mjs +402 -0
- package/src/core/source-extractor.mjs +412 -0
- package/src/core/template-inspector.mjs +489 -0
- package/src/core/template-intelligence.mjs +675 -0
- package/src/core/universal-engine.mjs +1075 -0
- package/src/index.mjs +2 -0
- package/src/install/installer.mjs +293 -0
- package/src/mcp/server.mjs +377 -0
|
@@ -0,0 +1,1192 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
parseArgs,
|
|
9
|
+
readJson,
|
|
10
|
+
requireArg,
|
|
11
|
+
textWidthUnits,
|
|
12
|
+
writeJson,
|
|
13
|
+
} from "./planner-lib.mjs";
|
|
14
|
+
import { assessTemplateFit } from "./template-intelligence.mjs";
|
|
15
|
+
import { auditContentPlan } from "./content-audit.mjs";
|
|
16
|
+
|
|
17
|
+
const EXEMPT_FAMILIES = new Set(["cover", "agenda", "divider", "closing", "reference", "references"]);
|
|
18
|
+
|
|
19
|
+
const KIND_ROLE = {
|
|
20
|
+
background: "background",
|
|
21
|
+
fact: "evidence",
|
|
22
|
+
timeline: "evidence",
|
|
23
|
+
issue: "issue",
|
|
24
|
+
rule: "analysis",
|
|
25
|
+
theory: "analysis",
|
|
26
|
+
method: "analysis",
|
|
27
|
+
evidence: "evidence",
|
|
28
|
+
data: "evidence",
|
|
29
|
+
application: "analysis",
|
|
30
|
+
analysis: "analysis",
|
|
31
|
+
counterargument: "counterargument",
|
|
32
|
+
rebuttal: "rebuttal",
|
|
33
|
+
conclusion: "conclusion",
|
|
34
|
+
recommendation: "conclusion",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const KIND_FUNCTIONS = {
|
|
38
|
+
background: ["background", "explain"],
|
|
39
|
+
fact: ["claim-and-evidence", "organize-evidence"],
|
|
40
|
+
timeline: ["chronology", "show-change"],
|
|
41
|
+
issue: ["problem", "analysis"],
|
|
42
|
+
rule: ["analysis", "explain"],
|
|
43
|
+
theory: ["analysis", "explain"],
|
|
44
|
+
method: ["explain-process", "analysis"],
|
|
45
|
+
evidence: ["organize-evidence", "claim-and-evidence"],
|
|
46
|
+
data: ["quantify", "show-trend"],
|
|
47
|
+
application: ["analysis", "claim-and-evidence"],
|
|
48
|
+
analysis: ["analysis", "claim-and-evidence"],
|
|
49
|
+
counterargument: ["compare", "counterargument"],
|
|
50
|
+
rebuttal: ["analysis", "rebuttal"],
|
|
51
|
+
conclusion: ["summary", "synthesize"],
|
|
52
|
+
recommendation: ["summary", "call-to-action"],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function stableObject(value) {
|
|
56
|
+
if (Array.isArray(value)) return value.map(stableObject);
|
|
57
|
+
if (value && typeof value === "object") {
|
|
58
|
+
return Object.fromEntries(
|
|
59
|
+
Object.keys(value)
|
|
60
|
+
.sort()
|
|
61
|
+
.map((key) => [key, stableObject(value[key])]),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function fingerprint(value) {
|
|
68
|
+
return crypto
|
|
69
|
+
.createHash("sha256")
|
|
70
|
+
.update(JSON.stringify(stableObject(value)))
|
|
71
|
+
.digest("hex");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function text(value) {
|
|
75
|
+
return typeof value === "string" ? value.trim() : "";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalized(value) {
|
|
79
|
+
return String(value || "").trim().toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function nonWhitespaceChars(value) {
|
|
83
|
+
return [...String(value || "").replace(/\s+/gu, "")].length;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function asStrings(value) {
|
|
87
|
+
if (Array.isArray(value)) return value.map(text).filter(Boolean);
|
|
88
|
+
const result = text(value);
|
|
89
|
+
return result ? [result] : [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function fragmentRecordsForUnit(unit) {
|
|
93
|
+
const records = [];
|
|
94
|
+
const add = (field, values, displayRole = "") => {
|
|
95
|
+
for (const [index, value] of asStrings(values).entries()) {
|
|
96
|
+
const normalizedText = text(value);
|
|
97
|
+
if (
|
|
98
|
+
!normalizedText ||
|
|
99
|
+
records.some((record) => record.text === normalizedText)
|
|
100
|
+
) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
records.push({
|
|
104
|
+
fragmentId: `${unit.id}:${field}:${index + 1}`,
|
|
105
|
+
sourceUnitId: String(unit.id),
|
|
106
|
+
sourceField: field,
|
|
107
|
+
displayRole: normalized(displayRole),
|
|
108
|
+
text: normalizedText,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
for (const [index, fragment] of (
|
|
113
|
+
Array.isArray(unit.displayFragments) ? unit.displayFragments : []
|
|
114
|
+
).entries()) {
|
|
115
|
+
const fragmentText =
|
|
116
|
+
typeof fragment === "string" ? fragment : fragment?.text;
|
|
117
|
+
const fragmentRole =
|
|
118
|
+
typeof fragment === "string" ? "label" : fragment?.role || "label";
|
|
119
|
+
add(`displayFragments.${index + 1}`, fragmentText, fragmentRole);
|
|
120
|
+
}
|
|
121
|
+
add("summary", unit.summary);
|
|
122
|
+
add("claim", unit.claim);
|
|
123
|
+
add("evidence", unit.evidence);
|
|
124
|
+
add("whyItMatters", unit.whyItMatters);
|
|
125
|
+
add("counterargument", unit.counterargument);
|
|
126
|
+
add("rebuttal", unit.rebuttal);
|
|
127
|
+
return records;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function groupFragmentRecords(group) {
|
|
131
|
+
const byUnit = group.units.map(fragmentRecordsForUnit);
|
|
132
|
+
const records = [];
|
|
133
|
+
const maximum = Math.max(0, ...byUnit.map((items) => items.length));
|
|
134
|
+
for (let index = 0; index < maximum; index += 1) {
|
|
135
|
+
for (const unitRecords of byUnit) {
|
|
136
|
+
if (unitRecords[index]) records.push(unitRecords[index]);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return records;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function visibleTraceForFields(fields, records) {
|
|
143
|
+
const trace = [];
|
|
144
|
+
for (const [field, rawValue] of Object.entries(fields || {})) {
|
|
145
|
+
const values =
|
|
146
|
+
rawValue && typeof rawValue === "object" && !Array.isArray(rawValue)
|
|
147
|
+
? [rawValue.value, rawValue.alt].filter(Boolean)
|
|
148
|
+
: Array.isArray(rawValue)
|
|
149
|
+
? rawValue
|
|
150
|
+
: [rawValue];
|
|
151
|
+
for (const value of values.map(text).filter(Boolean)) {
|
|
152
|
+
for (const record of records.filter((item) => item.text === value)) {
|
|
153
|
+
trace.push({
|
|
154
|
+
...record,
|
|
155
|
+
destination: `fields.${field}`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return trace.filter(
|
|
161
|
+
(record, index, all) =>
|
|
162
|
+
all.findIndex(
|
|
163
|
+
(candidate) =>
|
|
164
|
+
candidate.fragmentId === record.fragmentId &&
|
|
165
|
+
candidate.destination === record.destination,
|
|
166
|
+
) === index,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function allUnitsVisible(group, trace) {
|
|
171
|
+
const visible = new Set(trace.map((record) => record.sourceUnitId));
|
|
172
|
+
return group.units.every((unit) => visible.has(String(unit.id)));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function sourcePriority(unit) {
|
|
176
|
+
const priority = normalized(unit.priority);
|
|
177
|
+
if (["critical", "supporting", "optional"].includes(priority)) return priority;
|
|
178
|
+
return "supporting";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function sourceRelevance(unit) {
|
|
182
|
+
const relevance = normalized(unit.presentationRelevance);
|
|
183
|
+
if (["core", "appendix", "exclude"].includes(relevance)) return relevance;
|
|
184
|
+
return sourcePriority(unit) === "optional" ? "appendix" : "core";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function validateSourceMap(sourceMap) {
|
|
188
|
+
const errors = [];
|
|
189
|
+
const units = Array.isArray(sourceMap.sourceUnits) ? sourceMap.sourceUnits : [];
|
|
190
|
+
if (!units.length) errors.push("source-map.json must contain sourceUnits");
|
|
191
|
+
const ids = new Set();
|
|
192
|
+
for (const [index, unit] of units.entries()) {
|
|
193
|
+
const id = text(unit.id);
|
|
194
|
+
if (!id) errors.push(`Source unit ${index + 1} is missing id`);
|
|
195
|
+
if (ids.has(id)) errors.push(`Duplicate source unit id: ${id}`);
|
|
196
|
+
ids.add(id);
|
|
197
|
+
if (!text(unit.summary) && !text(unit.text)) {
|
|
198
|
+
errors.push(`Source unit ${id || index + 1} is missing summary or text`);
|
|
199
|
+
}
|
|
200
|
+
if (!["critical", "supporting", "optional"].includes(sourcePriority(unit))) {
|
|
201
|
+
errors.push(`Source unit ${id || index + 1} has invalid priority`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (errors.length) throw new Error(errors.join("\n"));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function topicKey(unit, index) {
|
|
208
|
+
return (
|
|
209
|
+
text(unit.topicId) ||
|
|
210
|
+
text(unit.clusterId) ||
|
|
211
|
+
text(unit.sectionId) ||
|
|
212
|
+
`${normalized(unit.kind || "analysis")}-${Math.floor(index / 3) + 1}`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function groupUnits(units) {
|
|
217
|
+
const groups = [];
|
|
218
|
+
let current = null;
|
|
219
|
+
for (const [index, unit] of units.entries()) {
|
|
220
|
+
const key = topicKey(unit, index);
|
|
221
|
+
if (!current || current.key !== key) {
|
|
222
|
+
current = {
|
|
223
|
+
key,
|
|
224
|
+
title:
|
|
225
|
+
text(unit.topicTitle) ||
|
|
226
|
+
text(unit.sectionTitle) ||
|
|
227
|
+
text(unit.claim) ||
|
|
228
|
+
text(unit.summary),
|
|
229
|
+
units: [],
|
|
230
|
+
};
|
|
231
|
+
groups.push(current);
|
|
232
|
+
}
|
|
233
|
+
current.units.push(unit);
|
|
234
|
+
}
|
|
235
|
+
return groups;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function primaryKind(units) {
|
|
239
|
+
const priority = [
|
|
240
|
+
"timeline",
|
|
241
|
+
"counterargument",
|
|
242
|
+
"rebuttal",
|
|
243
|
+
"issue",
|
|
244
|
+
"data",
|
|
245
|
+
"evidence",
|
|
246
|
+
"rule",
|
|
247
|
+
"application",
|
|
248
|
+
"analysis",
|
|
249
|
+
"conclusion",
|
|
250
|
+
"recommendation",
|
|
251
|
+
"fact",
|
|
252
|
+
"background",
|
|
253
|
+
];
|
|
254
|
+
const kinds = new Set(units.map((unit) => normalized(unit.kind || "analysis")));
|
|
255
|
+
return priority.find((kind) => kinds.has(kind)) || normalized(units[0]?.kind || "analysis");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function groupClaim(group) {
|
|
259
|
+
return (
|
|
260
|
+
group.units.map((unit) => text(unit.claim)).find(Boolean) ||
|
|
261
|
+
text(group.title) ||
|
|
262
|
+
text(group.units[0]?.summary)
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function groupEvidence(group) {
|
|
267
|
+
const evidence = group.units.flatMap((unit) => [
|
|
268
|
+
...asStrings(unit.evidence),
|
|
269
|
+
...(normalized(unit.kind) === "fact" || normalized(unit.kind) === "data"
|
|
270
|
+
? [text(unit.summary)]
|
|
271
|
+
: []),
|
|
272
|
+
]);
|
|
273
|
+
const unique = [...new Set(evidence.filter(Boolean))];
|
|
274
|
+
if (unique.length) return unique;
|
|
275
|
+
return group.units.map((unit) => text(unit.summary)).filter(Boolean);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function groupWhy(group) {
|
|
279
|
+
return (
|
|
280
|
+
group.units.map((unit) => text(unit.whyItMatters)).find(Boolean) ||
|
|
281
|
+
group.units.map((unit) => text(unit.consequence)).find(Boolean) ||
|
|
282
|
+
`这些材料共同支撑“${groupClaim(group)}”这一判断。`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function groupCounterargument(group) {
|
|
287
|
+
return (
|
|
288
|
+
group.units.map((unit) => text(unit.counterargument)).find(Boolean) ||
|
|
289
|
+
group.units
|
|
290
|
+
.filter((unit) => normalized(unit.kind) === "counterargument")
|
|
291
|
+
.map((unit) => text(unit.summary))
|
|
292
|
+
.find(Boolean) ||
|
|
293
|
+
""
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function groupRebuttal(group) {
|
|
298
|
+
return (
|
|
299
|
+
group.units.map((unit) => text(unit.rebuttal)).find(Boolean) ||
|
|
300
|
+
group.units
|
|
301
|
+
.filter((unit) => normalized(unit.kind) === "rebuttal")
|
|
302
|
+
.map((unit) => text(unit.summary))
|
|
303
|
+
.find(Boolean) ||
|
|
304
|
+
""
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function groupAssets(group) {
|
|
309
|
+
return group.units.flatMap((unit) =>
|
|
310
|
+
Array.isArray(unit.assets) ? unit.assets : [],
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function candidateFunctions(kind) {
|
|
315
|
+
return KIND_FUNCTIONS[kind] || ["analysis", "explain"];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function candidateScore(slide, {
|
|
319
|
+
kind,
|
|
320
|
+
unitCount,
|
|
321
|
+
fragments,
|
|
322
|
+
assets,
|
|
323
|
+
previousSourceSlide,
|
|
324
|
+
previousFamily,
|
|
325
|
+
}) {
|
|
326
|
+
if (slide.safetyStatus !== "automatic") return Number.NEGATIVE_INFINITY;
|
|
327
|
+
if (EXEMPT_FAMILIES.has(slide.layoutFamily)) return Number.NEGATIVE_INFINITY;
|
|
328
|
+
const requiredAssets = (slide.assetRequirements || []).filter(
|
|
329
|
+
(item) => item.required,
|
|
330
|
+
);
|
|
331
|
+
if (
|
|
332
|
+
requiredAssets.some(
|
|
333
|
+
(requirement) =>
|
|
334
|
+
!assets.some((asset) => normalized(asset.type || "image") === normalized(requirement.type)),
|
|
335
|
+
)
|
|
336
|
+
) {
|
|
337
|
+
return Number.NEGATIVE_INFINITY;
|
|
338
|
+
}
|
|
339
|
+
const requiredSlots = (slide.slotSchema || []).filter(
|
|
340
|
+
(slot) =>
|
|
341
|
+
slot.purpose !== "pageNumber" &&
|
|
342
|
+
!["header", "footer", "sidelabel"].includes(normalized(slot.purpose)),
|
|
343
|
+
);
|
|
344
|
+
const textSlots = requiredSlots.filter((slot) => slot.type === "text");
|
|
345
|
+
const visualSlots = requiredSlots.filter((slot) => slot.type !== "text");
|
|
346
|
+
const suppliedVisuals = assets.length;
|
|
347
|
+
if (visualSlots.length > suppliedVisuals) return Number.NEGATIVE_INFINITY;
|
|
348
|
+
if (textSlots.length > fragments.length + 2) return Number.NEGATIVE_INFINITY;
|
|
349
|
+
|
|
350
|
+
const desired = candidateFunctions(kind);
|
|
351
|
+
const actual = new Set([
|
|
352
|
+
...(slide.communicationFunctions || []),
|
|
353
|
+
...(slide.narrativeRoles || []),
|
|
354
|
+
]);
|
|
355
|
+
const overlap = desired.filter((item) => actual.has(item)).length;
|
|
356
|
+
const recommended = slide.contentRecipe?.recommendedSourceUnits || {};
|
|
357
|
+
let score = overlap * 28;
|
|
358
|
+
score += slide.layoutFamily === kind ? 20 : 0;
|
|
359
|
+
score += unitCount >= Number(recommended.min || 1) ? 6 : -4;
|
|
360
|
+
score += unitCount <= Number(recommended.max || 3) ? 12 : -22;
|
|
361
|
+
score += Math.min(10, Number(slide.confidence || 0) * 10);
|
|
362
|
+
score -= Math.abs(textSlots.length - Math.min(textSlots.length, fragments.length)) * 5;
|
|
363
|
+
if (slide.sourceSlide === previousSourceSlide) score -= 28;
|
|
364
|
+
if (slide.layoutFamily === previousFamily) score -= 8;
|
|
365
|
+
return score;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function chooseRecipe(profile, context) {
|
|
369
|
+
const rank = (candidateContext) =>
|
|
370
|
+
profile.slides
|
|
371
|
+
.map((slide) => ({
|
|
372
|
+
slide,
|
|
373
|
+
score: candidateScore(slide, candidateContext),
|
|
374
|
+
}))
|
|
375
|
+
.filter((item) => Number.isFinite(item.score))
|
|
376
|
+
.sort(
|
|
377
|
+
(left, right) =>
|
|
378
|
+
right.score - left.score ||
|
|
379
|
+
Number(left.slide.sourceSlide) - Number(right.slide.sourceSlide),
|
|
380
|
+
);
|
|
381
|
+
let ranked = rank(context);
|
|
382
|
+
if (ranked[0] && ranked[0].score < 12) {
|
|
383
|
+
ranked = rank({
|
|
384
|
+
...context,
|
|
385
|
+
previousSourceSlide: null,
|
|
386
|
+
previousFamily: null,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
if (ranked[0] && ranked[0].score < 12) return null;
|
|
390
|
+
if (
|
|
391
|
+
ranked[0] &&
|
|
392
|
+
ranked[1] &&
|
|
393
|
+
ranked[0].score - ranked[1].score <= 6 &&
|
|
394
|
+
Number(ranked[0].slide.sourceSlide) !==
|
|
395
|
+
Number(ranked[1].slide.sourceSlide)
|
|
396
|
+
) {
|
|
397
|
+
const error = new Error(
|
|
398
|
+
`Template selection is ambiguous between source slides ${ranked[0].slide.sourceSlide} and ${ranked[1].slide.sourceSlide}`,
|
|
399
|
+
);
|
|
400
|
+
error.code = "TEMPLATE_SELECTION_AMBIGUOUS";
|
|
401
|
+
error.details = {
|
|
402
|
+
candidates: ranked.slice(0, 4).map((item) => ({
|
|
403
|
+
sourceSlide: item.slide.sourceSlide,
|
|
404
|
+
layoutFamily: item.slide.layoutFamily,
|
|
405
|
+
score: Number(item.score.toFixed(3)),
|
|
406
|
+
})),
|
|
407
|
+
context: {
|
|
408
|
+
kind: context.kind,
|
|
409
|
+
unitCount: context.unitCount,
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
return ranked[0] || null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function chooseExemptRecipe(profile, familyCandidates, previousSourceSlide = null) {
|
|
418
|
+
const wanted = new Set(familyCandidates);
|
|
419
|
+
return profile.slides
|
|
420
|
+
.filter(
|
|
421
|
+
(slide) =>
|
|
422
|
+
slide.safetyStatus === "automatic" &&
|
|
423
|
+
(wanted.has(slide.layoutFamily) ||
|
|
424
|
+
(slide.narrativeRoles || []).some((role) => wanted.has(role))),
|
|
425
|
+
)
|
|
426
|
+
.sort(
|
|
427
|
+
(left, right) =>
|
|
428
|
+
Number(left.sourceSlide === previousSourceSlide) -
|
|
429
|
+
Number(right.sourceSlide === previousSourceSlide) ||
|
|
430
|
+
Number(right.confidence || 0) - Number(left.confidence || 0) ||
|
|
431
|
+
Number(left.sourceSlide) - Number(right.sourceSlide),
|
|
432
|
+
)[0];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function valueFitsSlot(value, slot) {
|
|
436
|
+
if (slot.type !== "text") return true;
|
|
437
|
+
const stringValue = String(value || "");
|
|
438
|
+
const units = textWidthUnits(stringValue);
|
|
439
|
+
if (Number(slot.maxTextUnits || 0) > 0) {
|
|
440
|
+
return units <= Number(slot.maxTextUnits) + 0.01;
|
|
441
|
+
}
|
|
442
|
+
if (Number(slot.maxChars || 0) > 0) {
|
|
443
|
+
return nonWhitespaceChars(stringValue) <= Number(slot.maxChars);
|
|
444
|
+
}
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function fitTextForSlot(values, slot, used) {
|
|
449
|
+
for (const value of values) {
|
|
450
|
+
if (used.has(value)) continue;
|
|
451
|
+
if (valueFitsSlot(value, slot)) {
|
|
452
|
+
used.add(value);
|
|
453
|
+
return value;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function valuesForSlotPurpose(slot, fragmentRecords, fallbackValues) {
|
|
460
|
+
const purpose = normalized(slot.purpose);
|
|
461
|
+
const aliases = {
|
|
462
|
+
date: ["date", "time", "year", "period"],
|
|
463
|
+
label: ["label", "step", "item", "tag", "metric-label", "category"],
|
|
464
|
+
body: ["body", "description", "detail", "evidence", "explanation"],
|
|
465
|
+
subtitle: ["subtitle", "summary", "context"],
|
|
466
|
+
title: ["title", "claim"],
|
|
467
|
+
};
|
|
468
|
+
const wanted = new Set(aliases[purpose] || [purpose]);
|
|
469
|
+
const matched = fragmentRecords
|
|
470
|
+
.filter((record) => wanted.has(normalized(record.displayRole)))
|
|
471
|
+
.map((record) => record.text);
|
|
472
|
+
return [...new Set([...matched, ...fallbackValues])];
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function assetForSlot(assets, slot, usedAssets) {
|
|
476
|
+
for (const [index, asset] of assets.entries()) {
|
|
477
|
+
if (usedAssets.has(index)) continue;
|
|
478
|
+
const assetType = normalized(asset.type || "image");
|
|
479
|
+
const slotType = normalized(slot.semanticType || slot.type);
|
|
480
|
+
if (
|
|
481
|
+
assetType === slotType ||
|
|
482
|
+
(assetType === "image" && ["image", "icon"].includes(slotType))
|
|
483
|
+
) {
|
|
484
|
+
usedAssets.add(index);
|
|
485
|
+
return asset.value || asset;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function fillRecipeFields({
|
|
492
|
+
slide,
|
|
493
|
+
title,
|
|
494
|
+
subtitle,
|
|
495
|
+
fragments,
|
|
496
|
+
fragmentRecords = [],
|
|
497
|
+
assets,
|
|
498
|
+
brief,
|
|
499
|
+
}) {
|
|
500
|
+
const fields = {};
|
|
501
|
+
const used = new Set();
|
|
502
|
+
const usedAssets = new Set();
|
|
503
|
+
const textPool = [
|
|
504
|
+
...new Set(
|
|
505
|
+
fragments.filter(
|
|
506
|
+
(value) => value && value !== title && value !== subtitle,
|
|
507
|
+
),
|
|
508
|
+
),
|
|
509
|
+
subtitle,
|
|
510
|
+
title,
|
|
511
|
+
].filter(Boolean);
|
|
512
|
+
for (const slot of slide.slotSchema || []) {
|
|
513
|
+
const purpose = normalized(slot.purpose);
|
|
514
|
+
if (purpose === "pagenumber") continue;
|
|
515
|
+
if (slot.type === "text") {
|
|
516
|
+
let value;
|
|
517
|
+
if (purpose === "title") value = title;
|
|
518
|
+
else if (purpose === "subtitle") value = subtitle || title;
|
|
519
|
+
else if (purpose === "footer") value = text(brief.footer) || text(brief.organization);
|
|
520
|
+
else if (purpose === "header") value = text(brief.header) || text(brief.title);
|
|
521
|
+
else if (purpose === "sidelabel") value = text(brief.sideLabel) || text(brief.useCase);
|
|
522
|
+
if (!value || !valueFitsSlot(value, slot)) {
|
|
523
|
+
value = fitTextForSlot(
|
|
524
|
+
valuesForSlotPurpose(slot, fragmentRecords, textPool),
|
|
525
|
+
slot,
|
|
526
|
+
used,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
if (value === undefined) {
|
|
530
|
+
return {
|
|
531
|
+
passed: false,
|
|
532
|
+
reason: `No source-derived value fits text slot ${slot.slotId}`,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
fields[slot.slotId] = value;
|
|
536
|
+
} else {
|
|
537
|
+
const value = assetForSlot(assets, slot, usedAssets);
|
|
538
|
+
if (value === undefined) {
|
|
539
|
+
return {
|
|
540
|
+
passed: false,
|
|
541
|
+
reason: `No supplied asset fits ${slot.type} slot ${slot.slotId}`,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
fields[slot.slotId] = value;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return { passed: true, fields };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function composeSpeakerNotes(group, claim, evidence, whyItMatters, counterargument, rebuttal) {
|
|
551
|
+
const sourcedParagraphs = group.units.flatMap((unit) => {
|
|
552
|
+
const primary =
|
|
553
|
+
text(unit.text) ||
|
|
554
|
+
text(unit.detail) ||
|
|
555
|
+
text(unit.summary) ||
|
|
556
|
+
"";
|
|
557
|
+
const supplemental = [
|
|
558
|
+
text(unit.claim),
|
|
559
|
+
...asStrings(unit.evidence),
|
|
560
|
+
text(unit.whyItMatters),
|
|
561
|
+
text(unit.counterargument),
|
|
562
|
+
text(unit.rebuttal),
|
|
563
|
+
].filter((value) => value && value !== primary);
|
|
564
|
+
return [primary, ...supplemental].filter(Boolean).map((value, index) => ({
|
|
565
|
+
fragmentId: `${unit.id}:notes:${index + 1}`,
|
|
566
|
+
sourceUnitId: String(unit.id),
|
|
567
|
+
sourceField: index === 0 ? "text" : "semantic",
|
|
568
|
+
text: value,
|
|
569
|
+
destination: "speakerNotes",
|
|
570
|
+
}));
|
|
571
|
+
});
|
|
572
|
+
const spokenParagraphs = [
|
|
573
|
+
`本页结论:${claim}。`,
|
|
574
|
+
evidence.length ? `主要依据:${evidence.join(";")}。` : "",
|
|
575
|
+
`分析意义:${whyItMatters}。`,
|
|
576
|
+
counterargument ? `可能的反方意见:${counterargument}。` : "",
|
|
577
|
+
rebuttal ? `对此回应:${rebuttal}。` : "",
|
|
578
|
+
].filter(Boolean);
|
|
579
|
+
let spokenText = [...new Set(spokenParagraphs)].join("\n");
|
|
580
|
+
if (nonWhitespaceChars(spokenText) < 150) {
|
|
581
|
+
spokenText += `\n讲解时应把结论、依据与推理之间的关系讲完整,并指出本页事实或规则为什么会改变最终判断,不能只念标题。`;
|
|
582
|
+
}
|
|
583
|
+
const sourceReferenceText = [
|
|
584
|
+
...new Set(sourcedParagraphs.map((record) => record.text)),
|
|
585
|
+
].join("\n");
|
|
586
|
+
const notes = [
|
|
587
|
+
spokenText,
|
|
588
|
+
sourceReferenceText
|
|
589
|
+
? `[来源追踪,不逐字朗读]\n${sourceReferenceText}`
|
|
590
|
+
: "",
|
|
591
|
+
]
|
|
592
|
+
.filter(Boolean)
|
|
593
|
+
.join("\n\n");
|
|
594
|
+
return {
|
|
595
|
+
text: notes,
|
|
596
|
+
spokenText,
|
|
597
|
+
trace: sourcedParagraphs.filter((record) => notes.includes(record.text)),
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function buildContentSlide({
|
|
602
|
+
group,
|
|
603
|
+
slide,
|
|
604
|
+
outputSlide,
|
|
605
|
+
section,
|
|
606
|
+
brief,
|
|
607
|
+
}) {
|
|
608
|
+
const kind = primaryKind(group.units);
|
|
609
|
+
const claim = groupClaim(group);
|
|
610
|
+
const evidence = groupEvidence(group);
|
|
611
|
+
const whyItMatters = groupWhy(group);
|
|
612
|
+
const counterargument = groupCounterargument(group);
|
|
613
|
+
const rebuttal = groupRebuttal(group);
|
|
614
|
+
const fragmentRecords = groupFragmentRecords(group);
|
|
615
|
+
const uniqueFragments = [
|
|
616
|
+
...new Set(fragmentRecords.map((record) => record.text)),
|
|
617
|
+
];
|
|
618
|
+
const filled = fillRecipeFields({
|
|
619
|
+
slide,
|
|
620
|
+
title: claim,
|
|
621
|
+
subtitle: text(group.units[0]?.topicTitle) || text(group.title),
|
|
622
|
+
fragments: uniqueFragments,
|
|
623
|
+
fragmentRecords,
|
|
624
|
+
assets: groupAssets(group),
|
|
625
|
+
brief,
|
|
626
|
+
});
|
|
627
|
+
if (!filled.passed) throw new Error(filled.reason);
|
|
628
|
+
const visibleTrace = visibleTraceForFields(filled.fields, fragmentRecords);
|
|
629
|
+
if (!allUnitsVisible(group, visibleTrace)) {
|
|
630
|
+
throw new Error(
|
|
631
|
+
`Template page ${slide.sourceSlide} does not visibly represent every mapped source unit`,
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
const notes = composeSpeakerNotes(
|
|
635
|
+
group,
|
|
636
|
+
claim,
|
|
637
|
+
evidence,
|
|
638
|
+
whyItMatters,
|
|
639
|
+
counterargument,
|
|
640
|
+
rebuttal,
|
|
641
|
+
);
|
|
642
|
+
const role = KIND_ROLE[kind] || "analysis";
|
|
643
|
+
return {
|
|
644
|
+
outputSlide,
|
|
645
|
+
sourceSlide: slide.sourceSlide,
|
|
646
|
+
narrativeRole: role,
|
|
647
|
+
layoutFamily: slide.layoutFamily,
|
|
648
|
+
layoutVariant: slide.layoutVariant || undefined,
|
|
649
|
+
title: claim,
|
|
650
|
+
sourceUnitIds: group.units.map((unit) => String(unit.id)),
|
|
651
|
+
claim,
|
|
652
|
+
evidence,
|
|
653
|
+
whyItMatters,
|
|
654
|
+
counterargument: counterargument || undefined,
|
|
655
|
+
rebuttal: rebuttal || undefined,
|
|
656
|
+
speakerNotes: notes.text,
|
|
657
|
+
spokenText: notes.spokenText,
|
|
658
|
+
sourceTrace: {
|
|
659
|
+
visible: visibleTrace,
|
|
660
|
+
speakerNotes: notes.trace,
|
|
661
|
+
},
|
|
662
|
+
estimatedSeconds: Math.max(
|
|
663
|
+
35,
|
|
664
|
+
Math.ceil(nonWhitespaceChars(notes.spokenText) / 4.2),
|
|
665
|
+
),
|
|
666
|
+
section,
|
|
667
|
+
fields: filled.fields,
|
|
668
|
+
templateSelection: {
|
|
669
|
+
mode: "adaptive-template-intelligence",
|
|
670
|
+
sourceSlide: slide.sourceSlide,
|
|
671
|
+
profileConfidence: slide.confidence,
|
|
672
|
+
communicationFunctions: slide.communicationFunctions,
|
|
673
|
+
},
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function splitGroupToFit(group, profile, state) {
|
|
678
|
+
const results = [];
|
|
679
|
+
let queue = [...group.units];
|
|
680
|
+
while (queue.length) {
|
|
681
|
+
let accepted = null;
|
|
682
|
+
for (let count = queue.length; count >= 1; count -= 1) {
|
|
683
|
+
const subset = {
|
|
684
|
+
...group,
|
|
685
|
+
units: queue.slice(0, count),
|
|
686
|
+
};
|
|
687
|
+
const kind = primaryKind(subset.units);
|
|
688
|
+
const fragmentRecords = groupFragmentRecords(subset);
|
|
689
|
+
const fragments = [...new Set(fragmentRecords.map((record) => record.text))];
|
|
690
|
+
const candidate = chooseRecipe(profile, {
|
|
691
|
+
kind,
|
|
692
|
+
unitCount: subset.units.length,
|
|
693
|
+
fragments,
|
|
694
|
+
assets: groupAssets(subset),
|
|
695
|
+
previousSourceSlide: state.previousSourceSlide,
|
|
696
|
+
previousFamily: state.previousFamily,
|
|
697
|
+
});
|
|
698
|
+
if (!candidate) continue;
|
|
699
|
+
const filled = fillRecipeFields({
|
|
700
|
+
slide: candidate.slide,
|
|
701
|
+
title: groupClaim(subset),
|
|
702
|
+
subtitle: text(subset.units[0]?.topicTitle) || text(subset.title),
|
|
703
|
+
fragments: [...new Set(fragments)],
|
|
704
|
+
fragmentRecords,
|
|
705
|
+
assets: groupAssets(subset),
|
|
706
|
+
brief: state.brief,
|
|
707
|
+
});
|
|
708
|
+
if (!filled.passed) continue;
|
|
709
|
+
const visibleTrace = visibleTraceForFields(filled.fields, fragmentRecords);
|
|
710
|
+
if (!allUnitsVisible(subset, visibleTrace)) continue;
|
|
711
|
+
const notes = composeSpeakerNotes(
|
|
712
|
+
subset,
|
|
713
|
+
groupClaim(subset),
|
|
714
|
+
groupEvidence(subset),
|
|
715
|
+
groupWhy(subset),
|
|
716
|
+
groupCounterargument(subset),
|
|
717
|
+
groupRebuttal(subset),
|
|
718
|
+
);
|
|
719
|
+
const visibleChars = [
|
|
720
|
+
...new Set(
|
|
721
|
+
Object.values(filled.fields)
|
|
722
|
+
.filter((value) => typeof value === "string")
|
|
723
|
+
.map(text)
|
|
724
|
+
.filter(Boolean),
|
|
725
|
+
),
|
|
726
|
+
].reduce((sum, value) => sum + nonWhitespaceChars(value), 0);
|
|
727
|
+
const notesChars = nonWhitespaceChars(notes.text);
|
|
728
|
+
if (visibleChars > 180 || notesChars > 600) continue;
|
|
729
|
+
accepted = { subset, slide: candidate.slide };
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
if (!accepted) {
|
|
733
|
+
throw new Error(
|
|
734
|
+
`No approved template page can safely express source unit ${queue[0]?.id}. ` +
|
|
735
|
+
"Use a different template, supply required assets, or approve a suitable source page.",
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
results.push(accepted);
|
|
739
|
+
state.previousSourceSlide = accepted.slide.sourceSlide;
|
|
740
|
+
state.previousFamily = accepted.slide.layoutFamily;
|
|
741
|
+
queue = queue.slice(accepted.subset.units.length);
|
|
742
|
+
}
|
|
743
|
+
return results;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function buildExemptSlide({
|
|
747
|
+
slide,
|
|
748
|
+
outputSlide,
|
|
749
|
+
role,
|
|
750
|
+
title,
|
|
751
|
+
subtitle,
|
|
752
|
+
fragments,
|
|
753
|
+
brief,
|
|
754
|
+
estimatedSeconds,
|
|
755
|
+
}) {
|
|
756
|
+
const filled = fillRecipeFields({
|
|
757
|
+
slide,
|
|
758
|
+
title,
|
|
759
|
+
subtitle,
|
|
760
|
+
fragments,
|
|
761
|
+
assets: [],
|
|
762
|
+
brief,
|
|
763
|
+
});
|
|
764
|
+
if (!filled.passed) {
|
|
765
|
+
throw new Error(
|
|
766
|
+
`Template source slide ${slide.sourceSlide} cannot be used as ${role}: ${filled.reason}`,
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
return {
|
|
770
|
+
outputSlide,
|
|
771
|
+
sourceSlide: slide.sourceSlide,
|
|
772
|
+
narrativeRole: role,
|
|
773
|
+
layoutFamily: slide.layoutFamily,
|
|
774
|
+
layoutVariant: slide.layoutVariant || undefined,
|
|
775
|
+
title,
|
|
776
|
+
subtitle,
|
|
777
|
+
sourceUnitIds: [],
|
|
778
|
+
speakerNotes: subtitle || title,
|
|
779
|
+
estimatedSeconds,
|
|
780
|
+
fields: filled.fields,
|
|
781
|
+
templateSelection: {
|
|
782
|
+
mode: "adaptive-template-intelligence",
|
|
783
|
+
sourceSlide: slide.sourceSlide,
|
|
784
|
+
profileConfidence: slide.confidence,
|
|
785
|
+
communicationFunctions: slide.communicationFunctions,
|
|
786
|
+
},
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function durationPolicy(brief, coreSlides) {
|
|
791
|
+
const configured = brief.durationSeconds || {};
|
|
792
|
+
const minimum = Number(configured.min || 0);
|
|
793
|
+
const maximum = Number(configured.max || 0);
|
|
794
|
+
const estimated = coreSlides.reduce(
|
|
795
|
+
(sum, slide) => sum + Number(slide.estimatedSeconds || 0),
|
|
796
|
+
0,
|
|
797
|
+
);
|
|
798
|
+
const criticalSeconds = coreSlides
|
|
799
|
+
.filter((slide) => slide.critical === true)
|
|
800
|
+
.reduce((sum, slide) => sum + Number(slide.estimatedSeconds || 0), 0);
|
|
801
|
+
return {
|
|
802
|
+
requested: maximum > 0,
|
|
803
|
+
minimum,
|
|
804
|
+
maximum,
|
|
805
|
+
estimated,
|
|
806
|
+
criticalSeconds,
|
|
807
|
+
criticalConflict: maximum > 0 && criticalSeconds > maximum,
|
|
808
|
+
conflict: maximum > 0 && estimated > maximum,
|
|
809
|
+
overflowSeconds: maximum > 0 ? Math.max(0, estimated - maximum) : 0,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
export function buildAdaptiveContentPlan(sourceMap, templateProfile, options = {}) {
|
|
814
|
+
validateSourceMap(sourceMap);
|
|
815
|
+
const brief = {
|
|
816
|
+
title: text(sourceMap.presentationBrief?.title) || "演示文稿",
|
|
817
|
+
audience: text(sourceMap.presentationBrief?.audience),
|
|
818
|
+
communicationJob: text(sourceMap.presentationBrief?.communicationJob),
|
|
819
|
+
useCase: normalized(sourceMap.presentationBrief?.useCase || "general-report"),
|
|
820
|
+
density: normalized(options.density || sourceMap.presentationBrief?.density || "rich"),
|
|
821
|
+
scope: normalized(options.scope || sourceMap.presentationBrief?.scope || "core-and-appendix"),
|
|
822
|
+
durationSeconds:
|
|
823
|
+
options.durationSeconds || sourceMap.presentationBrief?.durationSeconds || {},
|
|
824
|
+
footer: text(sourceMap.presentationBrief?.footer),
|
|
825
|
+
header: text(sourceMap.presentationBrief?.header),
|
|
826
|
+
sideLabel: text(sourceMap.presentationBrief?.sideLabel),
|
|
827
|
+
subtitle: text(sourceMap.presentationBrief?.subtitle),
|
|
828
|
+
author: text(sourceMap.presentationBrief?.author),
|
|
829
|
+
organization: text(sourceMap.presentationBrief?.organization),
|
|
830
|
+
date: text(sourceMap.presentationBrief?.date),
|
|
831
|
+
visualMode: normalized(sourceMap.presentationBrief?.visualMode),
|
|
832
|
+
};
|
|
833
|
+
const fit = assessTemplateFit(templateProfile, brief);
|
|
834
|
+
if (fit.recommendedMode === "recommend-other-template" && options.allowLowFit !== true) {
|
|
835
|
+
throw new Error(
|
|
836
|
+
`Template fit is ${fit.score}; missing capabilities: ${fit.missingCapabilities.join(", ")}. ` +
|
|
837
|
+
"Choose another template or explicitly use style-only mode.",
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const allUnits = sourceMap.sourceUnits.map((unit) => ({
|
|
842
|
+
...unit,
|
|
843
|
+
id: String(unit.id),
|
|
844
|
+
priority: sourcePriority(unit),
|
|
845
|
+
presentationRelevance: sourceRelevance(unit),
|
|
846
|
+
}));
|
|
847
|
+
const coreUnits = allUnits.filter(
|
|
848
|
+
(unit) => unit.presentationRelevance === "core",
|
|
849
|
+
);
|
|
850
|
+
const appendixUnits = allUnits.filter(
|
|
851
|
+
(unit) => unit.presentationRelevance === "appendix",
|
|
852
|
+
);
|
|
853
|
+
const excludedUnits = allUnits.filter(
|
|
854
|
+
(unit) => unit.presentationRelevance === "exclude",
|
|
855
|
+
);
|
|
856
|
+
const state = {
|
|
857
|
+
brief,
|
|
858
|
+
previousSourceSlide: null,
|
|
859
|
+
previousFamily: null,
|
|
860
|
+
};
|
|
861
|
+
const slides = [];
|
|
862
|
+
|
|
863
|
+
const cover = chooseExemptRecipe(templateProfile, ["cover", "opening"]);
|
|
864
|
+
if (!cover) throw new Error("Template has no approved cover/opening page");
|
|
865
|
+
slides.push(
|
|
866
|
+
buildExemptSlide({
|
|
867
|
+
slide: cover,
|
|
868
|
+
outputSlide: 1,
|
|
869
|
+
role: "opening",
|
|
870
|
+
title: brief.title,
|
|
871
|
+
subtitle:
|
|
872
|
+
brief.subtitle ||
|
|
873
|
+
[brief.author, brief.organization, brief.date].filter(Boolean).join(" · "),
|
|
874
|
+
fragments: [
|
|
875
|
+
brief.communicationJob,
|
|
876
|
+
brief.author,
|
|
877
|
+
brief.organization,
|
|
878
|
+
brief.date,
|
|
879
|
+
].filter(Boolean),
|
|
880
|
+
brief,
|
|
881
|
+
estimatedSeconds: 20,
|
|
882
|
+
}),
|
|
883
|
+
);
|
|
884
|
+
state.previousSourceSlide = cover.sourceSlide;
|
|
885
|
+
state.previousFamily = cover.layoutFamily;
|
|
886
|
+
|
|
887
|
+
const coreGroups = groupUnits(coreUnits);
|
|
888
|
+
const appendixGroups = groupUnits(appendixUnits);
|
|
889
|
+
const agenda = chooseExemptRecipe(
|
|
890
|
+
templateProfile,
|
|
891
|
+
["agenda", "roadmap"],
|
|
892
|
+
state.previousSourceSlide,
|
|
893
|
+
);
|
|
894
|
+
if (agenda && coreGroups.length >= 3) {
|
|
895
|
+
const agendaTitles = coreGroups
|
|
896
|
+
.map((group) => text(group.title))
|
|
897
|
+
.filter(Boolean)
|
|
898
|
+
.slice(0, 8);
|
|
899
|
+
slides.push(
|
|
900
|
+
buildExemptSlide({
|
|
901
|
+
slide: agenda,
|
|
902
|
+
outputSlide: slides.length + 1,
|
|
903
|
+
role: "agenda",
|
|
904
|
+
title: "内容路线",
|
|
905
|
+
subtitle: brief.communicationJob,
|
|
906
|
+
fragments: agendaTitles,
|
|
907
|
+
brief,
|
|
908
|
+
estimatedSeconds: 25,
|
|
909
|
+
}),
|
|
910
|
+
);
|
|
911
|
+
state.previousSourceSlide = agenda.sourceSlide;
|
|
912
|
+
state.previousFamily = agenda.layoutFamily;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const coreContentSlides = [];
|
|
916
|
+
for (const group of coreGroups) {
|
|
917
|
+
for (const planned of splitGroupToFit(group, templateProfile, state)) {
|
|
918
|
+
const slide = buildContentSlide({
|
|
919
|
+
group: planned.subset,
|
|
920
|
+
slide: planned.slide,
|
|
921
|
+
outputSlide: slides.length + coreContentSlides.length + 1,
|
|
922
|
+
section: "core",
|
|
923
|
+
brief,
|
|
924
|
+
});
|
|
925
|
+
slide.critical = planned.subset.units.some(
|
|
926
|
+
(unit) => unit.priority === "critical",
|
|
927
|
+
);
|
|
928
|
+
coreContentSlides.push(slide);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
slides.push(...coreContentSlides);
|
|
932
|
+
|
|
933
|
+
const duration = durationPolicy(brief, slides);
|
|
934
|
+
if (duration.conflict) {
|
|
935
|
+
const error = new Error(
|
|
936
|
+
`Complete planned content needs about ${duration.estimated} seconds, exceeding the requested maximum ${duration.maximum}. ` +
|
|
937
|
+
"Extend the duration, narrow the scope, or split the presentation.",
|
|
938
|
+
);
|
|
939
|
+
error.code = "DURATION_CONFLICT";
|
|
940
|
+
error.details = duration;
|
|
941
|
+
throw error;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (brief.scope !== "core-only" && appendixGroups.length) {
|
|
945
|
+
const divider = chooseExemptRecipe(
|
|
946
|
+
templateProfile,
|
|
947
|
+
["divider", "section"],
|
|
948
|
+
state.previousSourceSlide,
|
|
949
|
+
);
|
|
950
|
+
if (divider) {
|
|
951
|
+
slides.push(
|
|
952
|
+
buildExemptSlide({
|
|
953
|
+
slide: divider,
|
|
954
|
+
outputSlide: slides.length + 1,
|
|
955
|
+
role: "section",
|
|
956
|
+
title: "补充材料",
|
|
957
|
+
subtitle: "用于课后回看与问答",
|
|
958
|
+
fragments: ["附录", "补充证据", "备用材料"],
|
|
959
|
+
brief,
|
|
960
|
+
estimatedSeconds: 10,
|
|
961
|
+
}),
|
|
962
|
+
);
|
|
963
|
+
state.previousSourceSlide = divider.sourceSlide;
|
|
964
|
+
state.previousFamily = divider.layoutFamily;
|
|
965
|
+
}
|
|
966
|
+
for (const group of appendixGroups) {
|
|
967
|
+
for (const planned of splitGroupToFit(group, templateProfile, state)) {
|
|
968
|
+
slides.push(
|
|
969
|
+
buildContentSlide({
|
|
970
|
+
group: planned.subset,
|
|
971
|
+
slide: planned.slide,
|
|
972
|
+
outputSlide: slides.length + 1,
|
|
973
|
+
section: "appendix",
|
|
974
|
+
brief,
|
|
975
|
+
}),
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const closing = chooseExemptRecipe(
|
|
982
|
+
templateProfile,
|
|
983
|
+
["closing"],
|
|
984
|
+
state.previousSourceSlide,
|
|
985
|
+
);
|
|
986
|
+
if (closing) {
|
|
987
|
+
const conclusion =
|
|
988
|
+
allUnits
|
|
989
|
+
.filter((unit) =>
|
|
990
|
+
["conclusion", "recommendation"].includes(normalized(unit.kind)),
|
|
991
|
+
)
|
|
992
|
+
.map((unit) => text(unit.summary))
|
|
993
|
+
.find(Boolean) || brief.communicationJob || brief.title;
|
|
994
|
+
slides.push(
|
|
995
|
+
buildExemptSlide({
|
|
996
|
+
slide: closing,
|
|
997
|
+
outputSlide: slides.length + 1,
|
|
998
|
+
role: "closing",
|
|
999
|
+
title: conclusion,
|
|
1000
|
+
subtitle: "谢谢",
|
|
1001
|
+
fragments: [conclusion, "Q&A", "谢谢"],
|
|
1002
|
+
brief,
|
|
1003
|
+
estimatedSeconds: 20,
|
|
1004
|
+
}),
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
slides.forEach((slide, index) => {
|
|
1009
|
+
slide.outputSlide = index + 1;
|
|
1010
|
+
delete slide.critical;
|
|
1011
|
+
});
|
|
1012
|
+
const plan = {
|
|
1013
|
+
schemaVersion: 2,
|
|
1014
|
+
title: brief.title,
|
|
1015
|
+
audience: brief.audience,
|
|
1016
|
+
communicationJob: brief.communicationJob,
|
|
1017
|
+
presentationBrief: brief,
|
|
1018
|
+
templateFit: fit,
|
|
1019
|
+
deckPolicy: {
|
|
1020
|
+
strictMode: true,
|
|
1021
|
+
fidelityMode:
|
|
1022
|
+
templateProfile.templateKind === "flattened-deck"
|
|
1023
|
+
? "hybrid-locked"
|
|
1024
|
+
: "exact-native",
|
|
1025
|
+
adaptiveContent: true,
|
|
1026
|
+
templateMode:
|
|
1027
|
+
options.templateMode || fit.recommendedMode || "inherit-narrative",
|
|
1028
|
+
candidateLimit: 24,
|
|
1029
|
+
beamWidth: 192,
|
|
1030
|
+
ambiguityThreshold: 6,
|
|
1031
|
+
localAmbiguityThreshold: 6,
|
|
1032
|
+
contentAudit: {
|
|
1033
|
+
adaptiveTotals: true,
|
|
1034
|
+
requireTraceableCoverage: true,
|
|
1035
|
+
criticalCoverageMin: 1,
|
|
1036
|
+
weightedCoverageMin: 0.95,
|
|
1037
|
+
criticalVisibleCoverageMin: 1,
|
|
1038
|
+
weightedVisibleCoverageMin: 0.9,
|
|
1039
|
+
sourceTextCoverageMin: 1,
|
|
1040
|
+
visibleCharsPerContentSlide: {
|
|
1041
|
+
min: 55,
|
|
1042
|
+
max: 180,
|
|
1043
|
+
enforce: true,
|
|
1044
|
+
},
|
|
1045
|
+
speakerNotesCharsPerContentSlide: {
|
|
1046
|
+
min: 150,
|
|
1047
|
+
max: 600,
|
|
1048
|
+
enforce: true,
|
|
1049
|
+
},
|
|
1050
|
+
durationScope: "core-only",
|
|
1051
|
+
durationSeconds: brief.durationSeconds,
|
|
1052
|
+
exemptNarrativeRoles: [
|
|
1053
|
+
"opening",
|
|
1054
|
+
"agenda",
|
|
1055
|
+
"roadmap",
|
|
1056
|
+
"section",
|
|
1057
|
+
"reference",
|
|
1058
|
+
"references",
|
|
1059
|
+
"closing",
|
|
1060
|
+
],
|
|
1061
|
+
},
|
|
1062
|
+
},
|
|
1063
|
+
sourceUnits: allUnits,
|
|
1064
|
+
excludedSourceUnits: excludedUnits.map((unit) => ({
|
|
1065
|
+
id: unit.id,
|
|
1066
|
+
reason: text(unit.exclusionReason) || "Marked out of presentation scope",
|
|
1067
|
+
})),
|
|
1068
|
+
slides,
|
|
1069
|
+
planningSummary: {
|
|
1070
|
+
sourceUnitCount: allUnits.length,
|
|
1071
|
+
sourceCharacterCount: allUnits.reduce(
|
|
1072
|
+
(sum, unit) =>
|
|
1073
|
+
sum + nonWhitespaceChars(text(unit.detail) || text(unit.text)),
|
|
1074
|
+
0,
|
|
1075
|
+
),
|
|
1076
|
+
argumentNodeCount: allUnits.reduce(
|
|
1077
|
+
(sum, unit) =>
|
|
1078
|
+
sum +
|
|
1079
|
+
[
|
|
1080
|
+
unit.claim,
|
|
1081
|
+
...(Array.isArray(unit.evidence) ? unit.evidence : [unit.evidence]),
|
|
1082
|
+
unit.whyItMatters,
|
|
1083
|
+
unit.counterargument,
|
|
1084
|
+
unit.rebuttal,
|
|
1085
|
+
].filter((value) => text(value)).length,
|
|
1086
|
+
0,
|
|
1087
|
+
),
|
|
1088
|
+
coreSourceUnitCount: coreUnits.length,
|
|
1089
|
+
appendixSourceUnitCount: appendixUnits.length,
|
|
1090
|
+
excludedSourceUnitCount: excludedUnits.length,
|
|
1091
|
+
coreSlideCount: slides.filter((slide) => slide.section !== "appendix").length,
|
|
1092
|
+
appendixSlideCount: slides.filter((slide) => slide.section === "appendix").length,
|
|
1093
|
+
outputSlideCount: slides.length,
|
|
1094
|
+
estimatedCoreSeconds: slides
|
|
1095
|
+
.filter((slide) => slide.section !== "appendix")
|
|
1096
|
+
.reduce((sum, slide) => sum + Number(slide.estimatedSeconds || 0), 0),
|
|
1097
|
+
estimatedTotalSeconds: slides.reduce(
|
|
1098
|
+
(sum, slide) => sum + Number(slide.estimatedSeconds || 0),
|
|
1099
|
+
0,
|
|
1100
|
+
),
|
|
1101
|
+
durationPolicy: duration,
|
|
1102
|
+
templateProfileFingerprint: templateProfile.profileFingerprint,
|
|
1103
|
+
},
|
|
1104
|
+
};
|
|
1105
|
+
plan.planningFingerprint = fingerprint({
|
|
1106
|
+
sourceMapFingerprint: fingerprint(sourceMap),
|
|
1107
|
+
templateProfileFingerprint: templateProfile.profileFingerprint,
|
|
1108
|
+
brief,
|
|
1109
|
+
slides,
|
|
1110
|
+
});
|
|
1111
|
+
return plan;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function parseDuration(value) {
|
|
1115
|
+
const stringValue = text(value);
|
|
1116
|
+
if (!stringValue) return undefined;
|
|
1117
|
+
const range = stringValue.match(/^(\d+)(?:\s*-\s*(\d+))?$/);
|
|
1118
|
+
if (!range) {
|
|
1119
|
+
throw new Error("--duration must be minutes such as 12 or 12-15");
|
|
1120
|
+
}
|
|
1121
|
+
const minMinutes = Number(range[1]);
|
|
1122
|
+
const maxMinutes = Number(range[2] || range[1]);
|
|
1123
|
+
return {
|
|
1124
|
+
min: minMinutes * 60,
|
|
1125
|
+
max: maxMinutes * 60,
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function usage() {
|
|
1130
|
+
return [
|
|
1131
|
+
"Usage:",
|
|
1132
|
+
" node plan-adaptive-content.mjs --source-map <source-map.json>",
|
|
1133
|
+
" --template-profile <template-profile-v2.json> --out <content-plan.json>",
|
|
1134
|
+
" [--duration 12-15] [--density rich] [--scope core-and-appendix]",
|
|
1135
|
+
" [--template-mode style-only] [--allow-low-fit]",
|
|
1136
|
+
].join("\n");
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
async function main() {
|
|
1140
|
+
const args = parseArgs(process.argv.slice(2));
|
|
1141
|
+
if (args.help) {
|
|
1142
|
+
console.log(usage());
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
const sourceMapPath = path.resolve(requireArg(args, "source-map"));
|
|
1146
|
+
const profilePath = path.resolve(requireArg(args, "template-profile"));
|
|
1147
|
+
const outPath = path.resolve(requireArg(args, "out"));
|
|
1148
|
+
const sourceMap = await readJson(sourceMapPath);
|
|
1149
|
+
const templateProfile = await readJson(profilePath);
|
|
1150
|
+
const plan = buildAdaptiveContentPlan(sourceMap, templateProfile, {
|
|
1151
|
+
durationSeconds: parseDuration(args.duration),
|
|
1152
|
+
density: args.density,
|
|
1153
|
+
scope: args.scope,
|
|
1154
|
+
templateMode: args["template-mode"],
|
|
1155
|
+
allowLowFit: args["allow-low-fit"] === true,
|
|
1156
|
+
});
|
|
1157
|
+
const audit = auditContentPlan(plan, { planPath: outPath });
|
|
1158
|
+
if (!audit.passed) {
|
|
1159
|
+
throw new Error(
|
|
1160
|
+
`Adaptive content audit failed:\n- ${audit.errors.join("\n- ")}`,
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
await writeJson(outPath, plan);
|
|
1164
|
+
console.log(
|
|
1165
|
+
JSON.stringify(
|
|
1166
|
+
{
|
|
1167
|
+
out: outPath,
|
|
1168
|
+
sourceUnits: plan.planningSummary.sourceUnitCount,
|
|
1169
|
+
outputSlides: plan.planningSummary.outputSlideCount,
|
|
1170
|
+
coreSlides: plan.planningSummary.coreSlideCount,
|
|
1171
|
+
appendixSlides: plan.planningSummary.appendixSlideCount,
|
|
1172
|
+
estimatedCoreSeconds: plan.planningSummary.estimatedCoreSeconds,
|
|
1173
|
+
templateFit: plan.templateFit,
|
|
1174
|
+
planningFingerprint: plan.planningFingerprint,
|
|
1175
|
+
},
|
|
1176
|
+
null,
|
|
1177
|
+
2,
|
|
1178
|
+
),
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const isMain =
|
|
1183
|
+
process.argv[1] &&
|
|
1184
|
+
fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
1185
|
+
if (isMain) {
|
|
1186
|
+
main().catch((error) => {
|
|
1187
|
+
console.error(error.stack || error.message || String(error));
|
|
1188
|
+
if (error.details) console.error(JSON.stringify(error.details, null, 2));
|
|
1189
|
+
console.error(usage());
|
|
1190
|
+
process.exit(error.code === "DURATION_CONFLICT" ? 2 : 1);
|
|
1191
|
+
});
|
|
1192
|
+
}
|