atris 3.62.0 → 3.63.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.
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Bench what users get from `atris youtube notes`, end to end, uncached.
5
+ // Usage: node scripts/det/ytrail-bench.js [--quick] [--case <name>]
6
+ // Prints one line per case and a final "bench: X/Y passed in Ns"; exits 1 if
7
+ // any case fails. Rows (with a `case` field) go to $YTRAIL_OUT_DIR/ytrail.jsonl,
8
+ // default ~/.atris/benchmarks, outside any git repo.
9
+ // ytnotes is resolved next to this file, so an installed copy benches itself.
10
+
11
+ const { spawnSync } = require('node:child_process');
12
+ const fs = require('node:fs');
13
+ const os = require('node:os');
14
+ const path = require('node:path');
15
+ const { evaluate, appendRow } = require('./ytrail-eval');
16
+
17
+ const YTNOTES = path.join(__dirname, 'ytnotes');
18
+ const STRANGER_TIMEOUT_MS = 300000;
19
+
20
+ const CASES = [
21
+ { name: 'captioned-short', kind: 'graded', url: 'https://www.youtube.com/watch?v=Z3JyAqh4ixg', quick: true },
22
+ { name: 'captioned-long', kind: 'graded', url: 'https://www.youtube.com/watch?v=Am7IWP8IpEc' },
23
+ { name: 'no-captions', kind: 'no-captions', url: 'https://www.youtube.com/watch?v=6DRlX5vIOE0', minWords: 50 },
24
+ { name: 'stranger-no-downloader', kind: 'no-downloader', url: 'https://www.youtube.com/watch?v=Am7IWP8IpEc', quick: true },
25
+ { name: 'stranger-no-writer', kind: 'no-writer', url: 'https://www.youtube.com/watch?v=Am7IWP8IpEc', quick: true },
26
+ ];
27
+
28
+ function parseArgs(argv) {
29
+ const opts = { quick: false, only: null };
30
+ for (let i = 0; i < argv.length; i += 1) {
31
+ if (argv[i] === '--quick') opts.quick = true;
32
+ else if (argv[i] === '--case') opts.only = argv[++i] || '';
33
+ else if (argv[i].startsWith('--case=')) opts.only = argv[i].slice('--case='.length);
34
+ else throw new Error(`unknown option: ${argv[i]}`);
35
+ }
36
+ return opts;
37
+ }
38
+
39
+ function selectCases(opts, cases = CASES) {
40
+ if (opts.only !== null) {
41
+ const found = cases.filter((c) => c.name === opts.only);
42
+ if (!found.length) {
43
+ throw new Error(`unknown case: ${opts.only} (cases: ${cases.map((c) => c.name).join(', ')})`);
44
+ }
45
+ return found;
46
+ }
47
+ return opts.quick ? cases.filter((c) => c.quick) : cases.slice();
48
+ }
49
+
50
+ // First executable named `name` on PATH, then in the extra dirs.
51
+ function findBinary(name, env = process.env, extraDirs = []) {
52
+ const dirs = String(env.PATH || '').split(path.delimiter).filter(Boolean).concat(extraDirs);
53
+ for (const dir of dirs) {
54
+ const file = path.join(dir, name);
55
+ try {
56
+ fs.accessSync(file, fs.constants.X_OK);
57
+ if (fs.statSync(file).isFile()) return file;
58
+ } catch {}
59
+ }
60
+ return null;
61
+ }
62
+
63
+ function findWhisper(env = process.env) {
64
+ return findBinary('mlx_whisper', env, [path.join(os.homedir(), '.local', 'bin')]);
65
+ }
66
+
67
+ function findYtDlp(env = process.env) {
68
+ return findBinary('yt-dlp', env, [path.join(os.homedir(), '.local', 'bin'), '/opt/homebrew/bin']);
69
+ }
70
+
71
+ // Pure pass/fail rules. Each takes a finished run and returns { pass, note }.
72
+ function judgeNoCaptions({ whisper, row, stderr }) {
73
+ if (whisper) {
74
+ return { pass: Boolean(row.pass), note: 'local transcription' };
75
+ }
76
+ const pass = row.exit === 2 && /atris youtube process/.test(stderr || '');
77
+ return { pass, note: 'no speech model, paid command offered' };
78
+ }
79
+
80
+ function judgeNoDownloader({ status, stderr }) {
81
+ const text = String(stderr || '');
82
+ const pass = status === 2 && /missing yt-dlp/.test(text) && !/No English captions/.test(text);
83
+ return { pass, note: pass ? 'says to install yt-dlp' : 'did not say to install yt-dlp' };
84
+ }
85
+
86
+ function judgeNoWriter({ status, stderr, notesLeft }) {
87
+ const text = String(stderr || '');
88
+ const pass = status === 3 && /No AI writer is installed/.test(text) && !notesLeft;
89
+ return { pass, note: pass ? 'says to install an AI writer' : 'did not say to install an AI writer' };
90
+ }
91
+
92
+ // Run ytnotes as a stranger would: a bare HOME, and only node (plus yt-dlp
93
+ // when given) on top of the system dirs.
94
+ function runStranger(url, { ytDlp = null } = {}) {
95
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ytrail-stranger-'));
96
+ const bin = path.join(tmp, 'bin');
97
+ fs.mkdirSync(bin);
98
+ fs.symlinkSync(process.execPath, path.join(bin, 'node'));
99
+ if (ytDlp) fs.symlinkSync(ytDlp, path.join(bin, 'yt-dlp'));
100
+ try {
101
+ const started = Date.now();
102
+ const run = spawnSync(YTNOTES, [url], {
103
+ encoding: 'utf8',
104
+ cwd: tmp,
105
+ env: { HOME: tmp, PATH: `${bin}:/usr/bin:/bin`, TMPDIR: tmp },
106
+ timeout: STRANGER_TIMEOUT_MS,
107
+ });
108
+ const seconds = Number(((Date.now() - started) / 1000).toFixed(1));
109
+ const id = String(url).match(/[?&]v=([^&]+)/)[1];
110
+ const notesLeft = fs.existsSync(path.join(tmp, 'ytnotes', `yt_${id}.md`));
111
+ return { status: run.status, stderr: String(run.stderr || ''), seconds, notesLeft };
112
+ } finally {
113
+ fs.rmSync(tmp, { recursive: true, force: true });
114
+ }
115
+ }
116
+
117
+ function lastLines(text, n = 3) {
118
+ return String(text || '').trim().split('\n').filter(Boolean).slice(-n).map((line) => ` ${line}`).join('\n');
119
+ }
120
+
121
+ function runCase(c) {
122
+ if (c.kind === 'graded' || c.kind === 'no-captions') {
123
+ const whisper = c.kind === 'no-captions' ? findWhisper() : null;
124
+ const { row, stderr } = evaluate({ url: c.url, engine: 'auto', minWords: c.minWords });
125
+ let verdict = { pass: row.pass, note: null };
126
+ if (c.kind === 'no-captions') verdict = judgeNoCaptions({ whisper, row, stderr });
127
+ const q = row.quotes;
128
+ const parts = [
129
+ `${c.name} ${verdict.pass ? 'pass' : 'fail'} ${row.seconds}s`,
130
+ verdict.note ? `(${verdict.note})` : null,
131
+ `exit=${row.exit === null ? 'none' : row.exit}`,
132
+ `words=${row.words}`,
133
+ `writer=${row.writer || 'none'}`,
134
+ q ? `quotes=${q.kept} kept/${q.repaired} repaired/${q.dropped} dropped` : null,
135
+ ].filter(Boolean);
136
+ return {
137
+ pass: verdict.pass,
138
+ line: parts.join(' '),
139
+ detail: verdict.pass ? '' : lastLines(stderr),
140
+ row: { ...row, case: c.name, pass: verdict.pass, ...(verdict.note ? { path: verdict.note } : {}) },
141
+ };
142
+ }
143
+
144
+ let ytDlp = null;
145
+ if (c.kind === 'no-writer') {
146
+ ytDlp = findYtDlp();
147
+ if (!ytDlp) {
148
+ return {
149
+ skipped: true,
150
+ line: `${c.name} skipped: no yt-dlp on this computer to test with`,
151
+ row: { ts: new Date().toISOString(), case: c.name, url: c.url, skipped: true },
152
+ };
153
+ }
154
+ }
155
+ const run = runStranger(c.url, { ytDlp });
156
+ const verdict = c.kind === 'no-writer' ? judgeNoWriter(run) : judgeNoDownloader(run);
157
+ return {
158
+ pass: verdict.pass,
159
+ line: `${c.name} ${verdict.pass ? 'pass' : 'fail'} ${run.seconds}s exit=${run.status === null ? 'none' : run.status} (${verdict.note})`,
160
+ detail: verdict.pass ? '' : lastLines(run.stderr),
161
+ row: {
162
+ ts: new Date().toISOString(),
163
+ case: c.name,
164
+ url: c.url,
165
+ seconds: run.seconds,
166
+ pass: verdict.pass,
167
+ exit: run.status,
168
+ notesLeft: run.notesLeft,
169
+ },
170
+ };
171
+ }
172
+
173
+ function summary(results, seconds) {
174
+ const ran = results.filter((r) => !r.skipped);
175
+ const passed = ran.filter((r) => r.pass).length;
176
+ const skipped = results.length - ran.length;
177
+ return `bench: ${passed}/${ran.length} passed in ${seconds}s${skipped ? ` (${skipped} skipped)` : ''}`;
178
+ }
179
+
180
+ function main() {
181
+ let cases;
182
+ try {
183
+ cases = selectCases(parseArgs(process.argv.slice(2)));
184
+ } catch (err) {
185
+ console.error(err.message);
186
+ console.error('usage: ytrail-bench [--quick] [--case <name>]');
187
+ process.exit(2);
188
+ }
189
+ const outDir = process.env.YTRAIL_OUT_DIR || path.join(os.homedir(), '.atris', 'benchmarks');
190
+ const started = Date.now();
191
+ const results = [];
192
+ for (const c of cases) {
193
+ const result = runCase(c);
194
+ results.push(result);
195
+ console.log(result.line);
196
+ if (result.detail) console.log(result.detail);
197
+ appendRow(outDir, result.row);
198
+ }
199
+ console.log(summary(results, Math.round((Date.now() - started) / 1000)));
200
+ process.exit(results.some((r) => !r.skipped && !r.pass) ? 1 : 0);
201
+ }
202
+
203
+ if (require.main === module) main();
204
+
205
+ module.exports = {
206
+ CASES,
207
+ parseArgs,
208
+ selectCases,
209
+ findBinary,
210
+ judgeNoCaptions,
211
+ judgeNoDownloader,
212
+ judgeNoWriter,
213
+ summary,
214
+ };
@@ -4,14 +4,23 @@
4
4
  // Score one ytnotes run. Usage:
5
5
  // node scripts/det/ytrail-eval.js [url] [engine]
6
6
  // Default url: https://www.youtube.com/watch?v=Z3JyAqh4ixg
7
- // Default engine: haiku
7
+ // Default engine: auto (what users get)
8
+ // Each run gets a fresh, empty TMPDIR, so nothing is cached and the timing
9
+ // is real. YTRAIL_OUT_DIR sets where ytrail.jsonl goes (default
10
+ // <root>/atris/benchmarks). YTRAIL_MIN_WORDS sets the transcript floor
11
+ // (default 1000).
8
12
 
9
13
  const { spawnSync } = require('node:child_process');
10
14
  const fs = require('node:fs');
15
+ const os = require('node:os');
11
16
  const path = require('node:path');
12
17
 
13
18
  const DEFAULT_URL = 'https://www.youtube.com/watch?v=Z3JyAqh4ixg';
14
- const DEFAULT_ENGINE = 'haiku';
19
+ const DEFAULT_ENGINE = 'auto';
20
+ const DEFAULT_TIMEOUT_MS = 600000;
21
+ const DEFAULT_MIN_WORDS = 1000;
22
+ const ROOT = path.resolve(__dirname, '..', '..');
23
+ const YTNOTES = path.join(__dirname, 'ytnotes');
15
24
 
16
25
  function videoId(url) {
17
26
  const watch = String(url).match(/[?&]v=([^&]+)/);
@@ -53,72 +62,135 @@ function scoreQuotes(notes, transcript) {
53
62
  }
54
63
 
55
64
  function firstLine(text) {
56
- return String(text).replace(/^\uFEFF/, '').split(/\r?\n/, 1)[0] || '';
65
+ return String(text).replace(/^/, '').split(/\r?\n/, 1)[0] || '';
57
66
  }
58
67
 
59
- function main() {
60
- const url = process.argv[2] || DEFAULT_URL;
61
- const engine = process.argv[3] || DEFAULT_ENGINE;
62
- const root = path.resolve(__dirname, '..', '..');
63
- const ytnotes = path.join(root, 'scripts', 'det', 'ytnotes');
64
- const workDir = path.join(process.env.TMPDIR || '/tmp', 'ytnotes');
65
- const id = videoId(url);
66
- const transcriptPath = id ? path.join(workDir, `yt_${id}.clean.txt`) : '';
67
- const notesPath = id ? path.join(workDir, `yt_${id}.md`) : '';
68
-
69
- const started = Date.now();
70
- const run = spawnSync(ytnotes, [url, engine], {
71
- encoding: 'utf8',
72
- cwd: root,
73
- env: process.env,
74
- timeout: 180000,
75
- });
76
- const seconds = Number(((Date.now() - started) / 1000).toFixed(1));
77
-
78
- const stdoutNotes = String(run.stdout || '');
79
- let fileNotes = '';
80
- if (notesPath && fs.existsSync(notesPath)) {
81
- fileNotes = fs.readFileSync(notesPath, 'utf8');
82
- }
83
- const notes = fileNotes || stdoutNotes;
68
+ // ytquote-repair prints "quotes: N kept, N repaired, N dropped" to stderr.
69
+ function parseQuoteRepair(stderr) {
70
+ const match = String(stderr || '').match(/quotes: (\d+) kept, (\d+) repaired, (\d+) dropped/);
71
+ if (!match) return null;
72
+ return { kept: Number(match[1]), repaired: Number(match[2]), dropped: Number(match[3]) };
73
+ }
84
74
 
85
- let transcript = '';
86
- if (transcriptPath && fs.existsSync(transcriptPath)) {
87
- transcript = fs.readFileSync(transcriptPath, 'utf8');
88
- }
75
+ // ytnotes prints "notes by <writer>" to stderr once notes are written.
76
+ function parseWriter(stderr) {
77
+ const matches = [...String(stderr || '').matchAll(/^notes by (.+)$/gm)];
78
+ return matches.length ? matches[matches.length - 1][1].trim() : null;
79
+ }
89
80
 
81
+ function parseMinWords(value) {
82
+ const n = Number.parseInt(value, 10);
83
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_MIN_WORDS;
84
+ }
85
+
86
+ function outDir(env = process.env) {
87
+ return env.YTRAIL_OUT_DIR || path.join(ROOT, 'atris', 'benchmarks');
88
+ }
89
+
90
+ // Pure grade of one finished run.
91
+ function gradeRun({ status, notes, transcript, minWords = DEFAULT_MIN_WORDS }) {
90
92
  const quotes = scoreQuotes(notes, transcript);
93
+ const words = wordCount(transcript);
91
94
  const checks = {
92
- exit0: run.status === 0,
93
- transcriptWords: wordCount(transcript) >= 1000,
94
- notesExist: notes.trim().length > 0,
95
+ exit0: status === 0,
96
+ transcriptWords: words >= minWords,
97
+ notesExist: String(notes).trim().length > 0,
95
98
  notesHeading: firstLine(notes).startsWith('#'),
96
99
  quoteHonesty: quotes.pass,
97
100
  };
98
- const pass = Object.values(checks).every(Boolean);
99
-
100
- const row = {
101
- ts: new Date().toISOString(),
102
- url,
103
- engine,
104
- seconds,
105
- pass,
106
- checks,
107
- };
101
+ return { pass: Object.values(checks).every(Boolean), checks, words, quoteScore: quotes };
102
+ }
108
103
 
109
- const outDir = path.join(root, 'atris', 'benchmarks');
110
- fs.mkdirSync(outDir, { recursive: true });
111
- fs.appendFileSync(path.join(outDir, 'ytrail.jsonl'), `${JSON.stringify(row)}\n`);
104
+ function readIfExists(file) {
105
+ return file && fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
106
+ }
112
107
 
113
- const words = wordCount(transcript);
114
- console.log(
115
- `ytrail ${pass ? 'pass' : 'fail'} ${engine} ${seconds}s words=${words} quotes=${quotes.verified}/${quotes.spans} heading=${checks.notesHeading ? 'yes' : 'no'}`
116
- );
117
- if (run.status !== 0 && run.stderr) {
118
- console.log(String(run.stderr).trim().split('\n').slice(-8).join('\n'));
108
+ // Run ytnotes once in a fresh TMPDIR, grade it, and clean up. Writes nothing.
109
+ function evaluate(options = {}) {
110
+ const url = options.url || DEFAULT_URL;
111
+ const engine = options.engine || DEFAULT_ENGINE;
112
+ const minWords = options.minWords === undefined ? DEFAULT_MIN_WORDS : options.minWords;
113
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ytrail-'));
114
+ const workDir = path.join(tmp, 'ytnotes');
115
+ const id = videoId(url);
116
+
117
+ try {
118
+ const started = Date.now();
119
+ const run = spawnSync(options.ytnotes || YTNOTES, [url, engine], {
120
+ encoding: 'utf8',
121
+ cwd: options.cwd || ROOT,
122
+ env: { ...(options.env || process.env), TMPDIR: tmp },
123
+ timeout: options.timeoutMs || DEFAULT_TIMEOUT_MS,
124
+ });
125
+ const seconds = Number(((Date.now() - started) / 1000).toFixed(1));
126
+ const stderr = String(run.stderr || '');
127
+ const notes = readIfExists(id && path.join(workDir, `yt_${id}.md`)) || String(run.stdout || '');
128
+ const transcript = readIfExists(id && path.join(workDir, `yt_${id}.clean.txt`));
129
+ const grade = gradeRun({ status: run.status, notes, transcript, minWords });
130
+ const timedOut = Boolean(run.error && run.error.code === 'ETIMEDOUT');
131
+
132
+ const row = {
133
+ ts: new Date().toISOString(),
134
+ url,
135
+ engine,
136
+ seconds,
137
+ pass: grade.pass,
138
+ checks: grade.checks,
139
+ exit: run.status,
140
+ timedOut,
141
+ writer: parseWriter(stderr),
142
+ quotes: parseQuoteRepair(stderr),
143
+ words: grade.words,
144
+ minWords,
145
+ };
146
+ return { row, grade, stderr, status: run.status };
147
+ } finally {
148
+ fs.rmSync(tmp, { recursive: true, force: true });
149
+ }
150
+ }
151
+
152
+ // Keep the head of this line as is: ytrail-race.js parses it.
153
+ function formatLine(row, grade) {
154
+ const head = `ytrail ${row.pass ? 'pass' : 'fail'} ${row.engine} ${row.seconds}s words=${grade.words} quotes=${grade.quoteScore.verified}/${grade.quoteScore.spans} heading=${grade.checks.notesHeading ? 'yes' : 'no'}`;
155
+ return `${head} writer=${row.writer || 'none'} exit=${row.exit === null ? 'none' : row.exit}`;
156
+ }
157
+
158
+ function appendRow(dir, row) {
159
+ fs.mkdirSync(dir, { recursive: true });
160
+ fs.appendFileSync(path.join(dir, 'ytrail.jsonl'), `${JSON.stringify(row)}\n`);
161
+ }
162
+
163
+ function main() {
164
+ const url = process.argv[2] || DEFAULT_URL;
165
+ const engine = process.argv[3] || DEFAULT_ENGINE;
166
+ const minWords = parseMinWords(process.env.YTRAIL_MIN_WORDS);
167
+ const { row, grade, stderr, status } = evaluate({ url, engine, minWords });
168
+
169
+ appendRow(outDir(), row);
170
+ console.log(formatLine(row, grade));
171
+ if (status !== 0 && stderr) {
172
+ console.log(stderr.trim().split('\n').slice(-8).join('\n'));
119
173
  }
120
174
 
121
- process.exit(pass ? 0 : 1);
175
+ process.exit(row.pass ? 0 : 1);
122
176
  }
123
177
 
124
- main();
178
+ if (require.main === module) main();
179
+
180
+ module.exports = {
181
+ DEFAULT_URL,
182
+ DEFAULT_ENGINE,
183
+ DEFAULT_TIMEOUT_MS,
184
+ DEFAULT_MIN_WORDS,
185
+ videoId,
186
+ wordCount,
187
+ scoreQuotes,
188
+ parseQuoteRepair,
189
+ parseWriter,
190
+ parseMinWords,
191
+ outDir,
192
+ gradeRun,
193
+ evaluate,
194
+ formatLine,
195
+ appendRow,
196
+ };