examples/transcript-files.mjs
Rama master · réplica de lectura del repositorio original.
#!/usr/bin/env node
// Example: transcript files from other notetakers read to turns, and the plan of an archive's entries.
//
// Docs: docs/features/LIBRARY_INDEX.md, "Transcript files from other notetakers" and "An archive's plan".
//
// This script demonstrates, all from the browser entry @framers/agentos/cognition/library/browser,
// which a page imports and Node runs too:
// - transcriptFormatOf naming a file's format from its name and its bytes
// - readTranscript reading one call written as SubRip, WebVTT, Otter's text, Fireflies's JSON and
// plain lines into the same turns, with the speakers' names kept apart from the turns
// - the turns cut into passages by chunkTurns, searched with LexicalIndex, and a hit's speaker
// found again from the labels the page kept
// - planEntries saying what each entry of an archive becomes before anything is read, and sha256Hex
// finding two copies of one text once they are read
//
// With no file named, it reads the five layouts written below. Name files to read those instead; a Word
// file is read through mammoth, a dependency of the package:
// node examples/transcript-files.mjs call.vtt call_otter_ai.txt notes.docx
//
// Usage (no API key, no network):
// pnpm install && pnpm run build
// node examples/transcript-files.mjs
import { readFile } from 'node:fs/promises';
import { basename } from 'node:path';
import {
LexicalIndex,
chunkTurns,
lexicalTokens,
planEntries,
readTranscript,
readWordTranscript,
sha256Hex,
snippetAround,
transcriptFormatOf,
} from '@framers/agentos/cognition/library/browser';
/** One call of four turns, as five notetakers write it. */
const SAMPLES = {
'Planning call.srt': [
'1',
'00:00:01,000 --> 00:00:04,200',
'Ana Silva: Good morning. Shall we start with the budget?',
'',
'2',
'00:00:04,500 --> 00:00:09,000',
'Leo Park: Travel is ten percent over plan.',
'',
'3',
'00:00:09,400 --> 00:00:13,800',
'Ana Silva: Can we move the offsite to May?',
'',
'4',
'00:00:14,000 --> 00:00:19,500',
'Leo Park: May works, and the venue holds',
'forty people.',
'',
].join('\n'),
'Planning call.vtt': [
'WEBVTT',
'',
'00:00:01.000 --> 00:00:04.200',
'<v Ana Silva>Good morning. Shall we start with the budget?</v>',
'',
'00:00:04.500 --> 00:00:09.000',
'<v Leo Park>Travel is ten percent over plan.</v>',
'',
'00:00:09.400 --> 00:00:13.800',
'<v Ana Silva>Can we move the <i>offsite</i> to May?</v>',
'',
'00:00:14.000 --> 00:00:19.500',
'<v Leo Park>May works, and the venue holds forty people.</v>',
'',
].join('\n'),
'Planning call_otter_ai.txt': [
'Ana Silva 0:01',
'Good morning. Shall we start with the budget?',
'',
'Leo Park 0:04',
'Travel is ten percent over plan.',
'',
'Ana Silva 0:09',
'Can we move the offsite to May?',
'',
'Leo Park 0:14',
'May works, and the venue holds forty people.',
'',
'Transcribed by https://otter.ai',
'',
].join('\n'),
'Planning call.json': JSON.stringify({
sentences: [
{ index: 0, speaker_name: 'Ana Silva', text: 'Good morning. Shall we start with the budget?', start_time: 1.0, end_time: 4.2 },
{ index: 1, speaker_name: 'Leo Park', text: 'Travel is ten percent over plan.', start_time: 4.5, end_time: 9.0 },
{ index: 2, speaker_name: 'Ana Silva', text: 'Can we move the offsite to May?', start_time: 9.4, end_time: 13.8 },
{ index: 3, speaker_name: 'Leo Park', text: 'May works, and the venue holds forty people.', start_time: 14.0, end_time: 19.5 },
],
}),
'Planning call notes.txt': [
'Ana Silva: Good morning. Shall we start with the budget?',
'Leo Park: Travel is ten percent over plan.',
'Ana Silva: Can we move the offsite to May?',
'Leo Park: May works, and the venue holds forty people.',
'',
].join('\n'),
};
/** A file's turns, read by the format its name and its bytes give; `null` for a file that is none of them. */
async function read(name, bytes) {
// The whole file: Fireflies's JSON can hold its sentences, and Otter's export its second turn or its closing line, past
// a file's first bytes.
const format = transcriptFormatOf(name, bytes);
if (format === null) return null;
if (format === 'docx') {
// mammoth's Node build reads { buffer }; a page passes mammoth's browser build, which reads { arrayBuffer }, as it is.
const { default: mammoth } = await import('mammoth');
return readWordTranscript(bytes, {
extractRawText: ({ arrayBuffer }) => mammoth.extractRawText({ buffer: Buffer.from(arrayBuffer) }),
});
}
return readTranscript(bytes, format);
}
/** Prints a read: its format, its length, and each turn with its speaker joined back from the labels. */
function show(name, transcript) {
const speakers = new Map(transcript.labels.map((label) => [label.seq, label.label]));
const length = transcript.durationMs === null ? 'no length given' : `${transcript.durationMs / 1000} s`;
console.log(`${name}: ${transcript.format}, ${transcript.turns.length} turns, ${length}`);
for (const turn of transcript.turns) {
const at = turn.startMs === null ? '' : `[${(turn.startMs / 1000).toFixed(1)} s] `;
console.log(` ${turn.seq}. ${at}${speakers.get(turn.seq) ?? '(no speaker named)'}: ${turn.text}`);
}
}
const files = process.argv.slice(2);
if (files.length > 0) {
for (const file of files) {
const transcript = await read(basename(file), new Uint8Array(await readFile(file)));
if (transcript === null) console.log(`${file}: not SubRip, WebVTT, Otter's text, Fireflies's JSON, plain lines or a Word file`);
else show(file, transcript);
}
} else {
console.log('\n=== one call in five layouts, each read from its bytes ===\n');
const encoder = new TextEncoder();
const reads = [];
for (const [name, text] of Object.entries(SAMPLES)) {
const transcript = await read(name, encoder.encode(text));
if (transcript === null) throw new Error(`${name} was named no transcript format`);
show(name, transcript);
reads.push(transcript);
}
// Whatever the layout, the call reads into the same words and the same speakers by turn.
const words = (transcript) => JSON.stringify(transcript.turns.map((turn) => turn.text));
const voices = (transcript) => JSON.stringify(transcript.labels);
for (const transcript of reads) {
if (words(transcript) !== words(reads[0]) || voices(transcript) !== voices(reads[0])) {
throw new Error(`the ${transcript.format} layout read into other turns than the ${reads[0].format} layout`);
}
}
console.log(`\nall five: the same ${reads[0].turns.length} turns and ${reads[0].labels.length} labels`);
console.log('\n=== the turns as passages, searched on the device ===\n');
const call = reads[1];
const speakers = new Map(call.labels.map((label) => [label.seq, label.label]));
const passages = chunkTurns(
call.turns.map((turn) => ({ ...turn, itemId: `turn-${turn.seq}` })),
{ maxChars: 100 },
);
const index = new LexicalIndex({ tokenize: lexicalTokens });
for (const passage of passages) index.addDocument(`call#${passage.index}`, passage.text);
console.log(`${passages.length} passages`);
const [hit] = index.search('offsite', 5);
if (hit === undefined) throw new Error('the search for "offsite" found no passage');
const passage = passages[Number(hit.id.split('#')[1])];
const snippet = snippetAround(passage.text, ['offsite']);
// The passage records where each turn sits in its text: the turn that holds the word, and the speaker the page kept.
const turn = passage.turns.find((placed) => placed.start <= snippet.at && snippet.at < placed.end);
if (turn === undefined) throw new Error('the word found lies in no turn of its passage');
console.log(`"offsite": ${hit.id}, turn ${turn.seq}, said by ${speakers.get(turn.seq)}: ${snippet.text}`);
console.log('\n=== an archive\'s plan, before anything is read ===\n');
const MB = 1_048_576;
const plan = await planEntries(
[
{ path: 'Planning 0123456789abcdef0123456789abcdef/Offsite 89abcdef0123456789abcdef01234567.md', size: 2_048 },
{ path: 'Planning 0123456789abcdef0123456789abcdef/Budget.pdf', size: 3 * MB },
{ path: 'Calls/standup.vtt', size: 9_000 },
{ path: 'Calls/standup.m4a', size: 8 * MB },
{ path: 'Calls/Agenda.txt', size: 16 },
{ path: 'Calls/Agenda copy.txt', size: 16 },
{ path: 'Scans/receipt.heic', size: 900_000 },
{ path: 'Scans/manual.pdf', size: 40 * MB },
{ path: 'more.zip', size: 30_000 },
{ path: '../outside.md', size: 100 },
],
{
documentTypes: { '.md': 'text/markdown', '.txt': 'text/plain', '.pdf': 'application/pdf' },
transcriptExtensions: ['.srt', '.vtt'],
recordingExtensions: ['.m4a', '.mp3'],
maxBytes: (kind) => (kind === 'recording' ? 500 * MB : 10 * MB),
// A nested archive is listed by the caller, one level deep; here, from a list.
openArchive: async (path) => (path === 'more.zip' ? [{ path: 'Inner.md', size: 500 }, { path: 'deeper.zip', size: 900 }] : []),
},
);
for (const entry of plan) {
const what = entry.becomes === 'skipped' ? `skipped (${entry.reason})` : entry.becomes;
const where = `${entry.folder ?? 'the top'}${entry.within === null ? '' : ` of ${entry.within}`}`;
console.log(` ${what.padEnd(18)} "${entry.title}" in ${where}`);
}
if (plan[0].title !== 'Offsite' || plan[0].folder !== 'Planning') throw new Error('a Notion export\'s ids were left in a title');
if (plan.find((entry) => entry.path === 'Scans/manual.pdf')?.reason !== 'size') throw new Error('a file past its bound was planned');
// The plan reads no file, so two copies of one text are planned alike; the caller finds them by what it reads.
const texts = { 'Calls/Agenda.txt': 'Book the venue.\n', 'Calls/Agenda copy.txt': 'Book the venue.\n' };
const seen = new Map();
for (const [path, text] of Object.entries(texts)) {
const digest = await sha256Hex(text);
if (seen.has(digest)) console.log(`\n${path} holds the same text as ${seen.get(digest)} (SHA-256 ${digest.slice(0, 16)}...)`);
else seen.set(digest, path);
}
if (seen.size !== 1) throw new Error('two copies of one text hashed apart');
}