@lightcone-ai/daemon 0.15.53 → 0.15.55
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/mcp-servers/_thin-proxy/forward.js +80 -0
- package/mcp-servers/official/audience-research/index.js +24 -376
- package/mcp-servers/official/hook-pattern-library/index.js +17 -410
- package/mcp-servers/official/keyword-research/index.js +17 -324
- package/mcp-servers/official/page-understanding/index.js +17 -96
- package/mcp-servers/official/platform-policy-db/index.js +19 -264
- package/mcp-servers/official/video-narration-planner/index.js +30 -130
- package/package.json +1 -1
- package/src/chat-bridge.js +1 -1
- package/mcp-servers/official/keyword-research/keyword-fixtures.json +0 -58
- package/mcp-servers/official/platform-policy-db/policy-fixtures.json +0 -257
- package/mcp-servers/official/video-narration-planner/core.js +0 -1403
- package/mcp-servers/official/video-narration-planner/planner-config.json +0 -112
- package/src/_vendor/video/understanding/analyze-page.js +0 -737
- package/src/_vendor/video/understanding/heuristics.js +0 -826
- package/src/_vendor/video/understanding/index.js +0 -11
- package/src/_vendor/video/understanding/llm-client.js +0 -261
- package/src/_vendor/video/understanding/schema.js +0 -254
- package/src/_vendor/video/understanding/site-selectors.js +0 -47
|
@@ -1,1403 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
4
|
-
|
|
5
|
-
export const VISUAL_ACTION_TYPES = Object.freeze([
|
|
6
|
-
'scroll_to_dwell',
|
|
7
|
-
'linear_scroll_during',
|
|
8
|
-
'cursor_focus',
|
|
9
|
-
'scroll_back',
|
|
10
|
-
]);
|
|
11
|
-
|
|
12
|
-
const VISUAL_ACTION_SET = new Set(VISUAL_ACTION_TYPES);
|
|
13
|
-
const PLANNER_CONFIG = loadPlannerConfig();
|
|
14
|
-
const DEFAULT_OUTRO_VIDEO_ID = PLANNER_CONFIG.default_outro_video_id;
|
|
15
|
-
const SENTENCE_MIN_CHARS = PLANNER_CONFIG.sentence_char_range.min;
|
|
16
|
-
const SENTENCE_MAX_CHARS = PLANNER_CONFIG.sentence_char_range.max;
|
|
17
|
-
const PLATFORM_PROFILES = PLANNER_CONFIG.platform_profiles;
|
|
18
|
-
const PLATFORM_LOOKUP = buildPlatformLookup(PLATFORM_PROFILES);
|
|
19
|
-
const SENTENCE_FILLERS = PLANNER_CONFIG.sentence_fillers;
|
|
20
|
-
|
|
21
|
-
const SEMANTIC_SLOT_KEYS = Object.freeze([
|
|
22
|
-
'company',
|
|
23
|
-
'published_at',
|
|
24
|
-
'recruitment_type',
|
|
25
|
-
'cohort',
|
|
26
|
-
'job_directions',
|
|
27
|
-
'locations',
|
|
28
|
-
'target_or_requirements',
|
|
29
|
-
'process',
|
|
30
|
-
'entry_or_cta',
|
|
31
|
-
]);
|
|
32
|
-
|
|
33
|
-
const NARRATION_MODES = PLANNER_CONFIG.narration_modes;
|
|
34
|
-
|
|
35
|
-
const NARRATION_MODE_SET = new Set(NARRATION_MODES);
|
|
36
|
-
const SLOT_STATUS_SET = new Set(['present', 'missing']);
|
|
37
|
-
|
|
38
|
-
const RECRUITMENT_KEYWORDS = buildKeywordRegex(PLANNER_CONFIG.recruitment_keyword_terms);
|
|
39
|
-
const RECRUITMENT_TYPE_LABELS = PLANNER_CONFIG.recruitment_type_labels;
|
|
40
|
-
const SLOT_LABELS = PLANNER_CONFIG.slot_labels;
|
|
41
|
-
const MODE_SEVERITY = PLANNER_CONFIG.mode_severity;
|
|
42
|
-
const MODE_MIN_CONFIDENCE = PLANNER_CONFIG.mode_min_confidence;
|
|
43
|
-
const RECRUITMENT_FAIL_CLOSED_GROUPS = PLANNER_CONFIG.recruitment_fail_closed_groups;
|
|
44
|
-
|
|
45
|
-
function isPlainObject(value) {
|
|
46
|
-
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function deepFreeze(value) {
|
|
50
|
-
if (!value || typeof value !== 'object') return value;
|
|
51
|
-
if (Object.isFrozen(value)) return value;
|
|
52
|
-
if (Array.isArray(value)) {
|
|
53
|
-
value.forEach(item => deepFreeze(item));
|
|
54
|
-
return Object.freeze(value);
|
|
55
|
-
}
|
|
56
|
-
for (const key of Object.keys(value)) {
|
|
57
|
-
deepFreeze(value[key]);
|
|
58
|
-
}
|
|
59
|
-
return Object.freeze(value);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function normalizeStringArray(value) {
|
|
63
|
-
if (!Array.isArray(value)) return [];
|
|
64
|
-
return value
|
|
65
|
-
.map(item => String(item ?? '').trim())
|
|
66
|
-
.filter(Boolean);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function normalizeNumberPair(value, fallback = [100, 150]) {
|
|
70
|
-
if (!Array.isArray(value) || value.length < 2) return [...fallback];
|
|
71
|
-
const first = Number(value[0]);
|
|
72
|
-
const second = Number(value[1]);
|
|
73
|
-
if (!Number.isFinite(first) || !Number.isFinite(second)) return [...fallback];
|
|
74
|
-
const min = Math.min(first, second);
|
|
75
|
-
const max = Math.max(first, second);
|
|
76
|
-
return [Math.round(min), Math.round(max)];
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function normalizeGroupList(groups) {
|
|
80
|
-
if (!Array.isArray(groups)) return [];
|
|
81
|
-
return groups
|
|
82
|
-
.map((group) => {
|
|
83
|
-
if (!isPlainObject(group)) return null;
|
|
84
|
-
const id = String(group.id ?? '').trim();
|
|
85
|
-
const keys = normalizeStringArray(group.keys);
|
|
86
|
-
if (!id || keys.length === 0) return null;
|
|
87
|
-
return { id, keys };
|
|
88
|
-
})
|
|
89
|
-
.filter(Boolean);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function normalizeLabelMap(value) {
|
|
93
|
-
if (!isPlainObject(value)) return {};
|
|
94
|
-
const normalized = {};
|
|
95
|
-
for (const [key, raw] of Object.entries(value)) {
|
|
96
|
-
const normalizedKey = String(key ?? '').trim();
|
|
97
|
-
const normalizedValue = String(raw ?? '').trim();
|
|
98
|
-
if (!normalizedKey || !normalizedValue) continue;
|
|
99
|
-
normalized[normalizedKey] = normalizedValue;
|
|
100
|
-
}
|
|
101
|
-
return normalized;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function buildPlatformLookup(profiles) {
|
|
105
|
-
const map = new Map();
|
|
106
|
-
for (const profile of Object.values(profiles ?? {})) {
|
|
107
|
-
const id = String(profile?.id ?? '').trim().toLowerCase();
|
|
108
|
-
if (!id) continue;
|
|
109
|
-
map.set(id, id);
|
|
110
|
-
const aliases = normalizeStringArray(profile.aliases);
|
|
111
|
-
for (const alias of aliases) {
|
|
112
|
-
map.set(alias.toLowerCase(), id);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
return map;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function escapeRegexToken(value) {
|
|
119
|
-
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function buildKeywordRegex(terms) {
|
|
123
|
-
const normalizedTerms = normalizeStringArray(terms);
|
|
124
|
-
if (normalizedTerms.length === 0) return /招聘|校招|实习|职位|岗位|career|jobs|intern|campus/i;
|
|
125
|
-
return new RegExp(normalizedTerms.map(escapeRegexToken).join('|'), 'i');
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function loadPlannerConfig() {
|
|
129
|
-
let parsed = null;
|
|
130
|
-
try {
|
|
131
|
-
parsed = JSON.parse(
|
|
132
|
-
readFileSync(new URL('./planner-config.json', import.meta.url), 'utf8')
|
|
133
|
-
);
|
|
134
|
-
} catch (error) {
|
|
135
|
-
throw new Error(`video_narration_planner_config_load_failed:${error.message}`);
|
|
136
|
-
}
|
|
137
|
-
if (!isPlainObject(parsed)) {
|
|
138
|
-
throw new Error('video_narration_planner_config_invalid:root_object_required');
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const platformProfiles = isPlainObject(parsed.platform_profiles) ? parsed.platform_profiles : {};
|
|
142
|
-
if (!isPlainObject(platformProfiles.xiaohongshu)) {
|
|
143
|
-
throw new Error('video_narration_planner_config_invalid:platform_profiles.xiaohongshu_required');
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const sentenceRange = normalizeNumberPair(
|
|
147
|
-
[parsed?.sentence_char_range?.min, parsed?.sentence_char_range?.max],
|
|
148
|
-
[100, 150]
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
const failClosedGroups = normalizeGroupList(parsed.recruitment_fail_closed_groups);
|
|
152
|
-
if (failClosedGroups.length === 0) {
|
|
153
|
-
throw new Error('video_narration_planner_config_invalid:recruitment_fail_closed_groups_required');
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const narrationModes = normalizeStringArray(parsed.narration_modes);
|
|
157
|
-
if (narrationModes.length === 0) {
|
|
158
|
-
throw new Error('video_narration_planner_config_invalid:narration_modes_required');
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const sentenceFillers = normalizeStringArray(parsed.sentence_fillers);
|
|
162
|
-
if (sentenceFillers.length === 0) {
|
|
163
|
-
throw new Error('video_narration_planner_config_invalid:sentence_fillers_required');
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
return deepFreeze({
|
|
167
|
-
default_outro_video_id: String(parsed.default_outro_video_id ?? 'outro-default-zh').trim() || 'outro-default-zh',
|
|
168
|
-
sentence_char_range: {
|
|
169
|
-
min: sentenceRange[0],
|
|
170
|
-
max: sentenceRange[1],
|
|
171
|
-
},
|
|
172
|
-
platform_profiles: platformProfiles,
|
|
173
|
-
sentence_fillers: sentenceFillers,
|
|
174
|
-
narration_modes: narrationModes,
|
|
175
|
-
recruitment_keyword_terms: normalizeStringArray(parsed.recruitment_keyword_terms),
|
|
176
|
-
recruitment_type_labels: normalizeLabelMap(parsed.recruitment_type_labels),
|
|
177
|
-
slot_labels: normalizeLabelMap(parsed.slot_labels),
|
|
178
|
-
mode_severity: isPlainObject(parsed.mode_severity) ? parsed.mode_severity : {},
|
|
179
|
-
mode_min_confidence: isPlainObject(parsed.mode_min_confidence) ? parsed.mode_min_confidence : {},
|
|
180
|
-
recruitment_fail_closed_groups: failClosedGroups,
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function clampNumber(value, min, max, fallback) {
|
|
185
|
-
const num = Number(value);
|
|
186
|
-
if (!Number.isFinite(num)) return fallback;
|
|
187
|
-
return Math.max(min, Math.min(max, num));
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function clampInt(value, min, max, fallback) {
|
|
191
|
-
return Math.round(clampNumber(value, min, max, fallback));
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function toSafeString(value) {
|
|
195
|
-
if (value == null) return '';
|
|
196
|
-
return String(value).trim();
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function toFiniteNumber(value) {
|
|
200
|
-
const num = Number(value);
|
|
201
|
-
return Number.isFinite(num) ? num : null;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function toCharLength(text) {
|
|
205
|
-
return Array.from(String(text ?? '')).length;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function trimToChars(text, maxChars) {
|
|
209
|
-
const chars = Array.from(String(text ?? ''));
|
|
210
|
-
if (chars.length <= maxChars) return chars.join('');
|
|
211
|
-
return chars.slice(0, maxChars).join('');
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function withSentencePunctuation(text) {
|
|
215
|
-
const value = toSafeString(text);
|
|
216
|
-
if (!value) return value;
|
|
217
|
-
if (/[。!?!?]$/.test(value)) return value;
|
|
218
|
-
return `${value}。`;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function ensureSentenceLength(text, minChars = SENTENCE_MIN_CHARS, maxChars = SENTENCE_MAX_CHARS) {
|
|
222
|
-
let output = withSentencePunctuation(text);
|
|
223
|
-
let pointer = 0;
|
|
224
|
-
while (toCharLength(output) < minChars) {
|
|
225
|
-
const filler = SENTENCE_FILLERS[pointer % SENTENCE_FILLERS.length];
|
|
226
|
-
output = withSentencePunctuation(`${output}${filler}`);
|
|
227
|
-
pointer += 1;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
if (toCharLength(output) > maxChars) {
|
|
231
|
-
output = trimToChars(output, maxChars);
|
|
232
|
-
output = output.replace(/[,,;;::]?[^,。!?!?]*$/, '');
|
|
233
|
-
if (!output) {
|
|
234
|
-
output = trimToChars(text, maxChars);
|
|
235
|
-
}
|
|
236
|
-
output = withSentencePunctuation(output);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
return output;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function normalizeTargetPlatform(value) {
|
|
243
|
-
const raw = toSafeString(value).toLowerCase();
|
|
244
|
-
if (!raw) return 'xiaohongshu';
|
|
245
|
-
return PLATFORM_LOOKUP.get(raw) ?? 'xiaohongshu';
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function getPlatformProfile(platform) {
|
|
249
|
-
const normalized = normalizeTargetPlatform(platform);
|
|
250
|
-
return PLATFORM_PROFILES[normalized] ?? PLATFORM_PROFILES.xiaohongshu;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function normalizeYRange(value) {
|
|
254
|
-
if (!Array.isArray(value) || value.length < 2) return null;
|
|
255
|
-
const start = toFiniteNumber(value[0]);
|
|
256
|
-
const end = toFiniteNumber(value[1]);
|
|
257
|
-
if (start == null || end == null) return null;
|
|
258
|
-
const low = Math.min(start, end);
|
|
259
|
-
const high = Math.max(start, end);
|
|
260
|
-
return [Math.round(low), Math.round(high)];
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function midpoint(range, fallback = 0) {
|
|
264
|
-
if (!Array.isArray(range) || range.length < 2) return fallback;
|
|
265
|
-
return Math.round((Number(range[0]) + Number(range[1])) / 2);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function coerceRangeByY(centerY, span = 220) {
|
|
269
|
-
const center = toFiniteNumber(centerY);
|
|
270
|
-
if (center == null) return null;
|
|
271
|
-
const half = Math.max(60, Math.round(span / 2));
|
|
272
|
-
return [Math.round(center - half), Math.round(center + half)];
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function inAnyRange(y, ranges = []) {
|
|
276
|
-
return ranges.some(([start, end]) => y >= start && y <= end);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function normalizeModeHint(value) {
|
|
280
|
-
const mode = toSafeString(value);
|
|
281
|
-
return NARRATION_MODE_SET.has(mode) ? mode : null;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function normalizeSlotValue(value) {
|
|
285
|
-
if (Array.isArray(value)) {
|
|
286
|
-
const output = [];
|
|
287
|
-
const seen = new Set();
|
|
288
|
-
for (const item of value) {
|
|
289
|
-
const text = toSafeString(item);
|
|
290
|
-
if (!text) continue;
|
|
291
|
-
const key = text.toLowerCase();
|
|
292
|
-
if (seen.has(key)) continue;
|
|
293
|
-
seen.add(key);
|
|
294
|
-
output.push(text);
|
|
295
|
-
}
|
|
296
|
-
return output.length > 0 ? output : null;
|
|
297
|
-
}
|
|
298
|
-
const text = toSafeString(value);
|
|
299
|
-
return text || null;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
function normalizeSemanticSlot(rawSlot = {}) {
|
|
303
|
-
const value = normalizeSlotValue(rawSlot?.value);
|
|
304
|
-
const statusRaw = toSafeString(rawSlot?.status);
|
|
305
|
-
const status = SLOT_STATUS_SET.has(statusRaw) ? statusRaw : (value == null ? 'missing' : 'present');
|
|
306
|
-
return {
|
|
307
|
-
value,
|
|
308
|
-
status,
|
|
309
|
-
confidence: Number(clampNumber(rawSlot?.confidence, 0, 1, 0).toFixed(2)),
|
|
310
|
-
focus_region: normalizeYRange(rawSlot?.focus_region ?? rawSlot?.focusRegion),
|
|
311
|
-
source_type: toSafeString(rawSlot?.source_type || rawSlot?.sourceType || 'heuristic') || 'heuristic',
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function collectSemanticSlots(understanding = {}) {
|
|
316
|
-
const raw = understanding?.semantic_slots;
|
|
317
|
-
const source = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
318
|
-
const slots = {};
|
|
319
|
-
for (const key of SEMANTIC_SLOT_KEYS) {
|
|
320
|
-
slots[key] = normalizeSemanticSlot(source[key]);
|
|
321
|
-
}
|
|
322
|
-
return slots;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function slotHasValue(slot) {
|
|
326
|
-
if (!slot || slot.status === 'missing') return false;
|
|
327
|
-
if (Array.isArray(slot.value)) return slot.value.length > 0;
|
|
328
|
-
return Boolean(toSafeString(slot.value));
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function slotConfidence(slot) {
|
|
332
|
-
const confidence = Number(slot?.confidence);
|
|
333
|
-
if (!Number.isFinite(confidence)) return 0;
|
|
334
|
-
return Math.max(0, Math.min(1, confidence));
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function slotHasMinConfidence(slot, min = 0) {
|
|
338
|
-
return slotHasValue(slot) && slotConfidence(slot) >= min;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
function slotValueToText(slot, { maxItems = 3 } = {}) {
|
|
342
|
-
if (!slotHasValue(slot)) return '';
|
|
343
|
-
if (!Array.isArray(slot.value)) return toSafeString(slot.value);
|
|
344
|
-
const picked = slot.value.slice(0, maxItems).map(item => toSafeString(item)).filter(Boolean);
|
|
345
|
-
if (picked.length <= 0) return '';
|
|
346
|
-
if (slot.value.length > picked.length) return `${picked.join('、')}等`;
|
|
347
|
-
return picked.join('、');
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function slotLabel(key) {
|
|
351
|
-
return SLOT_LABELS[key] || key;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
function formatRecruitmentType(slot) {
|
|
355
|
-
const raw = toSafeString(Array.isArray(slot?.value) ? slot.value[0] : slot?.value).toLowerCase();
|
|
356
|
-
if (!raw) return '';
|
|
357
|
-
return RECRUITMENT_TYPE_LABELS[raw] || raw;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function averageSlotConfidence(slots = []) {
|
|
361
|
-
const values = slots
|
|
362
|
-
.map(slot => Number(slot?.confidence))
|
|
363
|
-
.filter(value => Number.isFinite(value) && value > 0);
|
|
364
|
-
if (values.length <= 0) return 0;
|
|
365
|
-
return Number((values.reduce((sum, value) => sum + value, 0) / values.length).toFixed(2));
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
function hasAnySemanticSlot(slots = {}) {
|
|
369
|
-
return SEMANTIC_SLOT_KEYS.some(key => slotHasValue(slots[key]));
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
function detectRecruitmentRelated({ understanding = {}, slots = {} } = {}) {
|
|
373
|
-
const pageType = toSafeString(understanding?.page_type || understanding?.pageType).toLowerCase();
|
|
374
|
-
if (pageType === 'job_detail') return true;
|
|
375
|
-
|
|
376
|
-
const url = toSafeString(understanding?.url);
|
|
377
|
-
if (RECRUITMENT_KEYWORDS.test(url)) return true;
|
|
378
|
-
|
|
379
|
-
const coreMessage = toSafeString(understanding?.core_message || understanding?.coreMessage || understanding?.title);
|
|
380
|
-
if (RECRUITMENT_KEYWORDS.test(coreMessage)) return true;
|
|
381
|
-
|
|
382
|
-
return ['company', 'recruitment_type', 'job_directions', 'locations', 'process']
|
|
383
|
-
.some(key => slotHasValue(slots[key]));
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function shouldApplyRecruitmentFailClosed(understanding = {}) {
|
|
387
|
-
const pageType = toSafeString(understanding?.page_type || understanding?.pageType).toLowerCase();
|
|
388
|
-
if (pageType === 'job_detail') return true;
|
|
389
|
-
|
|
390
|
-
const url = toSafeString(understanding?.url);
|
|
391
|
-
if (RECRUITMENT_KEYWORDS.test(url)) return true;
|
|
392
|
-
|
|
393
|
-
const headline = toSafeString(understanding?.core_message || understanding?.coreMessage || understanding?.title);
|
|
394
|
-
if (RECRUITMENT_KEYWORDS.test(headline)) return true;
|
|
395
|
-
|
|
396
|
-
return false;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
function deriveModeFromSlots({ understanding = {}, slots = {} } = {}) {
|
|
400
|
-
const recruitmentRelated = detectRecruitmentRelated({ understanding, slots });
|
|
401
|
-
|
|
402
|
-
const companyStrong = slotHasMinConfidence(slots.company, MODE_MIN_CONFIDENCE.strong);
|
|
403
|
-
const publishedStrong = slotHasMinConfidence(slots.published_at, MODE_MIN_CONFIDENCE.strong);
|
|
404
|
-
const cohortSupport = slotHasMinConfidence(slots.cohort, MODE_MIN_CONFIDENCE.support);
|
|
405
|
-
const jobDirectionsSupport = slotHasMinConfidence(slots.job_directions, MODE_MIN_CONFIDENCE.support);
|
|
406
|
-
const locationsSupport = slotHasMinConfidence(slots.locations, MODE_MIN_CONFIDENCE.support);
|
|
407
|
-
const processSignal = slotHasMinConfidence(slots.process, MODE_MIN_CONFIDENCE.weakSignal);
|
|
408
|
-
const recruitmentTypeValue = toSafeString(Array.isArray(slots.recruitment_type?.value)
|
|
409
|
-
? slots.recruitment_type.value[0]
|
|
410
|
-
: slots.recruitment_type?.value);
|
|
411
|
-
const recruitmentTypeKnown = ['campus', 'intern', 'experienced'].includes(recruitmentTypeValue);
|
|
412
|
-
const recruitmentTypePresent = recruitmentTypeKnown || recruitmentTypeValue === 'job_posting_unknown';
|
|
413
|
-
const recruitmentTypeStrong = recruitmentTypeKnown
|
|
414
|
-
&& slotHasMinConfidence(slots.recruitment_type, MODE_MIN_CONFIDENCE.strong);
|
|
415
|
-
const recruitmentTypeSignal = recruitmentTypePresent
|
|
416
|
-
&& slotHasMinConfidence(slots.recruitment_type, MODE_MIN_CONFIDENCE.weakSignal);
|
|
417
|
-
|
|
418
|
-
const keySlots = [slots.company, slots.published_at, slots.recruitment_type, slots.job_directions, slots.locations, slots.process];
|
|
419
|
-
const confidence = averageSlotConfidence(keySlots);
|
|
420
|
-
const reliableSignalCount = [
|
|
421
|
-
companyStrong,
|
|
422
|
-
publishedStrong,
|
|
423
|
-
recruitmentTypeSignal,
|
|
424
|
-
jobDirectionsSupport,
|
|
425
|
-
locationsSupport,
|
|
426
|
-
processSignal,
|
|
427
|
-
].filter(Boolean).length;
|
|
428
|
-
|
|
429
|
-
if (companyStrong && ((publishedStrong && recruitmentTypeStrong) || (cohortSupport && jobDirectionsSupport) || (recruitmentTypeStrong && locationsSupport))) {
|
|
430
|
-
return { mode: 'job_intel_broadcast', confidence: Number(Math.max(0.7, confidence).toFixed(2)) };
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
if (companyStrong && publishedStrong && recruitmentTypeSignal) {
|
|
434
|
-
return { mode: 'job_alert', confidence: Number(Math.max(0.6, confidence).toFixed(2)) };
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
if (recruitmentRelated
|
|
438
|
-
&& reliableSignalCount > 0
|
|
439
|
-
&& confidence >= MODE_MIN_CONFIDENCE.floor) {
|
|
440
|
-
return { mode: 'info_summary', confidence: Number(Math.max(0.42, confidence).toFixed(2)) };
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
return { mode: 'refuse_auto_broadcast', confidence: Number(Math.max(0.2, confidence * 0.6).toFixed(2)) };
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
function evaluateRecruitmentFailClosed(slots = {}) {
|
|
447
|
-
const checks = {
|
|
448
|
-
company: slotHasMinConfidence(slots.company, MODE_MIN_CONFIDENCE.strong),
|
|
449
|
-
published_at: slotHasMinConfidence(slots.published_at, MODE_MIN_CONFIDENCE.support),
|
|
450
|
-
recruitment_type: slotHasMinConfidence(slots.recruitment_type, MODE_MIN_CONFIDENCE.weakSignal),
|
|
451
|
-
cohort: slotHasMinConfidence(slots.cohort, MODE_MIN_CONFIDENCE.support),
|
|
452
|
-
job_directions: slotHasMinConfidence(slots.job_directions, MODE_MIN_CONFIDENCE.support),
|
|
453
|
-
locations: slotHasMinConfidence(slots.locations, MODE_MIN_CONFIDENCE.support),
|
|
454
|
-
};
|
|
455
|
-
|
|
456
|
-
let bestGroup = null;
|
|
457
|
-
for (const group of RECRUITMENT_FAIL_CLOSED_GROUPS) {
|
|
458
|
-
const hitCount = group.keys.filter(key => checks[key]).length;
|
|
459
|
-
if (!bestGroup || hitCount > bestGroup.hitCount) {
|
|
460
|
-
bestGroup = { ...group, hitCount };
|
|
461
|
-
}
|
|
462
|
-
if (hitCount === group.keys.length) {
|
|
463
|
-
return {
|
|
464
|
-
passed: true,
|
|
465
|
-
matched_group: group.id,
|
|
466
|
-
missing_keys: [],
|
|
467
|
-
};
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
const missingKeys = bestGroup
|
|
472
|
-
? bestGroup.keys.filter(key => !checks[key])
|
|
473
|
-
: ['company', 'published_at', 'recruitment_type'];
|
|
474
|
-
|
|
475
|
-
return {
|
|
476
|
-
passed: false,
|
|
477
|
-
matched_group: null,
|
|
478
|
-
missing_keys: missingKeys,
|
|
479
|
-
required_groups: RECRUITMENT_FAIL_CLOSED_GROUPS.map(group => [...group.keys]),
|
|
480
|
-
};
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
function resolveNarrationMode({ understanding = {}, slots = {} } = {}) {
|
|
484
|
-
const hintMode = normalizeModeHint(understanding?.mode_hint ?? understanding?.modeHint);
|
|
485
|
-
const hintConfidence = Number(clampNumber(understanding?.mode_hint_confidence ?? understanding?.modeHintConfidence, 0, 1, 0));
|
|
486
|
-
const derived = deriveModeFromSlots({ understanding, slots });
|
|
487
|
-
|
|
488
|
-
if (!hintMode) {
|
|
489
|
-
return {
|
|
490
|
-
mode: derived.mode,
|
|
491
|
-
confidence: derived.confidence,
|
|
492
|
-
source: 'derived',
|
|
493
|
-
};
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
if (hintMode === derived.mode) {
|
|
497
|
-
return {
|
|
498
|
-
mode: hintMode,
|
|
499
|
-
confidence: Number(Math.max(hintConfidence, derived.confidence).toFixed(2)),
|
|
500
|
-
source: 'hint+derived',
|
|
501
|
-
};
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
const hintedScore = MODE_SEVERITY[hintMode] ?? 1;
|
|
505
|
-
const derivedScore = MODE_SEVERITY[derived.mode] ?? 1;
|
|
506
|
-
const conservativeMode = hintedScore <= derivedScore ? hintMode : derived.mode;
|
|
507
|
-
const conservativeConfidence = conservativeMode === hintMode ? hintConfidence : derived.confidence;
|
|
508
|
-
return {
|
|
509
|
-
mode: conservativeMode,
|
|
510
|
-
confidence: Number(conservativeConfidence.toFixed(2)),
|
|
511
|
-
source: 'conservative_merge',
|
|
512
|
-
hinted_mode: hintMode,
|
|
513
|
-
derived_mode: derived.mode,
|
|
514
|
-
};
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
function focusRegionToHighlight({ focusRegion, fallbackY, id, text, confidence = 0.5 }) {
|
|
518
|
-
const yRange = normalizeYRange(focusRegion) ?? coerceRangeByY(fallbackY, 260);
|
|
519
|
-
if (!yRange) return null;
|
|
520
|
-
return {
|
|
521
|
-
id,
|
|
522
|
-
text: toSafeString(text) || null,
|
|
523
|
-
y_range: yRange,
|
|
524
|
-
target_y: midpoint(yRange, fallbackY ?? 0),
|
|
525
|
-
confidence: Number(clampNumber(confidence, 0, 1, 0).toFixed(2)),
|
|
526
|
-
type: 'semantic_slot',
|
|
527
|
-
};
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
function parseHotspot(raw, index = 0) {
|
|
531
|
-
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
532
|
-
|
|
533
|
-
const id = toSafeString(raw.id || raw.hotspot_id || raw.name) || `hotspot_${index + 1}`;
|
|
534
|
-
const text = toSafeString(raw.text_excerpt || raw.textExcerpt || raw.text || raw.reason || raw.label);
|
|
535
|
-
const weight = clampNumber(raw.weight ?? raw.score ?? raw.priority ?? raw.rank ?? 1, 0, 10, 1);
|
|
536
|
-
|
|
537
|
-
let yRange = normalizeYRange(raw.y_range ?? raw.yRange ?? raw.range);
|
|
538
|
-
if (!yRange) {
|
|
539
|
-
const center = raw.y_center ?? raw.yCenter ?? raw.target_y ?? raw.targetY ?? raw.y;
|
|
540
|
-
yRange = coerceRangeByY(center);
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
if (!yRange) return null;
|
|
544
|
-
const targetY = midpoint(yRange, 0);
|
|
545
|
-
|
|
546
|
-
return {
|
|
547
|
-
id,
|
|
548
|
-
text,
|
|
549
|
-
weight,
|
|
550
|
-
y_range: yRange,
|
|
551
|
-
target_y: targetY,
|
|
552
|
-
type: toSafeString(raw.type || raw.kind) || null,
|
|
553
|
-
};
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
function collectHotspots(understanding = {}) {
|
|
557
|
-
const pools = [
|
|
558
|
-
understanding.visual_hotspots,
|
|
559
|
-
understanding.visualHotspots,
|
|
560
|
-
understanding.hotspots,
|
|
561
|
-
understanding.candidate_hotspots,
|
|
562
|
-
understanding.candidateHotspots,
|
|
563
|
-
understanding.highlights,
|
|
564
|
-
];
|
|
565
|
-
|
|
566
|
-
const rows = [];
|
|
567
|
-
for (const pool of pools) {
|
|
568
|
-
if (!Array.isArray(pool)) continue;
|
|
569
|
-
for (const item of pool) {
|
|
570
|
-
rows.push(item);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
return rows
|
|
575
|
-
.map((item, index) => parseHotspot(item, index))
|
|
576
|
-
.filter(Boolean);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
function collectSkipZones(understanding = {}) {
|
|
580
|
-
const pools = [
|
|
581
|
-
understanding.skip_zones,
|
|
582
|
-
understanding.skipZones,
|
|
583
|
-
understanding.ignore_zones,
|
|
584
|
-
understanding.ignoreZones,
|
|
585
|
-
];
|
|
586
|
-
|
|
587
|
-
const ranges = [];
|
|
588
|
-
for (const pool of pools) {
|
|
589
|
-
if (!Array.isArray(pool)) continue;
|
|
590
|
-
for (const item of pool) {
|
|
591
|
-
const range = normalizeYRange(item?.y_range ?? item?.yRange ?? item?.range ?? item);
|
|
592
|
-
if (range) ranges.push(range);
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
return ranges;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
function resolveCoreRange(understanding = {}, hotspots = []) {
|
|
599
|
-
const direct = normalizeYRange(understanding.core_y_range ?? understanding.coreYRange);
|
|
600
|
-
if (direct) return direct;
|
|
601
|
-
|
|
602
|
-
if (hotspots.length > 0) {
|
|
603
|
-
const ys = hotspots.flatMap(item => item.y_range);
|
|
604
|
-
const minY = Math.min(...ys);
|
|
605
|
-
const maxY = Math.max(...ys);
|
|
606
|
-
if (Number.isFinite(minY) && Number.isFinite(maxY) && maxY > minY) {
|
|
607
|
-
return [Math.max(0, Math.round(minY - 120)), Math.round(maxY + 120)];
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
const totalHeight = clampInt(understanding.total_height_px ?? understanding.totalHeightPx ?? 2400, 1200, 12000, 2400);
|
|
612
|
-
return [0, totalHeight];
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
function pickHighlightHotspots({ hotspots, skipZones, coreRange, maxHighlights = 3 }) {
|
|
616
|
-
const [coreStart, coreEnd] = coreRange;
|
|
617
|
-
const candidates = hotspots
|
|
618
|
-
.filter((item) => item.target_y >= coreStart && item.target_y <= coreEnd)
|
|
619
|
-
.filter((item) => !inAnyRange(item.target_y, skipZones))
|
|
620
|
-
.sort((a, b) => {
|
|
621
|
-
if (b.weight !== a.weight) return b.weight - a.weight;
|
|
622
|
-
return a.target_y - b.target_y;
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
const picked = [];
|
|
626
|
-
for (const candidate of candidates) {
|
|
627
|
-
if (picked.length >= maxHighlights) break;
|
|
628
|
-
const tooClose = picked.some(existing => Math.abs(existing.target_y - candidate.target_y) < 260);
|
|
629
|
-
if (tooClose) continue;
|
|
630
|
-
picked.push(candidate);
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
if (picked.length > 0) return picked;
|
|
634
|
-
|
|
635
|
-
const fallbackY = clampInt((coreStart + coreEnd) / 2, coreStart, coreEnd, coreStart);
|
|
636
|
-
return [{
|
|
637
|
-
id: 'fallback_center',
|
|
638
|
-
text: '核心信息总览',
|
|
639
|
-
weight: 1,
|
|
640
|
-
y_range: coerceRangeByY(fallbackY, 240) ?? [fallbackY - 120, fallbackY + 120],
|
|
641
|
-
target_y: fallbackY,
|
|
642
|
-
type: 'fallback',
|
|
643
|
-
}];
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
function roundDuration(value) {
|
|
647
|
-
return Number(clampNumber(value, 1, 120, 1).toFixed(1));
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
function roundSpeed(value) {
|
|
651
|
-
return Number(clampNumber(value, 0.5, 2.0, 1).toFixed(2));
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
function computeTotalDuration({ requestedDuration, understanding, profile }) {
|
|
655
|
-
const [minS, maxS] = profile.duration_range_s;
|
|
656
|
-
|
|
657
|
-
const direct = toFiniteNumber(requestedDuration ?? understanding?.target_duration_s ?? understanding?.targetDurationS);
|
|
658
|
-
if (direct != null) return clampInt(direct, minS, maxS, profile.default_total_duration_s);
|
|
659
|
-
|
|
660
|
-
const recommended = understanding?.recommended_duration_s ?? understanding?.recommendedDurationS;
|
|
661
|
-
const range = normalizeYRange(recommended);
|
|
662
|
-
if (range) {
|
|
663
|
-
const mid = Math.round((range[0] + range[1]) / 2);
|
|
664
|
-
return clampInt(mid, minS, maxS, profile.default_total_duration_s);
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
const recommendedSingle = toFiniteNumber(recommended);
|
|
668
|
-
if (recommendedSingle != null) {
|
|
669
|
-
return clampInt(recommendedSingle, minS, maxS, profile.default_total_duration_s);
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
return profile.default_total_duration_s;
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
function distributeHighlightDurations({ totalDuration, profile, highlightCount }) {
|
|
676
|
-
const hook = profile.hook_duration_s;
|
|
677
|
-
const cta = profile.cta_duration_s;
|
|
678
|
-
|
|
679
|
-
if (highlightCount <= 0) {
|
|
680
|
-
return {
|
|
681
|
-
hook,
|
|
682
|
-
cta: Math.max(profile.cta_duration_s, totalDuration - hook),
|
|
683
|
-
highlights: [],
|
|
684
|
-
};
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
const remaining = Math.max(totalDuration - hook - cta, profile.min_highlight_duration_s * highlightCount);
|
|
688
|
-
const rawEach = remaining / highlightCount;
|
|
689
|
-
const each = Math.max(profile.min_highlight_duration_s, rawEach);
|
|
690
|
-
const highlights = new Array(highlightCount).fill(0).map(() => roundDuration(each));
|
|
691
|
-
|
|
692
|
-
const consumed = hook + cta + highlights.reduce((sum, item) => sum + item, 0);
|
|
693
|
-
const delta = roundDuration(totalDuration - consumed);
|
|
694
|
-
if (Math.abs(delta) >= 0.1 && highlights.length > 0) {
|
|
695
|
-
highlights[highlights.length - 1] = roundDuration(highlights[highlights.length - 1] + delta);
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
return { hook, cta, highlights };
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
function buildHookAction({ coreRange, firstHighlight }) {
|
|
702
|
-
if (firstHighlight?.id) {
|
|
703
|
-
return {
|
|
704
|
-
type: 'cursor_focus',
|
|
705
|
-
target_hotspot: firstHighlight.id,
|
|
706
|
-
target_y: firstHighlight.target_y,
|
|
707
|
-
reason: '聚焦第一关键点,建立开场锚点',
|
|
708
|
-
};
|
|
709
|
-
}
|
|
710
|
-
return {
|
|
711
|
-
type: 'scroll_to_dwell',
|
|
712
|
-
target_y: coreRange[0],
|
|
713
|
-
transition_ms: 700,
|
|
714
|
-
reason: '无显式热点时先定位核心区起点',
|
|
715
|
-
};
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
function buildHighlightAction({ highlight, index, previousY }) {
|
|
719
|
-
const currentY = highlight.target_y;
|
|
720
|
-
|
|
721
|
-
if (index === 0 || previousY == null) {
|
|
722
|
-
return {
|
|
723
|
-
type: 'scroll_to_dwell',
|
|
724
|
-
target_hotspot: highlight.id,
|
|
725
|
-
target_y: currentY,
|
|
726
|
-
transition_ms: 800,
|
|
727
|
-
};
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
const distance = Math.abs(currentY - previousY);
|
|
731
|
-
if (distance >= 420) {
|
|
732
|
-
return {
|
|
733
|
-
type: 'linear_scroll_during',
|
|
734
|
-
target_hotspot: highlight.id,
|
|
735
|
-
from_y: previousY,
|
|
736
|
-
to_y: currentY,
|
|
737
|
-
};
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
return {
|
|
741
|
-
type: 'cursor_focus',
|
|
742
|
-
target_hotspot: highlight.id,
|
|
743
|
-
target_y: currentY,
|
|
744
|
-
};
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
function buildCtaAction({ coreRange }) {
|
|
748
|
-
return {
|
|
749
|
-
type: 'scroll_back',
|
|
750
|
-
target_y: coreRange[0],
|
|
751
|
-
transition_ms: 900,
|
|
752
|
-
reason: '收尾回到核心起点,承接 CTA',
|
|
753
|
-
};
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
function buildNarrativeArc(highlights = []) {
|
|
757
|
-
return [
|
|
758
|
-
'hook',
|
|
759
|
-
...highlights.map((_, index) => `highlight_${index + 1}`),
|
|
760
|
-
'cta',
|
|
761
|
-
];
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
function resolveFallbackY({ coreRange, hotspots, index = 0 }) {
|
|
765
|
-
if (Array.isArray(hotspots) && hotspots[index]?.target_y != null) {
|
|
766
|
-
return hotspots[index].target_y;
|
|
767
|
-
}
|
|
768
|
-
return midpoint(coreRange, coreRange[0] ?? 0);
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
function pickFirstAvailableSlot(slots, keys = []) {
|
|
772
|
-
for (const key of keys) {
|
|
773
|
-
if (slotHasValue(slots[key])) return key;
|
|
774
|
-
}
|
|
775
|
-
return null;
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
function pickSlotKeysForMode({ mode, slots }) {
|
|
779
|
-
if (mode === 'job_intel_broadcast') {
|
|
780
|
-
const ordered = ['job_directions', 'locations', 'process', 'target_or_requirements', 'cohort'];
|
|
781
|
-
return ordered.filter(key => slotHasValue(slots[key])).slice(0, 3);
|
|
782
|
-
}
|
|
783
|
-
if (mode === 'job_alert') {
|
|
784
|
-
const ordered = ['job_directions', 'locations', 'cohort'];
|
|
785
|
-
return ordered.filter(key => slotHasValue(slots[key])).slice(0, 1);
|
|
786
|
-
}
|
|
787
|
-
if (mode === 'info_summary') {
|
|
788
|
-
const ordered = ['target_or_requirements', 'process', 'job_directions'];
|
|
789
|
-
return ordered.filter(key => slotHasValue(slots[key])).slice(0, 1);
|
|
790
|
-
}
|
|
791
|
-
return [];
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
function buildSlotBackedHighlights({
|
|
795
|
-
slots,
|
|
796
|
-
slotKeys = [],
|
|
797
|
-
hotspots = [],
|
|
798
|
-
coreRange = [0, 0],
|
|
799
|
-
}) {
|
|
800
|
-
const highlights = [];
|
|
801
|
-
const usedTargets = [];
|
|
802
|
-
for (let i = 0; i < slotKeys.length; i += 1) {
|
|
803
|
-
const key = slotKeys[i];
|
|
804
|
-
const slot = slots[key];
|
|
805
|
-
const fallbackY = resolveFallbackY({ coreRange, hotspots, index: i });
|
|
806
|
-
const text = key === 'recruitment_type'
|
|
807
|
-
? formatRecruitmentType(slot)
|
|
808
|
-
: slotValueToText(slot);
|
|
809
|
-
const base = focusRegionToHighlight({
|
|
810
|
-
focusRegion: slot?.focus_region,
|
|
811
|
-
fallbackY,
|
|
812
|
-
id: `slot_${key}`,
|
|
813
|
-
text: text || `${slotLabel(key)}线索`,
|
|
814
|
-
confidence: slot?.confidence ?? 0.5,
|
|
815
|
-
});
|
|
816
|
-
if (!base) continue;
|
|
817
|
-
const tooClose = usedTargets.some(target => Math.abs(target - base.target_y) < 220);
|
|
818
|
-
if (tooClose) continue;
|
|
819
|
-
highlights.push({
|
|
820
|
-
...base,
|
|
821
|
-
semantic_slot: key,
|
|
822
|
-
slot_confidence: Number(clampNumber(slot?.confidence, 0, 1, 0).toFixed(2)),
|
|
823
|
-
});
|
|
824
|
-
usedTargets.push(base.target_y);
|
|
825
|
-
}
|
|
826
|
-
return highlights;
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
function buildSemanticSummary(slots = {}) {
|
|
830
|
-
return {
|
|
831
|
-
company: slotValueToText(slots.company),
|
|
832
|
-
published_at: slotValueToText(slots.published_at),
|
|
833
|
-
recruitment_type: formatRecruitmentType(slots.recruitment_type) || slotValueToText(slots.recruitment_type),
|
|
834
|
-
cohort: slotValueToText(slots.cohort),
|
|
835
|
-
job_directions: slotValueToText(slots.job_directions),
|
|
836
|
-
locations: slotValueToText(slots.locations),
|
|
837
|
-
target_or_requirements: slotValueToText(slots.target_or_requirements),
|
|
838
|
-
process: slotValueToText(slots.process),
|
|
839
|
-
entry_or_cta: slotValueToText(slots.entry_or_cta),
|
|
840
|
-
};
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
function describeCameraMotion(actionType) {
|
|
844
|
-
if (actionType === 'linear_scroll_during') return 'narrated_pan';
|
|
845
|
-
if (actionType === 'cursor_focus') return 'cursor_focus';
|
|
846
|
-
if (actionType === 'scroll_back') return 'return_anchor';
|
|
847
|
-
return 'focus_hold';
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
export function planVideo({
|
|
851
|
-
understanding = {},
|
|
852
|
-
persona = '',
|
|
853
|
-
target_platform = 'xiaohongshu',
|
|
854
|
-
total_duration_s = null,
|
|
855
|
-
} = {}) {
|
|
856
|
-
const profile = getPlatformProfile(target_platform);
|
|
857
|
-
const normalizedPersona = toSafeString(persona || understanding?.persona || '目标受众');
|
|
858
|
-
const coreMessage = toSafeString(understanding?.core_message || understanding?.coreMessage || understanding?.title) || '本页价值点概览';
|
|
859
|
-
|
|
860
|
-
const hotspots = collectHotspots(understanding);
|
|
861
|
-
const baseSkipZones = collectSkipZones(understanding);
|
|
862
|
-
const coreRange = resolveCoreRange(understanding, hotspots);
|
|
863
|
-
const semanticSlots = collectSemanticSlots(understanding);
|
|
864
|
-
const recruitmentFailClosedEnabled = shouldApplyRecruitmentFailClosed(understanding);
|
|
865
|
-
const failClosedGate = evaluateRecruitmentFailClosed(semanticSlots);
|
|
866
|
-
if (recruitmentFailClosedEnabled && !failClosedGate.passed) {
|
|
867
|
-
const missingLabels = failClosedGate.missing_keys
|
|
868
|
-
.map(key => slotLabel(key))
|
|
869
|
-
.filter(Boolean);
|
|
870
|
-
const requiredLabels = RECRUITMENT_FAIL_CLOSED_GROUPS
|
|
871
|
-
.map(group => group.keys.map(key => slotLabel(key)).join('+'))
|
|
872
|
-
.join(' | ');
|
|
873
|
-
throw new Error(
|
|
874
|
-
`recruitment_fail_closed:missing=${missingLabels.join('、') || '关键槽位'};required_any_of=${requiredLabels}`
|
|
875
|
-
);
|
|
876
|
-
}
|
|
877
|
-
const hasSemanticSlots = hasAnySemanticSlot(semanticSlots);
|
|
878
|
-
const modeInfo = resolveNarrationMode({ understanding, slots: semanticSlots });
|
|
879
|
-
const semanticSummary = buildSemanticSummary(semanticSlots);
|
|
880
|
-
|
|
881
|
-
const isRecruitmentMode = modeInfo.mode === 'job_intel_broadcast' || modeInfo.mode === 'job_alert';
|
|
882
|
-
// For recruitment pages, treat the entry/CTA region as a hard skip zone so the
|
|
883
|
-
// camera never scrolls into the QR-code / application-link area.
|
|
884
|
-
const entrySkipRange = isRecruitmentMode
|
|
885
|
-
? normalizeYRange(semanticSlots.entry_or_cta?.focus_region)
|
|
886
|
-
: null;
|
|
887
|
-
const skipZones = entrySkipRange
|
|
888
|
-
? [...baseSkipZones, entrySkipRange]
|
|
889
|
-
: baseSkipZones;
|
|
890
|
-
|
|
891
|
-
const hotspotHighlights = pickHighlightHotspots({
|
|
892
|
-
hotspots,
|
|
893
|
-
skipZones,
|
|
894
|
-
coreRange,
|
|
895
|
-
maxHighlights: 3,
|
|
896
|
-
}).slice(0, 3);
|
|
897
|
-
|
|
898
|
-
const totalDuration = computeTotalDuration({
|
|
899
|
-
requestedDuration: total_duration_s,
|
|
900
|
-
understanding,
|
|
901
|
-
profile,
|
|
902
|
-
});
|
|
903
|
-
|
|
904
|
-
const maxHighlightsByBudget = Math.max(
|
|
905
|
-
1,
|
|
906
|
-
Math.floor(
|
|
907
|
-
Math.max(totalDuration - profile.hook_duration_s - profile.cta_duration_s, profile.min_highlight_duration_s)
|
|
908
|
-
/ profile.min_highlight_duration_s
|
|
909
|
-
)
|
|
910
|
-
);
|
|
911
|
-
const semanticHighlightKeys = pickSlotKeysForMode({
|
|
912
|
-
mode: modeInfo.mode,
|
|
913
|
-
slots: semanticSlots,
|
|
914
|
-
});
|
|
915
|
-
const semanticHighlights = buildSlotBackedHighlights({
|
|
916
|
-
slots: semanticSlots,
|
|
917
|
-
slotKeys: semanticHighlightKeys,
|
|
918
|
-
hotspots: hotspotHighlights,
|
|
919
|
-
coreRange,
|
|
920
|
-
});
|
|
921
|
-
|
|
922
|
-
let normalizedHighlights = semanticHighlights;
|
|
923
|
-
if (!hasSemanticSlots || normalizedHighlights.length === 0) {
|
|
924
|
-
normalizedHighlights = hotspotHighlights.map(item => ({
|
|
925
|
-
...item,
|
|
926
|
-
semantic_slot: null,
|
|
927
|
-
slot_confidence: null,
|
|
928
|
-
}));
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
const expectedHighlights = modeInfo.mode === 'refuse_auto_broadcast'
|
|
932
|
-
? 0
|
|
933
|
-
: Math.max(1, Math.min(3, maxHighlightsByBudget));
|
|
934
|
-
normalizedHighlights = normalizedHighlights.slice(0, expectedHighlights);
|
|
935
|
-
|
|
936
|
-
const allocation = distributeHighlightDurations({
|
|
937
|
-
totalDuration,
|
|
938
|
-
profile,
|
|
939
|
-
highlightCount: normalizedHighlights.length,
|
|
940
|
-
});
|
|
941
|
-
|
|
942
|
-
const phasePlan = [];
|
|
943
|
-
const hookSlotKey = pickFirstAvailableSlot(semanticSlots, ['company', 'published_at', 'recruitment_type', 'cohort']);
|
|
944
|
-
const hookSlot = hookSlotKey ? semanticSlots[hookSlotKey] : null;
|
|
945
|
-
const hookAnchor = focusRegionToHighlight({
|
|
946
|
-
focusRegion: hookSlot?.focus_region,
|
|
947
|
-
fallbackY: resolveFallbackY({ coreRange, hotspots: hotspotHighlights, index: 0 }),
|
|
948
|
-
id: hookSlotKey ? `slot_${hookSlotKey}` : 'hook_anchor',
|
|
949
|
-
text: hookSlotKey === 'recruitment_type'
|
|
950
|
-
? (formatRecruitmentType(hookSlot) || slotLabel(hookSlotKey))
|
|
951
|
-
: (slotValueToText(hookSlot) || slotLabel(hookSlotKey || 'company')),
|
|
952
|
-
confidence: hookSlot?.confidence ?? 0.4,
|
|
953
|
-
});
|
|
954
|
-
|
|
955
|
-
phasePlan.push({
|
|
956
|
-
phase_id: 'hook',
|
|
957
|
-
role: 'hook',
|
|
958
|
-
duration_s: roundDuration(allocation.hook),
|
|
959
|
-
visual_action: buildHookAction({
|
|
960
|
-
coreRange,
|
|
961
|
-
firstHighlight: hookAnchor ?? normalizedHighlights[0],
|
|
962
|
-
}),
|
|
963
|
-
semantic_slot: hookSlotKey,
|
|
964
|
-
focus_region: hookAnchor?.y_range ?? null,
|
|
965
|
-
confidence: hookSlot ? Number(clampNumber(hookSlot.confidence, 0, 1, 0).toFixed(2)) : null,
|
|
966
|
-
guidance: `开场先说结论,语气为${profile.tone}`,
|
|
967
|
-
});
|
|
968
|
-
|
|
969
|
-
let previousY = hookAnchor?.target_y ?? null;
|
|
970
|
-
for (let i = 0; i < normalizedHighlights.length; i += 1) {
|
|
971
|
-
const highlight = normalizedHighlights[i];
|
|
972
|
-
const phaseId = `highlight_${i + 1}`;
|
|
973
|
-
phasePlan.push({
|
|
974
|
-
phase_id: phaseId,
|
|
975
|
-
role: 'highlight',
|
|
976
|
-
highlight_index: i + 1,
|
|
977
|
-
duration_s: roundDuration(allocation.highlights[i] ?? profile.min_highlight_duration_s),
|
|
978
|
-
visual_action: buildHighlightAction({
|
|
979
|
-
highlight,
|
|
980
|
-
index: i,
|
|
981
|
-
previousY,
|
|
982
|
-
}),
|
|
983
|
-
highlight: {
|
|
984
|
-
id: highlight.id,
|
|
985
|
-
text: highlight.text || null,
|
|
986
|
-
y_range: highlight.y_range,
|
|
987
|
-
target_y: highlight.target_y,
|
|
988
|
-
},
|
|
989
|
-
semantic_slot: toSafeString(highlight.semantic_slot) || null,
|
|
990
|
-
focus_region: normalizeYRange(highlight.y_range),
|
|
991
|
-
confidence: highlight.slot_confidence == null
|
|
992
|
-
? Number(clampNumber(highlight.confidence, 0, 1, 0).toFixed(2))
|
|
993
|
-
: Number(clampNumber(highlight.slot_confidence, 0, 1, 0).toFixed(2)),
|
|
994
|
-
guidance: '讲清这段信息的结论、证据与行动意义',
|
|
995
|
-
});
|
|
996
|
-
previousY = highlight.target_y;
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
// Recruitment pages: never navigate to entry/QR-code area for CTA.
|
|
1000
|
-
const ctaSlotCandidates = isRecruitmentMode
|
|
1001
|
-
? ['process', 'company']
|
|
1002
|
-
: ['entry_or_cta', 'process', 'company'];
|
|
1003
|
-
const ctaSlotKey = pickFirstAvailableSlot(semanticSlots, ctaSlotCandidates);
|
|
1004
|
-
const ctaSlot = ctaSlotKey ? semanticSlots[ctaSlotKey] : null;
|
|
1005
|
-
const ctaAnchor = focusRegionToHighlight({
|
|
1006
|
-
focusRegion: ctaSlot?.focus_region,
|
|
1007
|
-
fallbackY: resolveFallbackY({ coreRange, hotspots: hotspotHighlights, index: 2 }),
|
|
1008
|
-
id: ctaSlotKey ? `slot_${ctaSlotKey}` : 'cta_anchor',
|
|
1009
|
-
text: ctaSlotKey ? (slotValueToText(ctaSlot) || slotLabel(ctaSlotKey)) : '收尾行动',
|
|
1010
|
-
confidence: ctaSlot?.confidence ?? 0.38,
|
|
1011
|
-
});
|
|
1012
|
-
// Recruitment mode: always scroll back to core start — never scroll to entry/QR area.
|
|
1013
|
-
const ctaAction = isRecruitmentMode
|
|
1014
|
-
? buildCtaAction({ coreRange })
|
|
1015
|
-
: (ctaAnchor && modeInfo.mode !== 'refuse_auto_broadcast'
|
|
1016
|
-
? {
|
|
1017
|
-
type: 'scroll_to_dwell',
|
|
1018
|
-
target_hotspot: ctaAnchor.id,
|
|
1019
|
-
target_y: ctaAnchor.target_y,
|
|
1020
|
-
transition_ms: 900,
|
|
1021
|
-
reason: '收尾聚焦行动提示',
|
|
1022
|
-
}
|
|
1023
|
-
: buildCtaAction({ coreRange }));
|
|
1024
|
-
|
|
1025
|
-
phasePlan.push({
|
|
1026
|
-
phase_id: 'cta',
|
|
1027
|
-
role: 'cta',
|
|
1028
|
-
duration_s: roundDuration(allocation.cta),
|
|
1029
|
-
visual_action: ctaAction,
|
|
1030
|
-
semantic_slot: ctaSlotKey,
|
|
1031
|
-
focus_region: ctaAnchor?.y_range ?? null,
|
|
1032
|
-
confidence: ctaSlot ? Number(clampNumber(ctaSlot.confidence, 0, 1, 0).toFixed(2)) : null,
|
|
1033
|
-
guidance: '介绍完最后一个信息点直接结束,禁止加任何收口语(不说"感兴趣去原文查看""截止见正文"等),禁止提及 URL、网址、二维码或投递入口',
|
|
1034
|
-
});
|
|
1035
|
-
|
|
1036
|
-
const cappedPlan = phasePlan.slice(0, 5);
|
|
1037
|
-
const highlightPhases = cappedPlan.filter(item => item.role === 'highlight');
|
|
1038
|
-
|
|
1039
|
-
return {
|
|
1040
|
-
version: 'video-narration-plan/v1',
|
|
1041
|
-
plan_video_version: 1,
|
|
1042
|
-
target_platform: profile.id,
|
|
1043
|
-
persona: normalizedPersona,
|
|
1044
|
-
core_message: coreMessage,
|
|
1045
|
-
narrative_arc: buildNarrativeArc(highlightPhases),
|
|
1046
|
-
narration_mode: modeInfo.mode,
|
|
1047
|
-
narration_mode_confidence: modeInfo.confidence,
|
|
1048
|
-
narration_mode_source: modeInfo.source,
|
|
1049
|
-
semantic_slots: semanticSlots,
|
|
1050
|
-
semantic_summary: semanticSummary,
|
|
1051
|
-
duration_range_s: profile.duration_range_s,
|
|
1052
|
-
total_duration_s: roundDuration(
|
|
1053
|
-
cappedPlan.reduce((sum, phase) => sum + Number(phase.duration_s || 0), 0)
|
|
1054
|
-
),
|
|
1055
|
-
phase_plan: cappedPlan,
|
|
1056
|
-
constraints: {
|
|
1057
|
-
max_phases: 5,
|
|
1058
|
-
max_highlights: 3,
|
|
1059
|
-
phase_count: cappedPlan.length,
|
|
1060
|
-
highlight_count: highlightPhases.length,
|
|
1061
|
-
},
|
|
1062
|
-
context: {
|
|
1063
|
-
page_type: toSafeString(understanding?.page_type || understanding?.pageType) || null,
|
|
1064
|
-
url: toSafeString(understanding?.url) || null,
|
|
1065
|
-
hotspot_count: hotspots.length,
|
|
1066
|
-
skip_zone_count: skipZones.length,
|
|
1067
|
-
core_y_range: coreRange,
|
|
1068
|
-
has_semantic_slots: hasSemanticSlots,
|
|
1069
|
-
mode_hint: normalizeModeHint(understanding?.mode_hint ?? understanding?.modeHint),
|
|
1070
|
-
mode_hint_confidence: Number(clampNumber(understanding?.mode_hint_confidence ?? understanding?.modeHintConfidence, 0, 1, 0).toFixed(2)),
|
|
1071
|
-
},
|
|
1072
|
-
};
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
function resolveVoiceForPhase({ profile, role, index = 0 }) {
|
|
1076
|
-
const baseSpeed = profile.speed;
|
|
1077
|
-
|
|
1078
|
-
if (role === 'hook') {
|
|
1079
|
-
return {
|
|
1080
|
-
voice_preset: profile.voice_preset,
|
|
1081
|
-
speed: roundSpeed(baseSpeed + 0.03),
|
|
1082
|
-
};
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
if (role === 'cta') {
|
|
1086
|
-
const delta = profile.id === 'douyin' ? -0.02 : -0.04;
|
|
1087
|
-
return {
|
|
1088
|
-
voice_preset: profile.voice_preset,
|
|
1089
|
-
speed: roundSpeed(baseSpeed + delta),
|
|
1090
|
-
};
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
const modulation = index % 2 === 0 ? 0 : -0.01;
|
|
1094
|
-
return {
|
|
1095
|
-
voice_preset: profile.voice_preset,
|
|
1096
|
-
speed: roundSpeed(baseSpeed + modulation),
|
|
1097
|
-
};
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
function estimateDurationMs(text, speed = 1) {
|
|
1102
|
-
const chars = Math.max(1, toCharLength(text));
|
|
1103
|
-
const cps = clampNumber(28 * Number(speed || 1), 12, 48, 28);
|
|
1104
|
-
return Math.max(1500, Math.round((chars / cps) * 1000));
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
function resolveAudioId(payload = {}, fallbackPrefix = 'audio') {
|
|
1108
|
-
const direct = toSafeString(payload.audio_id || payload.audioId);
|
|
1109
|
-
if (direct) return direct;
|
|
1110
|
-
|
|
1111
|
-
const audioPath = toSafeString(payload.audio_path || payload.audioPath);
|
|
1112
|
-
if (audioPath) {
|
|
1113
|
-
const base = path.basename(audioPath, path.extname(audioPath));
|
|
1114
|
-
if (base) return base;
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
const audioUrl = toSafeString(payload.audio_url || payload.audioUrl);
|
|
1118
|
-
if (audioUrl) {
|
|
1119
|
-
try {
|
|
1120
|
-
const parsed = new URL(audioUrl);
|
|
1121
|
-
const base = path.posix.basename(parsed.pathname || '', path.posix.extname(parsed.pathname || ''));
|
|
1122
|
-
if (base) return base;
|
|
1123
|
-
} catch {
|
|
1124
|
-
// ignore URL parse failure
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
return `${fallbackPrefix}-${randomUUID().slice(0, 8)}`;
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
async function callGenerateVoiceover({
|
|
1132
|
-
env,
|
|
1133
|
-
fetchFn,
|
|
1134
|
-
workspaceId,
|
|
1135
|
-
text,
|
|
1136
|
-
voicePreset,
|
|
1137
|
-
speed,
|
|
1138
|
-
format,
|
|
1139
|
-
credentialId,
|
|
1140
|
-
}) {
|
|
1141
|
-
const serverUrl = toSafeString(env?.SERVER_URL);
|
|
1142
|
-
const machineApiKey = toSafeString(env?.MACHINE_API_KEY);
|
|
1143
|
-
const agentId = toSafeString(env?.AGENT_ID);
|
|
1144
|
-
|
|
1145
|
-
if (!serverUrl || !machineApiKey || !agentId) {
|
|
1146
|
-
throw new Error('tts_runtime_env_missing');
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
const targetWorkspaceId = toSafeString(workspaceId || env?.WORKSPACE_ID);
|
|
1150
|
-
if (!targetWorkspaceId) {
|
|
1151
|
-
throw new Error('tts_workspace_id_missing');
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
const payload = {
|
|
1155
|
-
workspace_id: targetWorkspaceId,
|
|
1156
|
-
text,
|
|
1157
|
-
voice_preset: voicePreset,
|
|
1158
|
-
speed,
|
|
1159
|
-
format,
|
|
1160
|
-
};
|
|
1161
|
-
if (credentialId) payload.credential_id = credentialId;
|
|
1162
|
-
|
|
1163
|
-
const url = `${serverUrl.replace(/\/+$/, '')}/internal/agent/${encodeURIComponent(agentId)}/tts/voiceover`;
|
|
1164
|
-
const response = await fetchFn(url, {
|
|
1165
|
-
method: 'POST',
|
|
1166
|
-
headers: {
|
|
1167
|
-
'Content-Type': 'application/json',
|
|
1168
|
-
Authorization: `Bearer ${machineApiKey}`,
|
|
1169
|
-
},
|
|
1170
|
-
body: JSON.stringify(payload),
|
|
1171
|
-
});
|
|
1172
|
-
|
|
1173
|
-
const textBody = await response.text();
|
|
1174
|
-
let data = null;
|
|
1175
|
-
try {
|
|
1176
|
-
data = JSON.parse(textBody);
|
|
1177
|
-
} catch {
|
|
1178
|
-
data = null;
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
if (!response.ok) {
|
|
1182
|
-
const errorText = toSafeString(data?.error || textBody).slice(0, 240);
|
|
1183
|
-
throw new Error(`tts_http_${response.status}:${errorText || 'unknown_error'}`);
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
const duration = toFiniteNumber(data?.duration_ms);
|
|
1187
|
-
if (duration == null || duration <= 0) {
|
|
1188
|
-
throw new Error('tts_duration_missing');
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
return {
|
|
1192
|
-
audio_id: resolveAudioId(data, 'voiceover'),
|
|
1193
|
-
duration_ms: Math.round(duration),
|
|
1194
|
-
audio_url: toSafeString(data?.audio_url) || null,
|
|
1195
|
-
audio_path: toSafeString(data?.audio_path) || null,
|
|
1196
|
-
provider: toSafeString(data?.provider) || null,
|
|
1197
|
-
format: toSafeString(data?.format) || format,
|
|
1198
|
-
voice_preset: toSafeString(data?.voice_preset) || voicePreset,
|
|
1199
|
-
};
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
function normalizePhaseList(strategy = {}) {
|
|
1203
|
-
const phases = Array.isArray(strategy?.phase_plan) ? strategy.phase_plan : [];
|
|
1204
|
-
if (phases.length > 0) return phases.slice(0, 5);
|
|
1205
|
-
|
|
1206
|
-
return [
|
|
1207
|
-
{
|
|
1208
|
-
phase_id: 'hook',
|
|
1209
|
-
role: 'hook',
|
|
1210
|
-
duration_s: 6,
|
|
1211
|
-
visual_action: { type: 'scroll_to_dwell', target_y: 0 },
|
|
1212
|
-
},
|
|
1213
|
-
{
|
|
1214
|
-
phase_id: 'cta',
|
|
1215
|
-
role: 'cta',
|
|
1216
|
-
duration_s: 6,
|
|
1217
|
-
visual_action: { type: 'scroll_back', target_y: 0 },
|
|
1218
|
-
},
|
|
1219
|
-
];
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
function normalizeVisualAction(rawAction = {}, fallbackType = 'scroll_to_dwell') {
|
|
1223
|
-
const typeRaw = toSafeString(rawAction?.type).toLowerCase();
|
|
1224
|
-
const type = VISUAL_ACTION_SET.has(typeRaw) ? typeRaw : fallbackType;
|
|
1225
|
-
return {
|
|
1226
|
-
...rawAction,
|
|
1227
|
-
type,
|
|
1228
|
-
};
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
function shouldTryTts({ env, workspaceId }) {
|
|
1232
|
-
const serverUrl = toSafeString(env?.SERVER_URL);
|
|
1233
|
-
const machineApiKey = toSafeString(env?.MACHINE_API_KEY);
|
|
1234
|
-
const agentId = toSafeString(env?.AGENT_ID);
|
|
1235
|
-
const targetWorkspaceId = toSafeString(workspaceId || env?.WORKSPACE_ID);
|
|
1236
|
-
return Boolean(serverUrl && machineApiKey && agentId && targetWorkspaceId);
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
export async function detailSections({
|
|
1240
|
-
strategy = {},
|
|
1241
|
-
sentences = null,
|
|
1242
|
-
workspace_id = null,
|
|
1243
|
-
credential_id = null,
|
|
1244
|
-
format = 'mp3',
|
|
1245
|
-
strict_tts = false,
|
|
1246
|
-
} = {}, {
|
|
1247
|
-
env = process.env,
|
|
1248
|
-
fetchFn = globalThis.fetch,
|
|
1249
|
-
} = {}) {
|
|
1250
|
-
if (isPlainObject(strategy) && !strategy.plan_video_version) {
|
|
1251
|
-
throw new Error(
|
|
1252
|
-
'pipeline_violation: strategy must come from plan_video output. '
|
|
1253
|
-
+ 'Required pipeline: analyze_page → plan_video → detail_sections → record_url_narration → compose_video → submit_to_library. '
|
|
1254
|
-
+ 'Do not hand-write strategy or bypass plan_video.'
|
|
1255
|
-
);
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
if (!Array.isArray(sentences) || sentences.length === 0) {
|
|
1259
|
-
throw new Error(
|
|
1260
|
-
'sentences are required. Write one narration sentence per phase '
|
|
1261
|
-
+ '(hook, highlight_1…N, cta) based on the semantic_slots and persona from plan_video. '
|
|
1262
|
-
+ 'Use natural peer-voice tone for the target platform — not a news broadcast.'
|
|
1263
|
-
);
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
const sentenceMap = {};
|
|
1267
|
-
for (const item of sentences) {
|
|
1268
|
-
const phaseId = toSafeString(item?.phase_id);
|
|
1269
|
-
const text = toSafeString(item?.text);
|
|
1270
|
-
if (phaseId && text) sentenceMap[phaseId] = text;
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
const profile = getPlatformProfile(strategy?.target_platform);
|
|
1274
|
-
const phases = normalizePhaseList(strategy);
|
|
1275
|
-
|
|
1276
|
-
const sections = [];
|
|
1277
|
-
const ttsErrors = [];
|
|
1278
|
-
let usedLiveTts = false;
|
|
1279
|
-
|
|
1280
|
-
for (let i = 0; i < phases.length; i += 1) {
|
|
1281
|
-
const phase = phases[i];
|
|
1282
|
-
const phaseId = toSafeString(phase.phase_id) || `phase_${i + 1}`;
|
|
1283
|
-
const role = toSafeString(phase?.role).toLowerCase() || (phaseId === 'cta' ? 'cta' : (phaseId === 'hook' ? 'hook' : 'highlight'));
|
|
1284
|
-
|
|
1285
|
-
const sentence = sentenceMap[phaseId];
|
|
1286
|
-
if (!sentence) {
|
|
1287
|
-
throw new Error(
|
|
1288
|
-
`missing sentence for phase "${phaseId}". Provide a text sentence for every phase in the plan.`
|
|
1289
|
-
);
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
const charLen = toCharLength(sentence);
|
|
1293
|
-
if (charLen < SENTENCE_MIN_CHARS) {
|
|
1294
|
-
throw new Error(`sentence for "${phaseId}" too short (${charLen} chars, min ${SENTENCE_MIN_CHARS}).`);
|
|
1295
|
-
}
|
|
1296
|
-
if (charLen > SENTENCE_MAX_CHARS) {
|
|
1297
|
-
throw new Error(`sentence for "${phaseId}" too long (${charLen} chars, max ${SENTENCE_MAX_CHARS}). Shorten to fit one TTS segment.`);
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
const voice = resolveVoiceForPhase({ profile, role, index: i });
|
|
1301
|
-
const phaseBudgetMs = clampInt((toFiniteNumber(phase?.duration_s) ?? 6) * 1000, 1000, 180000, 6000);
|
|
1302
|
-
|
|
1303
|
-
let audio = null;
|
|
1304
|
-
const tryTts = shouldTryTts({ env, workspaceId: workspace_id });
|
|
1305
|
-
|
|
1306
|
-
if (tryTts) {
|
|
1307
|
-
try {
|
|
1308
|
-
audio = await callGenerateVoiceover({
|
|
1309
|
-
env,
|
|
1310
|
-
fetchFn,
|
|
1311
|
-
workspaceId: workspace_id,
|
|
1312
|
-
text: sentence,
|
|
1313
|
-
voicePreset: voice.voice_preset,
|
|
1314
|
-
speed: voice.speed,
|
|
1315
|
-
format: toSafeString(format).toLowerCase() || 'mp3',
|
|
1316
|
-
credentialId: toSafeString(credential_id) || null,
|
|
1317
|
-
});
|
|
1318
|
-
usedLiveTts = true;
|
|
1319
|
-
} catch (error) {
|
|
1320
|
-
const message = toSafeString(error?.message) || 'tts_call_failed';
|
|
1321
|
-
ttsErrors.push({ phase_id: phaseId, error: message });
|
|
1322
|
-
if (strict_tts) {
|
|
1323
|
-
throw new Error(`detail_sections_tts_failed:${phaseId}:${message}`);
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
if (!audio) {
|
|
1329
|
-
const estimatedDuration = estimateDurationMs(sentence, voice.speed);
|
|
1330
|
-
audio = {
|
|
1331
|
-
audio_id: `estimated-${phaseId}`,
|
|
1332
|
-
duration_ms: estimatedDuration,
|
|
1333
|
-
audio_url: null,
|
|
1334
|
-
audio_path: null,
|
|
1335
|
-
provider: 'estimated',
|
|
1336
|
-
format: toSafeString(format).toLowerCase() || 'mp3',
|
|
1337
|
-
voice_preset: voice.voice_preset,
|
|
1338
|
-
};
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
const durationMs = clampInt(audio.duration_ms, 300, 180000, phaseBudgetMs);
|
|
1342
|
-
const dwellMs = Math.max(durationMs, phaseBudgetMs);
|
|
1343
|
-
|
|
1344
|
-
const visualAction = normalizeVisualAction(phase.visual_action, role === 'cta' ? 'scroll_back' : 'scroll_to_dwell');
|
|
1345
|
-
const phaseFocusRegion = normalizeYRange(
|
|
1346
|
-
phase.focus_region
|
|
1347
|
-
?? phase.highlight?.y_range
|
|
1348
|
-
?? phase.semantic_focus_region
|
|
1349
|
-
?? phase.semanticFocusRegion
|
|
1350
|
-
);
|
|
1351
|
-
|
|
1352
|
-
sections.push({
|
|
1353
|
-
phase_id: phaseId,
|
|
1354
|
-
visual_action: visualAction,
|
|
1355
|
-
sentence,
|
|
1356
|
-
voice_preset: voice.voice_preset,
|
|
1357
|
-
speed: voice.speed,
|
|
1358
|
-
audio_id: audio.audio_id,
|
|
1359
|
-
duration_ms: durationMs,
|
|
1360
|
-
dwell_ms: dwellMs,
|
|
1361
|
-
audio_url: audio.audio_url,
|
|
1362
|
-
audio_path: audio.audio_path,
|
|
1363
|
-
semantic_slot: toSafeString(phase.semantic_slot) || null,
|
|
1364
|
-
focus_region: phaseFocusRegion,
|
|
1365
|
-
confidence: toFiniteNumber(phase.confidence) != null
|
|
1366
|
-
? Number(clampNumber(phase.confidence, 0, 1, 0).toFixed(2))
|
|
1367
|
-
: null,
|
|
1368
|
-
camera_motion: describeCameraMotion(visualAction.type),
|
|
1369
|
-
});
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
const totalDurationMs = sections.reduce((sum, item) => sum + item.dwell_ms, 0);
|
|
1373
|
-
|
|
1374
|
-
return {
|
|
1375
|
-
detail_sections_version: 1,
|
|
1376
|
-
sections,
|
|
1377
|
-
outro_video_id: toSafeString(strategy?.outro_video_id || strategy?.outroVideoId) || DEFAULT_OUTRO_VIDEO_ID,
|
|
1378
|
-
total_duration_ms: totalDurationMs,
|
|
1379
|
-
constraints: {
|
|
1380
|
-
sections_count: sections.length,
|
|
1381
|
-
max_sections: 5,
|
|
1382
|
-
dwell_covers_audio: sections.every(item => item.dwell_ms >= item.duration_ms),
|
|
1383
|
-
sentence_char_range: [SENTENCE_MIN_CHARS, SENTENCE_MAX_CHARS],
|
|
1384
|
-
},
|
|
1385
|
-
meta: {
|
|
1386
|
-
target_platform: profile.id,
|
|
1387
|
-
tts_mode: usedLiveTts ? 'live_or_partial_live' : 'estimated',
|
|
1388
|
-
tts_errors: ttsErrors,
|
|
1389
|
-
},
|
|
1390
|
-
};
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
export function createVideoNarrationPlanner(runtime = {}) {
|
|
1394
|
-
const env = runtime.env ?? process.env;
|
|
1395
|
-
const fetchFn = runtime.fetchFn ?? globalThis.fetch;
|
|
1396
|
-
|
|
1397
|
-
return {
|
|
1398
|
-
planVideo: (input = {}) => planVideo(input),
|
|
1399
|
-
detailSections: (input = {}) => detailSections(input, { env, fetchFn }),
|
|
1400
|
-
};
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
export { normalizeTargetPlatform, getPlatformProfile, ensureSentenceLength };
|