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