@chatpanel/events 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +12 -0
- package/media-transcript.js +487 -0
- package/package.json +5 -1
- package/pdf-layout.js +258 -0
package/index.js
CHANGED
|
@@ -34,6 +34,18 @@ export { parseFlowchart, layoutFlowchart, renderFlowchartSvg } from './flowchart
|
|
|
34
34
|
export { validateView, validateViewInvocation, viewResult } from './view.js';
|
|
35
35
|
export { validateWidget, validateWidgetMessage, effectiveGrants, widgetIcon, WIDGET_SURFACES } from './widget.js';
|
|
36
36
|
export { fuseRRF, planQueries, multiSearch } from './rrf.js';
|
|
37
|
+
export {
|
|
38
|
+
PDF_MAX_CHARS, linesFromItems, orderLines, paragraphsFromLines,
|
|
39
|
+
pageTextFromItems, looksScanned, buildPdfDocument,
|
|
40
|
+
} from './pdf-layout.js';
|
|
41
|
+
export {
|
|
42
|
+
YOUTUBE_HOSTS, TRANSCRIPT_MAX_CHARS,
|
|
43
|
+
parseYouTubeUrl, isYouTubeUrl,
|
|
44
|
+
captionTracksFromPlayerResponse, videoMetaFromPlayerResponse,
|
|
45
|
+
pickCaptionTrack, timedTextUrl, parseTimedText,
|
|
46
|
+
groupSegments, formatTimestamp, formatTranscript,
|
|
47
|
+
buildTranscriptDocument, transcriptFromTracks,
|
|
48
|
+
} from './media-transcript.js';
|
|
37
49
|
export {
|
|
38
50
|
ACCESS_LOG_VERSION, ACCESS_LOG_MAX, redactAccessArgs, makeAccessEvent,
|
|
39
51
|
createAccessLog, makeStorageTier, formatBytes,
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
// Video transcripts, as a contract rather than as a scraper.
|
|
2
|
+
//
|
|
3
|
+
// "Summarise this video" is the same question as "summarise this page" — the only
|
|
4
|
+
// difference is where the words live. On a video page the words are NOT in the DOM: the
|
|
5
|
+
// article body a reader would summarise is a caption track, fetched separately, published
|
|
6
|
+
// in four different serialisations, and duplicated once per language plus once more for
|
|
7
|
+
// the machine-generated version.
|
|
8
|
+
//
|
|
9
|
+
// So the CHOOSING and the PARSING live here, not in a client:
|
|
10
|
+
// • which of eleven caption tracks is the one the user meant (manual over ASR, their
|
|
11
|
+
// language over the uploader's, an explicit ask over both);
|
|
12
|
+
// • json3 / srv3 / srv1 XML / WebVTT / SRT → one segment list;
|
|
13
|
+
// • segments → readable paragraphs with timestamps you can cite and click.
|
|
14
|
+
//
|
|
15
|
+
// None of that needs a browser, and every client will need all of it: the extension reads
|
|
16
|
+
// the tab, the bridge may be handed a URL by a CLI agent, the gateway may be asked to
|
|
17
|
+
// summarise one server-side, and a mobile app has no DOM to scrape at all. Written inside
|
|
18
|
+
// the extension it would be copied three times and would disagree three ways about which
|
|
19
|
+
// track is "the" transcript.
|
|
20
|
+
//
|
|
21
|
+
// WHAT IS NOT HERE: fetching. Every platform gates caption URLs on the session that asked
|
|
22
|
+
// (cookies, origin, a consent cookie, a per-load token), so the FETCH has to happen where
|
|
23
|
+
// that session is — in the page for the extension, behind the user's own credentials for a
|
|
24
|
+
// CLI. Callers pass a `fetchText` in; this module never reaches the network, which is also
|
|
25
|
+
// what keeps it testable and dependency-free.
|
|
26
|
+
|
|
27
|
+
/** Hostnames that serve YouTube watch pages. */
|
|
28
|
+
export const YOUTUBE_HOSTS = Object.freeze([
|
|
29
|
+
'youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com',
|
|
30
|
+
'youtube-nocookie.com', 'www.youtube-nocookie.com', 'youtu.be',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const VIDEO_ID_RE = /^[A-Za-z0-9_-]{11}$/;
|
|
34
|
+
|
|
35
|
+
/** `1h2m3s`, `90s`, `90` → seconds. Returns 0 for anything unparseable. */
|
|
36
|
+
function parseTimeParam(raw) {
|
|
37
|
+
const s = String(raw || '').trim();
|
|
38
|
+
if (!s) return 0;
|
|
39
|
+
if (/^\d+$/.test(s)) return Number(s);
|
|
40
|
+
const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(s);
|
|
41
|
+
if (!m || !(m[1] || m[2] || m[3])) return 0;
|
|
42
|
+
return Number(m[1] || 0) * 3600 + Number(m[2] || 0) * 60 + Number(m[3] || 0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Recognise a YouTube video URL in any of the shapes people actually paste.
|
|
47
|
+
*
|
|
48
|
+
* Every surface has its own: /watch?v=, youtu.be/, /shorts/, /embed/, /live/, /v/, and the
|
|
49
|
+
* mobile and music hosts on top. Matching only /watch?v= — the one everybody writes first —
|
|
50
|
+
* silently drops Shorts, which is most of what gets pasted into a chat.
|
|
51
|
+
*
|
|
52
|
+
* @returns {{videoId: string, start: number, url: string} | null}
|
|
53
|
+
*/
|
|
54
|
+
export function parseYouTubeUrl(input) {
|
|
55
|
+
let u;
|
|
56
|
+
try {
|
|
57
|
+
u = new URL(String(input || '').trim());
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|
62
|
+
const host = u.hostname.toLowerCase().replace(/^www\./, '');
|
|
63
|
+
const known = YOUTUBE_HOSTS.some((h) => host === h.replace(/^www\./, ''));
|
|
64
|
+
if (!known) return null;
|
|
65
|
+
|
|
66
|
+
let id = '';
|
|
67
|
+
if (host === 'youtu.be') {
|
|
68
|
+
id = u.pathname.split('/').filter(Boolean)[0] || '';
|
|
69
|
+
} else {
|
|
70
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
71
|
+
if (parts[0] === 'watch') id = u.searchParams.get('v') || '';
|
|
72
|
+
else if (['shorts', 'embed', 'live', 'v'].includes(parts[0])) id = parts[1] || '';
|
|
73
|
+
}
|
|
74
|
+
if (!VIDEO_ID_RE.test(id)) return null;
|
|
75
|
+
const start = parseTimeParam(u.searchParams.get('t') || u.searchParams.get('start'));
|
|
76
|
+
return { videoId: id, start, url: `https://www.youtube.com/watch?v=${id}` };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isYouTubeUrl(input) {
|
|
80
|
+
return parseYouTubeUrl(input) !== null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --------------------------------------------------------------------------
|
|
84
|
+
// Track selection
|
|
85
|
+
// --------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/** Normalise one YouTube captionTracks entry into the shape the rest of this file uses. */
|
|
88
|
+
function normalizeTrack(t) {
|
|
89
|
+
if (!t || typeof t !== 'object') return null;
|
|
90
|
+
const baseUrl = String(t.baseUrl || t.url || '');
|
|
91
|
+
if (!baseUrl) return null;
|
|
92
|
+
const name = t.name?.simpleText
|
|
93
|
+
|| t.name?.runs?.map((r) => r.text).join('')
|
|
94
|
+
|| String(t.label || '');
|
|
95
|
+
return {
|
|
96
|
+
baseUrl,
|
|
97
|
+
lang: String(t.languageCode || t.lang || '').toLowerCase(),
|
|
98
|
+
name: name || '',
|
|
99
|
+
// `asr` is YouTube's marker for the machine-generated track. It is usually the ONLY
|
|
100
|
+
// track on a video, so it must never be filtered out — only ranked below a human one.
|
|
101
|
+
generated: String(t.kind || '') === 'asr' || /auto-generated/i.test(name),
|
|
102
|
+
translatable: t.isTranslatable !== false,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Pull the caption tracks (and the video's own metadata) out of a YouTube player response.
|
|
108
|
+
*
|
|
109
|
+
* The player response is the JSON blob the watch page hands its player. Reading captions
|
|
110
|
+
* from it is what every open-source transcript library does, because it is the only place
|
|
111
|
+
* the *signed* caption URLs exist — they carry an expiring signature, so a URL guessed
|
|
112
|
+
* from the video id is rejected, and one copied from a previous load has expired.
|
|
113
|
+
*/
|
|
114
|
+
export function captionTracksFromPlayerResponse(pr) {
|
|
115
|
+
const list = pr?.captions?.playerCaptionsTracklistRenderer?.captionTracks;
|
|
116
|
+
if (!Array.isArray(list)) return [];
|
|
117
|
+
return list.map(normalizeTrack).filter(Boolean);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function videoMetaFromPlayerResponse(pr) {
|
|
121
|
+
const d = pr?.videoDetails || {};
|
|
122
|
+
const videoId = String(d.videoId || '');
|
|
123
|
+
const seconds = Number(d.lengthSeconds || 0) || 0;
|
|
124
|
+
return {
|
|
125
|
+
videoId,
|
|
126
|
+
title: String(d.title || ''),
|
|
127
|
+
author: String(d.author || ''),
|
|
128
|
+
durationSec: seconds,
|
|
129
|
+
url: videoId ? `https://www.youtube.com/watch?v=${videoId}` : '',
|
|
130
|
+
// The uploader's own description is frequently where the links, chapters and
|
|
131
|
+
// corrections live — a summary that ignores it misses what the video points at.
|
|
132
|
+
description: String(d.shortDescription || ''),
|
|
133
|
+
live: !!d.isLiveContent,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `en-GB` and `en_gb` both mean the `en` family. */
|
|
138
|
+
function langFamily(code) {
|
|
139
|
+
return String(code || '').toLowerCase().replace('_', '-').split('-')[0];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Choose the track to read.
|
|
144
|
+
*
|
|
145
|
+
* Ranked, not filtered: a video with only an auto-generated Hindi track must still return
|
|
146
|
+
* that track for an English-preferring user, because the alternative is telling them there
|
|
147
|
+
* is no transcript when there plainly is one. Preference order:
|
|
148
|
+
*
|
|
149
|
+
* 1. an explicitly requested language (exact code beats family)
|
|
150
|
+
* 2. one of the user's preferred languages
|
|
151
|
+
* 3. a human-written track over the machine one
|
|
152
|
+
* 4. the order YouTube itself listed them (its own default is first)
|
|
153
|
+
*/
|
|
154
|
+
export function pickCaptionTrack(tracks, { language = '', languages = ['en'] } = {}) {
|
|
155
|
+
const list = (Array.isArray(tracks) ? tracks : []).map(normalizeTrack).filter(Boolean);
|
|
156
|
+
if (!list.length) return null;
|
|
157
|
+
const wanted = String(language || '').toLowerCase();
|
|
158
|
+
const prefs = (wanted ? [wanted] : languages || []).map((l) => String(l).toLowerCase());
|
|
159
|
+
const score = (t, i) => {
|
|
160
|
+
let s = 0;
|
|
161
|
+
const exact = prefs.indexOf(t.lang);
|
|
162
|
+
const family = prefs.findIndex((p) => langFamily(p) === langFamily(t.lang));
|
|
163
|
+
if (exact >= 0) s += 1000 - exact * 10;
|
|
164
|
+
else if (family >= 0) s += 800 - family * 10;
|
|
165
|
+
if (!t.generated) s += 100;
|
|
166
|
+
return s - i; // stable: YouTube's own ordering breaks every remaining tie
|
|
167
|
+
};
|
|
168
|
+
return list
|
|
169
|
+
.map((t, i) => ({ t, s: score(t, i) }))
|
|
170
|
+
.sort((a, b) => b.s - a.s)[0].t;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Ask a caption URL for a specific serialisation.
|
|
175
|
+
*
|
|
176
|
+
* json3 is the one to want: it is the only format that reports per-segment durations
|
|
177
|
+
* reliably, and it needs no XML parser — which matters because a DOMParser does not exist
|
|
178
|
+
* in a service worker, in Node, or in a mobile JS runtime.
|
|
179
|
+
*/
|
|
180
|
+
export function timedTextUrl(baseUrl, { fmt = 'json3', language = '' } = {}) {
|
|
181
|
+
let u;
|
|
182
|
+
try {
|
|
183
|
+
u = new URL(String(baseUrl || ''));
|
|
184
|
+
} catch {
|
|
185
|
+
return '';
|
|
186
|
+
}
|
|
187
|
+
if (fmt) u.searchParams.set('fmt', fmt);
|
|
188
|
+
// Ask YouTube to translate only when the track we found is not already the language
|
|
189
|
+
// asked for; `tlang` on a matching track returns a machine round-trip of itself.
|
|
190
|
+
if (language) u.searchParams.set('tlang', language);
|
|
191
|
+
return u.toString();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// --------------------------------------------------------------------------
|
|
195
|
+
// Parsing — four serialisations, one segment list
|
|
196
|
+
// --------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
const XML_ENTITIES = {
|
|
199
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'", nbsp: ' ',
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/** Caption text is double-escaped on the XML endpoints (`&#39;` for an apostrophe). */
|
|
203
|
+
function decodeEntities(s) {
|
|
204
|
+
let out = String(s || '');
|
|
205
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
206
|
+
out = out.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, name) => {
|
|
207
|
+
if (name[0] === '#') {
|
|
208
|
+
const code = name[1] === 'x' || name[1] === 'X'
|
|
209
|
+
? parseInt(name.slice(2), 16)
|
|
210
|
+
: parseInt(name.slice(1), 10);
|
|
211
|
+
return Number.isFinite(code) && code > 0 ? String.fromCodePoint(code) : m;
|
|
212
|
+
}
|
|
213
|
+
const hit = XML_ENTITIES[name.toLowerCase()];
|
|
214
|
+
return hit === undefined ? m : hit;
|
|
215
|
+
});
|
|
216
|
+
if (!/&[a-zA-Z#]/.test(out)) break;
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Only the tags a caption track actually carries. A blanket `<[^>]*>` strip would also
|
|
222
|
+
// eat `<tag>` where the speaker said "angle bracket tag" and the track escaped it.
|
|
223
|
+
const CAPTION_MARKUP_RE = /<\/?(?:i|b|u|s|br|font|v|c|ruby|rt|rp)\b[^>]*>/gi;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Caption text, as text.
|
|
227
|
+
*
|
|
228
|
+
* ORDER MATTERS TWICE. Literal markup is stripped BEFORE decoding, because srv3 wraps every
|
|
229
|
+
* word in an `<s>` span for karaoke timing. Then entities are decoded — twice, since the XML
|
|
230
|
+
* endpoints double-escape (`&#39;` for an apostrophe) — and markup is stripped once more,
|
|
231
|
+
* because `<i>` reaches us escaped rather than literal on some tracks. Decoding first would
|
|
232
|
+
* turn an escaped `<tag>` the speaker actually said into markup and delete it.
|
|
233
|
+
*/
|
|
234
|
+
function cleanText(s) {
|
|
235
|
+
return decodeEntities(String(s || '').replace(CAPTION_MARKUP_RE, ''))
|
|
236
|
+
.replace(CAPTION_MARKUP_RE, '')
|
|
237
|
+
.replace(/\s+/g, ' ')
|
|
238
|
+
.trim();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** `00:01:02.500` / `01:02,500` / `62.5` → milliseconds. */
|
|
242
|
+
function parseClock(raw) {
|
|
243
|
+
const s = String(raw || '').trim().replace(',', '.');
|
|
244
|
+
const parts = s.split(':').map(Number);
|
|
245
|
+
if (parts.some((n) => !Number.isFinite(n))) return NaN;
|
|
246
|
+
let sec = 0;
|
|
247
|
+
for (const p of parts) sec = sec * 60 + p;
|
|
248
|
+
return Math.round(sec * 1000);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function parseJson3(body) {
|
|
252
|
+
let doc;
|
|
253
|
+
try {
|
|
254
|
+
doc = typeof body === 'string' ? JSON.parse(body) : body;
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
if (!Array.isArray(doc?.events)) return null;
|
|
259
|
+
const out = [];
|
|
260
|
+
for (const ev of doc.events) {
|
|
261
|
+
const text = cleanText((ev?.segs || []).map((s) => s?.utf8 || '').join(''));
|
|
262
|
+
if (!text) continue; // json3 emits empty timing-only events between cues
|
|
263
|
+
out.push({
|
|
264
|
+
start: Number(ev.tStartMs) || 0,
|
|
265
|
+
dur: Number(ev.dDurationMs) || 0,
|
|
266
|
+
text,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function parseTimedTextXml(body) {
|
|
273
|
+
const src = String(body || '');
|
|
274
|
+
if (!/<(transcript|timedtext|text|p)\b/i.test(src)) return null;
|
|
275
|
+
const out = [];
|
|
276
|
+
// srv1 uses <text start dur>, srv3 uses <p t d>. One regex over both beats requiring a
|
|
277
|
+
// DOMParser that half our runtimes do not have.
|
|
278
|
+
const re = /<(?:text|p)\b([^>]*)>([\s\S]*?)<\/(?:text|p)>/gi;
|
|
279
|
+
let m;
|
|
280
|
+
while ((m = re.exec(src))) {
|
|
281
|
+
const attrs = m[1];
|
|
282
|
+
const num = (name) => {
|
|
283
|
+
const a = new RegExp(`${name}="([^"]*)"`, 'i').exec(attrs);
|
|
284
|
+
return a ? Number(a[1]) : NaN;
|
|
285
|
+
};
|
|
286
|
+
const startSec = num('start');
|
|
287
|
+
const startMs = Number.isFinite(startSec) ? startSec * 1000 : num('t');
|
|
288
|
+
const durSec = num('dur');
|
|
289
|
+
const durMs = Number.isFinite(durSec) ? durSec * 1000 : num('d');
|
|
290
|
+
const text = cleanText(m[2]);
|
|
291
|
+
if (!text) continue;
|
|
292
|
+
out.push({
|
|
293
|
+
start: Math.round(Number.isFinite(startMs) ? startMs : 0),
|
|
294
|
+
dur: Math.round(Number.isFinite(durMs) ? durMs : 0),
|
|
295
|
+
text,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return out.length ? out : null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function parseCueList(body, sepRe) {
|
|
302
|
+
const blocks = String(body || '').replace(/\r\n?/g, '\n').split(/\n{2,}/);
|
|
303
|
+
const out = [];
|
|
304
|
+
for (const block of blocks) {
|
|
305
|
+
const lines = block.split('\n').filter((l) => l.trim());
|
|
306
|
+
const timeIdx = lines.findIndex((l) => sepRe.test(l));
|
|
307
|
+
if (timeIdx < 0) continue;
|
|
308
|
+
const [rawStart, rawEnd] = lines[timeIdx].split(sepRe);
|
|
309
|
+
const start = parseClock(rawStart);
|
|
310
|
+
const end = parseClock(String(rawEnd || '').split(/\s+/)[0]);
|
|
311
|
+
const text = cleanText(lines.slice(timeIdx + 1).join(' '));
|
|
312
|
+
if (!text || !Number.isFinite(start)) continue;
|
|
313
|
+
out.push({ start, dur: Number.isFinite(end) ? Math.max(0, end - start) : 0, text });
|
|
314
|
+
}
|
|
315
|
+
return out.length ? out : null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const parseVtt = (body) => parseCueList(body, /\s*-->\s*/);
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Any caption serialisation → `[{ start, dur, text }]` (milliseconds), sniffed by content.
|
|
322
|
+
*
|
|
323
|
+
* Sniffing rather than trusting a declared format: the same `fmt=json3` URL answers with
|
|
324
|
+
* XML when the parameter is dropped by a proxy or the track predates json3, and a parser
|
|
325
|
+
* chosen from the request instead of the response fails on exactly those.
|
|
326
|
+
*/
|
|
327
|
+
export function parseTimedText(body) {
|
|
328
|
+
const src = typeof body === 'string' ? body : JSON.stringify(body ?? '');
|
|
329
|
+
if (!src.trim()) return [];
|
|
330
|
+
return parseJson3(src) || parseTimedTextXml(src) || parseVtt(src) || [];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// --------------------------------------------------------------------------
|
|
334
|
+
// Formatting
|
|
335
|
+
// --------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
/** ms → `m:ss`, or `h:mm:ss` once it earns the hour. */
|
|
338
|
+
export function formatTimestamp(ms) {
|
|
339
|
+
const total = Math.max(0, Math.round(Number(ms) || 0) / 1000);
|
|
340
|
+
const h = Math.floor(total / 3600);
|
|
341
|
+
const m = Math.floor((total % 3600) / 60);
|
|
342
|
+
const s = Math.floor(total % 60);
|
|
343
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
344
|
+
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Caption cues → paragraphs.
|
|
349
|
+
*
|
|
350
|
+
* Cues are 2-6 words long and arrive several per second; handed over raw, a 40-minute talk
|
|
351
|
+
* is 8,000 lines of fragments in which nothing is a sentence. The model then spends its
|
|
352
|
+
* attention on reassembly rather than on the content, and every quote it produces is a
|
|
353
|
+
* fragment. Grouping by a time budget (and by a real pause) restores paragraphs and cuts
|
|
354
|
+
* the token count roughly in half by removing the line breaks alone.
|
|
355
|
+
*/
|
|
356
|
+
export function groupSegments(segments, { windowMs = 30000, gapMs = 2500 } = {}) {
|
|
357
|
+
const list = (Array.isArray(segments) ? segments : []).filter((s) => s && s.text);
|
|
358
|
+
const out = [];
|
|
359
|
+
let cur = null;
|
|
360
|
+
let prevEnd = 0;
|
|
361
|
+
for (const seg of list) {
|
|
362
|
+
const start = Number(seg.start) || 0;
|
|
363
|
+
const gap = start - prevEnd;
|
|
364
|
+
const tooLong = cur && start - cur.start >= windowMs;
|
|
365
|
+
const paused = cur && prevEnd > 0 && gap >= gapMs;
|
|
366
|
+
if (!cur || tooLong || paused) {
|
|
367
|
+
cur = { start, text: seg.text };
|
|
368
|
+
out.push(cur);
|
|
369
|
+
} else {
|
|
370
|
+
cur.text += ' ' + seg.text;
|
|
371
|
+
}
|
|
372
|
+
prevEnd = start + (Number(seg.dur) || 0);
|
|
373
|
+
}
|
|
374
|
+
// Auto-generated tracks repeat the tail of each cue as the head of the next (a rolling
|
|
375
|
+
// two-line caption). Left in, roughly a third of the transcript is duplicated text.
|
|
376
|
+
for (const p of out) p.text = dedupeOverlap(p.text);
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Collapse an immediately-repeated run of words: `a b c b c d` → `a b c d`.
|
|
382
|
+
*
|
|
383
|
+
* The rolling two-line caption an auto-generated track emits means each cue re-states the
|
|
384
|
+
* tail of the one before it, so a naive join duplicates roughly a third of the transcript.
|
|
385
|
+
* Longest run first, so `b c b c` collapses as one four-word repeat rather than twice.
|
|
386
|
+
*/
|
|
387
|
+
function dedupeOverlap(text) {
|
|
388
|
+
const words = String(text || '').split(' ');
|
|
389
|
+
for (let k = Math.min(12, words.length >> 1); k >= 3; k--) {
|
|
390
|
+
for (let i = 0; i + 2 * k <= words.length; i++) {
|
|
391
|
+
let same = true;
|
|
392
|
+
for (let j = 0; j < k && same; j++) same = words[i + j] === words[i + k + j];
|
|
393
|
+
if (same) { words.splice(i + k, k); i--; }
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return words.join(' ');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export const TRANSCRIPT_MAX_CHARS = 120_000;
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Segments → the text a model reads.
|
|
403
|
+
*
|
|
404
|
+
* Timestamps are kept by default and are not decoration: they are what lets an answer say
|
|
405
|
+
* "at 12:04 they say…", and what lets the panel turn that into a link that seeks the video.
|
|
406
|
+
*/
|
|
407
|
+
export function formatTranscript(segments, {
|
|
408
|
+
timestamps = true, windowMs = 30000, gapMs = 2500, maxChars = TRANSCRIPT_MAX_CHARS,
|
|
409
|
+
} = {}) {
|
|
410
|
+
const groups = groupSegments(segments, { windowMs, gapMs });
|
|
411
|
+
const lines = groups.map((g) => (timestamps ? `[${formatTimestamp(g.start)}] ${g.text}` : g.text));
|
|
412
|
+
const text = lines.join('\n\n');
|
|
413
|
+
if (text.length <= maxChars) return text;
|
|
414
|
+
return `${text.slice(0, maxChars)}\n\n…[transcript truncated at ${maxChars} characters]`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The whole thing a caller attaches or hands a model: metadata header + transcript body.
|
|
419
|
+
*
|
|
420
|
+
* The header exists because a transcript alone is anonymous. "Summarise this" over bare
|
|
421
|
+
* caption text produces a summary that cannot say what it summarised, and a model that
|
|
422
|
+
* cannot see the duration guesses at the shape of what it is reading.
|
|
423
|
+
*/
|
|
424
|
+
export function buildTranscriptDocument({
|
|
425
|
+
meta = {}, segments = [], language = '', generated = false, source = '', ...opts
|
|
426
|
+
} = {}) {
|
|
427
|
+
const head = [];
|
|
428
|
+
if (meta.title) head.push(`# ${meta.title}`);
|
|
429
|
+
const facts = [];
|
|
430
|
+
if (meta.author) facts.push(`Channel: ${meta.author}`);
|
|
431
|
+
if (meta.durationSec) facts.push(`Duration: ${formatTimestamp(meta.durationSec * 1000)}`);
|
|
432
|
+
if (language) facts.push(`Captions: ${language}${generated ? ' (auto-generated)' : ''}`);
|
|
433
|
+
if (meta.url) facts.push(`URL: ${meta.url}`);
|
|
434
|
+
if (facts.length) head.push(facts.join(' · '));
|
|
435
|
+
const desc = String(meta.description || '').trim();
|
|
436
|
+
if (desc) head.push(`## Description\n${desc.slice(0, 2000)}`);
|
|
437
|
+
head.push('## Transcript');
|
|
438
|
+
const body = formatTranscript(segments, opts);
|
|
439
|
+
const text = `${head.join('\n\n')}\n\n${body}`;
|
|
440
|
+
return {
|
|
441
|
+
title: meta.title || 'Video transcript',
|
|
442
|
+
url: meta.url || '',
|
|
443
|
+
language,
|
|
444
|
+
generated,
|
|
445
|
+
source,
|
|
446
|
+
segments: segments.length,
|
|
447
|
+
durationSec: meta.durationSec || 0,
|
|
448
|
+
text,
|
|
449
|
+
chars: text.length,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* The one orchestration worth sharing: tracks → chosen track → fetched body → document.
|
|
455
|
+
*
|
|
456
|
+
* `fetchText(url)` is injected, because WHERE the fetch runs is the entire reliability
|
|
457
|
+
* story and it differs per client (see the header). Everything either side of it is
|
|
458
|
+
* identical everywhere, so it lives here and gets tested with a fake fetch.
|
|
459
|
+
*/
|
|
460
|
+
export async function transcriptFromTracks({
|
|
461
|
+
tracks, meta = {}, fetchText, language = '', languages = ['en'], source = '', ...opts
|
|
462
|
+
} = {}) {
|
|
463
|
+
const track = pickCaptionTrack(tracks, { language, languages });
|
|
464
|
+
if (!track) return null;
|
|
465
|
+
// Only ask for a translation when the track genuinely is not the language wanted.
|
|
466
|
+
const wantTranslation = !!language && langFamily(track.lang) !== langFamily(language);
|
|
467
|
+
const attempts = [
|
|
468
|
+
timedTextUrl(track.baseUrl, { fmt: 'json3', language: wantTranslation ? language : '' }),
|
|
469
|
+
timedTextUrl(track.baseUrl, { fmt: 'srv1', language: wantTranslation ? language : '' }),
|
|
470
|
+
track.baseUrl,
|
|
471
|
+
].filter(Boolean);
|
|
472
|
+
for (const url of attempts) {
|
|
473
|
+
let body;
|
|
474
|
+
try {
|
|
475
|
+
body = await fetchText(url);
|
|
476
|
+
} catch {
|
|
477
|
+
continue; // a format the endpoint refuses is a reason to try the next, not to fail
|
|
478
|
+
}
|
|
479
|
+
const segments = parseTimedText(body);
|
|
480
|
+
if (segments.length) {
|
|
481
|
+
return buildTranscriptDocument({
|
|
482
|
+
meta, segments, language: track.lang, generated: track.generated, source, ...opts,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return null;
|
|
487
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -16,10 +16,12 @@
|
|
|
16
16
|
"./loop.js": "./loop.js",
|
|
17
17
|
"./manifest.js": "./manifest.js",
|
|
18
18
|
"./markdown-authoring.js": "./markdown-authoring.js",
|
|
19
|
+
"./media-transcript.js": "./media-transcript.js",
|
|
19
20
|
"./mcp-errors.js": "./mcp-errors.js",
|
|
20
21
|
"./meeting-analyzers.js": "./meeting-analyzers.js",
|
|
21
22
|
"./memory.js": "./memory.js",
|
|
22
23
|
"./order.js": "./order.js",
|
|
24
|
+
"./pdf-layout.js": "./pdf-layout.js",
|
|
23
25
|
"./queue.js": "./queue.js",
|
|
24
26
|
"./reach.js": "./reach.js",
|
|
25
27
|
"./ref.js": "./ref.js",
|
|
@@ -71,10 +73,12 @@
|
|
|
71
73
|
"manifest.js",
|
|
72
74
|
"markdown-authoring.js",
|
|
73
75
|
"mcp-errors.js",
|
|
76
|
+
"media-transcript.js",
|
|
74
77
|
"meeting-analyzers.js",
|
|
75
78
|
"memory.js",
|
|
76
79
|
"observability.js",
|
|
77
80
|
"order.js",
|
|
81
|
+
"pdf-layout.js",
|
|
78
82
|
"queue.js",
|
|
79
83
|
"reach.js",
|
|
80
84
|
"ref.js",
|
package/pdf-layout.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// A PDF's text items → something a person (or a model) can read.
|
|
2
|
+
//
|
|
3
|
+
// A PDF does not contain paragraphs. It contains positioned glyph runs: "Introduction" at
|
|
4
|
+
// (72, 690), "and this is" at (72, 674), "why" at (140, 674). Every PDF engine — pdf.js in
|
|
5
|
+
// a browser, a native renderer on mobile, a CLI extractor on the bridge — hands back that
|
|
6
|
+
// same list of runs with the same transform matrix, and every one of them leaves the job of
|
|
7
|
+
// turning runs into READING ORDER to the caller. Done naively (join the items with spaces)
|
|
8
|
+
// a two-column paper becomes an interleaved ransom note, and every hyphenated line break
|
|
9
|
+
// becomes a broken word the model then cannot match against a query.
|
|
10
|
+
//
|
|
11
|
+
// So the reconstruction lives here — pure, testable, engine-agnostic — and the client
|
|
12
|
+
// contributes only the engine that produced the items.
|
|
13
|
+
//
|
|
14
|
+
// THE ITEM SHAPE (pdf.js `getTextContent().items`, and what other engines are adapted to):
|
|
15
|
+
// { str, transform: [a, b, c, d, x, y], width, height, hasEOL }
|
|
16
|
+
// x/y are in PDF user space: y grows UPWARD from the bottom of the page.
|
|
17
|
+
|
|
18
|
+
/** One text item → the numbers this file reasons about. */
|
|
19
|
+
function place(item) {
|
|
20
|
+
const t = Array.isArray(item?.transform) ? item.transform : [1, 0, 0, 1, 0, 0];
|
|
21
|
+
return {
|
|
22
|
+
text: String(item?.str ?? ''),
|
|
23
|
+
x: Number(t[4]) || 0,
|
|
24
|
+
y: Number(t[5]) || 0,
|
|
25
|
+
width: Number(item?.width) || 0,
|
|
26
|
+
// The transform's vertical scale is the font size; `height` is often 0 on the items
|
|
27
|
+
// real documents produce, and a zero line height makes every line one paragraph.
|
|
28
|
+
size: Math.abs(Number(t[3])) || Number(item?.height) || 0,
|
|
29
|
+
eol: !!item?.hasEOL,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Items → lines, by shared baseline.
|
|
35
|
+
*
|
|
36
|
+
* Tolerance is a FRACTION OF THE FONT SIZE, not a fixed number of points: a footnote at 7pt
|
|
37
|
+
* and a heading at 24pt do not agree on what "the same line" means, and a constant that
|
|
38
|
+
* works for body text either splits headings or merges footnotes.
|
|
39
|
+
*/
|
|
40
|
+
export function linesFromItems(items, { tolerance = 0.5 } = {}) {
|
|
41
|
+
const placed = (Array.isArray(items) ? items : []).map(place).filter((p) => p.text !== '');
|
|
42
|
+
if (!placed.length) return [];
|
|
43
|
+
const lines = [];
|
|
44
|
+
for (const p of placed) {
|
|
45
|
+
const tol = Math.max(1, (p.size || 10) * tolerance);
|
|
46
|
+
// Compare against the most recent line only: items arrive in content-stream order, so a
|
|
47
|
+
// matching baseline further back belongs to an earlier column, not to this line.
|
|
48
|
+
const last = lines[lines.length - 1];
|
|
49
|
+
if (last && Math.abs(last.y - p.y) <= tol) {
|
|
50
|
+
last.items.push(p);
|
|
51
|
+
if (p.size > last.size) last.size = p.size;
|
|
52
|
+
} else {
|
|
53
|
+
lines.push({ y: p.y, size: p.size || 10, items: [p] });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return lines.map((line) => {
|
|
57
|
+
const sorted = line.items.slice().sort((a, b) => a.x - b.x);
|
|
58
|
+
return {
|
|
59
|
+
y: line.y,
|
|
60
|
+
size: line.size,
|
|
61
|
+
x: sorted[0].x,
|
|
62
|
+
right: Math.max(...sorted.map((i) => i.x + i.width)),
|
|
63
|
+
text: joinRun(sorted, line.size),
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Glyph runs on one line → a string.
|
|
70
|
+
*
|
|
71
|
+
* PDFs encode a space either as a space character or as a horizontal gap with nothing in
|
|
72
|
+
* it, and which one you get depends on the producer. Joining on the character alone loses
|
|
73
|
+
* every word break in documents from the second kind; joining every item with a space puts
|
|
74
|
+
* one inside every kerned pair in documents from the first.
|
|
75
|
+
*/
|
|
76
|
+
function joinRun(sorted, size) {
|
|
77
|
+
let out = '';
|
|
78
|
+
let prevRight = null;
|
|
79
|
+
for (const item of sorted) {
|
|
80
|
+
if (prevRight !== null) {
|
|
81
|
+
const gap = item.x - prevRight;
|
|
82
|
+
const needsSpace = gap > (size || 10) * 0.2;
|
|
83
|
+
if (needsSpace && !/\s$/.test(out) && !/^\s/.test(item.text)) out += ' ';
|
|
84
|
+
}
|
|
85
|
+
out += item.text;
|
|
86
|
+
prevRight = item.x + item.width;
|
|
87
|
+
}
|
|
88
|
+
return out.replace(/\s+/g, ' ').trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Lines → columns, in reading order.
|
|
93
|
+
*
|
|
94
|
+
* Two-column layouts are the case that makes naive extraction useless, and they are most of
|
|
95
|
+
* academic and technical PDF reading. A column is detected as a vertical band that lines
|
|
96
|
+
* cluster into; if the page is one column (or the split is not clean) this returns the lines
|
|
97
|
+
* unchanged rather than inventing a split, because a wrong split is worse than none.
|
|
98
|
+
*/
|
|
99
|
+
export function orderLines(lines, { pageWidth = 0 } = {}) {
|
|
100
|
+
const list = (Array.isArray(lines) ? lines : []).slice();
|
|
101
|
+
if (list.length < 6) return list.sort((a, b) => b.y - a.y);
|
|
102
|
+
const width = pageWidth || Math.max(...list.map((l) => l.right));
|
|
103
|
+
const mid = width / 2;
|
|
104
|
+
const left = list.filter((l) => l.right <= mid * 1.05);
|
|
105
|
+
const right = list.filter((l) => l.x >= mid * 0.95);
|
|
106
|
+
const spanning = list.filter((l) => !left.includes(l) && !right.includes(l));
|
|
107
|
+
// A real two-column page has substantial text on BOTH sides and few lines crossing the
|
|
108
|
+
// gutter. Anything else is a single column with a stray indent or a wide table.
|
|
109
|
+
const twoColumn = left.length >= 3 && right.length >= 3
|
|
110
|
+
&& spanning.length <= list.length * 0.2;
|
|
111
|
+
if (!twoColumn) return list.sort((a, b) => b.y - a.y);
|
|
112
|
+
const byY = (a, b) => b.y - a.y;
|
|
113
|
+
// Headers and footers that span the gutter keep their vertical position relative to the
|
|
114
|
+
// column they sit above; sorting them into the left column first is the conventional and
|
|
115
|
+
// least-surprising reading order.
|
|
116
|
+
return [...spanning.filter((l) => l.y > Math.max(...left.map((x) => x.y), 0)).sort(byY),
|
|
117
|
+
...left.sort(byY), ...right.sort(byY),
|
|
118
|
+
...spanning.filter((l) => l.y <= Math.max(...left.map((x) => x.y), 0)).sort(byY)];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Lines → paragraphs.
|
|
123
|
+
*
|
|
124
|
+
* A new paragraph is a bigger-than-usual vertical gap, a first-line indent, or a line that
|
|
125
|
+
* ended well short of the right margin. A word broken across lines with a hyphen is put
|
|
126
|
+
* back together — otherwise "distri-\nbuted" never matches a search for "distributed", and
|
|
127
|
+
* the model reads a word that does not exist.
|
|
128
|
+
*
|
|
129
|
+
* MARGINS ARE MEASURED PER BLOCK, NOT PER PAGE. On a two-column page every line in the left
|
|
130
|
+
* column ends far short of the page's right edge, so a page-wide margin makes every single
|
|
131
|
+
* line "short" and therefore its own paragraph — which is what a first attempt at this did.
|
|
132
|
+
* Lines that share a left edge are one block; a change of block is itself a paragraph break,
|
|
133
|
+
* because it is a change of column or of indentation level.
|
|
134
|
+
*/
|
|
135
|
+
export function paragraphsFromLines(lines) {
|
|
136
|
+
const list = (Array.isArray(lines) ? lines : []).filter((l) => l && l.text);
|
|
137
|
+
if (!list.length) return [];
|
|
138
|
+
|
|
139
|
+
// A block is a run of lines that share a left edge AND a font size. The size half is not
|
|
140
|
+
// cosmetic: a 14pt heading is wider than the 10pt lines under it, so a block containing
|
|
141
|
+
// both takes its right margin from the heading — and then every body line is "short" and
|
|
142
|
+
// every one of them becomes its own paragraph.
|
|
143
|
+
const blocks = [];
|
|
144
|
+
for (const line of list) {
|
|
145
|
+
const cur = blocks[blocks.length - 1];
|
|
146
|
+
const prev = cur?.lines[cur.lines.length - 1];
|
|
147
|
+
const sameEdge = cur && Math.abs(cur.lines[0].x - line.x) <= (line.size || 10) * 1.5;
|
|
148
|
+
const sameSize = prev && Math.abs((prev.size || 10) - (line.size || 10)) <= (prev.size || 10) * 0.15;
|
|
149
|
+
if (sameEdge && sameSize) cur.lines.push(line);
|
|
150
|
+
else blocks.push({ lines: [line] });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const paras = [];
|
|
154
|
+
for (const block of blocks) {
|
|
155
|
+
const rows = block.lines;
|
|
156
|
+
const gaps = [];
|
|
157
|
+
for (let i = 1; i < rows.length; i++) gaps.push(Math.abs(rows[i - 1].y - rows[i].y));
|
|
158
|
+
// A LOW percentile, not the median: in a three-line block whose second gap IS the
|
|
159
|
+
// paragraph break, the median sits between the line gap and the break and neither is
|
|
160
|
+
// then unusual. The common gap is a line gap, and it lives at the bottom of the range.
|
|
161
|
+
const typical = percentile(gaps, 0.3) || (rows[0].size || 10) * 1.2;
|
|
162
|
+
const rightEdge = Math.max(...rows.map((l) => l.right));
|
|
163
|
+
const leftEdge = Math.min(...rows.map((l) => l.x));
|
|
164
|
+
|
|
165
|
+
let cur = '';
|
|
166
|
+
for (let i = 0; i < rows.length; i++) {
|
|
167
|
+
const line = rows[i];
|
|
168
|
+
const prev = rows[i - 1];
|
|
169
|
+
const gap = prev ? Math.abs(prev.y - line.y) : 0;
|
|
170
|
+
const indented = line.x > leftEdge + (line.size || 10) * 0.8;
|
|
171
|
+
const prevShort = prev ? prev.right < rightEdge - (line.size || 10) * 3 : false;
|
|
172
|
+
if (cur && (gap > typical * 1.5 || indented || prevShort)) {
|
|
173
|
+
paras.push(cur);
|
|
174
|
+
cur = line.text;
|
|
175
|
+
} else if (!cur) {
|
|
176
|
+
cur = line.text;
|
|
177
|
+
} else if (/(\w)-$/.test(cur)) {
|
|
178
|
+
cur = cur.replace(/-$/, '') + line.text.replace(/^\s+/, '');
|
|
179
|
+
} else {
|
|
180
|
+
cur += ' ' + line.text;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (cur) paras.push(cur);
|
|
184
|
+
}
|
|
185
|
+
return paras.map((p) => p.replace(/\s+/g, ' ').trim()).filter(Boolean);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Nearest-rank percentile of the positive values, 0 when there are none. */
|
|
189
|
+
function percentile(nums, q) {
|
|
190
|
+
const list = nums.filter((n) => Number.isFinite(n) && n > 0).sort((a, b) => a - b);
|
|
191
|
+
if (!list.length) return 0;
|
|
192
|
+
return list[Math.min(list.length - 1, Math.floor(q * (list.length - 1)))];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** One page's items → its text. */
|
|
196
|
+
export function pageTextFromItems(items, { pageWidth = 0, ...opts } = {}) {
|
|
197
|
+
const lines = linesFromItems(items, opts);
|
|
198
|
+
return paragraphsFromLines(orderLines(lines, { pageWidth })).join('\n\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const PDF_MAX_CHARS = 200_000;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Is this PDF a picture of a document rather than a document?
|
|
205
|
+
*
|
|
206
|
+
* A scanned page has a text layer of nothing, and every extractor answers with an empty
|
|
207
|
+
* string. Returning that silently is the worst outcome: the user sees a summary of nothing
|
|
208
|
+
* and no explanation. Detecting it lets the caller say "this needs OCR" — which is an
|
|
209
|
+
* answer, where an empty attachment is a mystery.
|
|
210
|
+
*/
|
|
211
|
+
export function looksScanned(pages) {
|
|
212
|
+
const list = Array.isArray(pages) ? pages : [];
|
|
213
|
+
if (!list.length) return false;
|
|
214
|
+
const chars = list.reduce((n, p) => n + String(p?.text || '').length, 0);
|
|
215
|
+
return chars < list.length * 40;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Pages → the document a caller attaches.
|
|
220
|
+
*
|
|
221
|
+
* Page markers are kept because a citation into a 90-page PDF is worth nothing without one,
|
|
222
|
+
* and because they are the only thing that tells a model the document has an order at all.
|
|
223
|
+
*/
|
|
224
|
+
export function buildPdfDocument({ meta = {}, pages = [], maxChars = PDF_MAX_CHARS } = {}) {
|
|
225
|
+
const list = Array.isArray(pages) ? pages : [];
|
|
226
|
+
const head = [];
|
|
227
|
+
if (meta.title) head.push(`# ${meta.title}`);
|
|
228
|
+
const facts = [];
|
|
229
|
+
if (meta.author) facts.push(`Author: ${meta.author}`);
|
|
230
|
+
if (list.length) facts.push(`Pages: ${meta.pageCount || list.length}`);
|
|
231
|
+
if (meta.url) facts.push(`URL: ${meta.url}`);
|
|
232
|
+
if (facts.length) head.push(facts.join(' · '));
|
|
233
|
+
|
|
234
|
+
const body = list
|
|
235
|
+
.map((p) => {
|
|
236
|
+
const text = String(p?.text || '').trim();
|
|
237
|
+
return text ? `[page ${p.page}]\n${text}` : '';
|
|
238
|
+
})
|
|
239
|
+
.filter(Boolean)
|
|
240
|
+
.join('\n\n');
|
|
241
|
+
|
|
242
|
+
let text = head.length ? `${head.join('\n\n')}\n\n${body}` : body;
|
|
243
|
+
let truncated = false;
|
|
244
|
+
if (text.length > maxChars) {
|
|
245
|
+
text = `${text.slice(0, maxChars)}\n\n…[PDF truncated at ${maxChars} characters]`;
|
|
246
|
+
truncated = true;
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
title: meta.title || 'PDF',
|
|
250
|
+
url: meta.url || '',
|
|
251
|
+
pageCount: meta.pageCount || list.length,
|
|
252
|
+
pagesRead: list.length,
|
|
253
|
+
scanned: looksScanned(list),
|
|
254
|
+
truncated,
|
|
255
|
+
text,
|
|
256
|
+
chars: text.length,
|
|
257
|
+
};
|
|
258
|
+
}
|