@msareen/knowledge-hub-builder 0.1.3
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/.agents/skills/catalog/SKILL.md +7 -0
- package/.agents/skills/export/SKILL.md +7 -0
- package/.agents/skills/ingest/SKILL.md +7 -0
- package/.agents/skills/lint/SKILL.md +7 -0
- package/.agents/skills/new-bundle/SKILL.md +7 -0
- package/.agents/skills/query/SKILL.md +7 -0
- package/.agents/skills/visualize/SKILL.md +7 -0
- package/.bundle_template/index.md +9 -0
- package/.bundle_template/log.md +10 -0
- package/.bundle_template/raw/.gitkeep +15 -0
- package/.bundle_template/refs.md +6 -0
- package/.bundle_template/sources.yaml +13 -0
- package/.claude/skills/catalog/SKILL.md +7 -0
- package/.claude/skills/export/SKILL.md +7 -0
- package/.claude/skills/ingest/SKILL.md +7 -0
- package/.claude/skills/lint/SKILL.md +7 -0
- package/.claude/skills/new-bundle/SKILL.md +7 -0
- package/.claude/skills/query/SKILL.md +7 -0
- package/.claude/skills/visualize/SKILL.md +7 -0
- package/AGENTS.md +167 -0
- package/CLAUDE.md +13 -0
- package/README.md +289 -0
- package/SPEC.md +354 -0
- package/document/faq.md +156 -0
- package/package.json +52 -0
- package/scripts/cli.ts +66 -0
- package/scripts/export.ts +42 -0
- package/scripts/ingest/acquire.ts +189 -0
- package/scripts/ingest/exts.ts +29 -0
- package/scripts/ingest/files.ts +29 -0
- package/scripts/ingest/folder.ts +44 -0
- package/scripts/ingest/index.ts +125 -0
- package/scripts/ingest/protect.ts +42 -0
- package/scripts/ingest/web.ts +54 -0
- package/scripts/init.ts +93 -0
- package/scripts/lib/args.ts +8 -0
- package/scripts/lib/extract.ts +384 -0
- package/scripts/lib/graph-page.ts +477 -0
- package/scripts/lib/graph.ts +117 -0
- package/scripts/lib/ledger.ts +136 -0
- package/scripts/lib/log.ts +53 -0
- package/scripts/lib/paths.ts +55 -0
- package/scripts/lib/scaffold.ts +56 -0
- package/scripts/lib/util.ts +154 -0
- package/scripts/lint.ts +165 -0
- package/scripts/new-bundle.ts +17 -0
- package/scripts/visualize.ts +104 -0
- package/skills/catalog/SKILL.md +164 -0
- package/skills/export/SKILL.md +31 -0
- package/skills/ingest/SKILL.md +229 -0
- package/skills/lint/SKILL.md +69 -0
- package/skills/new-bundle/SKILL.md +25 -0
- package/skills/query/SKILL.md +114 -0
- package/skills/visualize/SKILL.md +33 -0
- package/templates/hub/gitattributes +12 -0
- package/templates/hub/gitignore +12 -0
- package/templates/hub/outer.index.md +13 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// Content-addressed extraction cache: inbox/extracted/<sha256>.md
|
|
2
|
+
//
|
|
3
|
+
// Binary formats have to be converted before anything can read them. That conversion is
|
|
4
|
+
// expensive and deterministic per content hash, so it is cached hub-wide rather than
|
|
5
|
+
// per-bundle. Nothing here ever writes into a bundle's `raw/` — `raw/` stays derived and
|
|
6
|
+
// rebuildable (templates/hub/gitignore:2); callers copy out of the cache.
|
|
7
|
+
//
|
|
8
|
+
// Extraction is built in. khb is tooling, so it carries the libraries: unpdf (pdf.js),
|
|
9
|
+
// mammoth and fflate are pure JS with no native build and no PATH assumptions, which is
|
|
10
|
+
// what lets `khb ingest` work on a bare machine. External CLIs are a bonus, not a
|
|
11
|
+
// requirement: if `pdftotext` or `pandoc` happen to be installed they get a second shot at
|
|
12
|
+
// anything the library couldn't read, because poppler still wins on gnarly layouts.
|
|
13
|
+
//
|
|
14
|
+
// Every route out of here is LOCAL and deterministic — pure-JS libraries, tesseract WASM,
|
|
15
|
+
// a whisper binary. None of it contacts a model. That is the AGENTS.md division of labor:
|
|
16
|
+
// khb converts bytes to text as cheaply as possible, and the agent's judgement is spent on
|
|
17
|
+
// curation, not on transcription.
|
|
18
|
+
//
|
|
19
|
+
// The two lossy routes (OCR, ASR) are marked `quality: low` rather than hidden. A pixel or
|
|
20
|
+
// audio source that extracted badly is not a dead end — the original file is still on disk
|
|
21
|
+
// and named in the provenance header, so curation can escalate to a vision read of the
|
|
22
|
+
// source instead of trusting garbled text.
|
|
23
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, readdirSync } from "node:fs";
|
|
24
|
+
import { INBOX, join, basename } from "./util";
|
|
25
|
+
import { note } from "./log";
|
|
26
|
+
|
|
27
|
+
export const EXTRACTED = join(INBOX, "extracted");
|
|
28
|
+
|
|
29
|
+
/** Below this many characters per page, a PDF is a picture of a document, not a document. */
|
|
30
|
+
const SCANNED_CHARS_PER_PAGE = 20;
|
|
31
|
+
|
|
32
|
+
/** `high` = real text out of a born-digital file. `low` = OCR/ASR guessed at it. */
|
|
33
|
+
export type Quality = "high" | "low";
|
|
34
|
+
|
|
35
|
+
export type Extraction =
|
|
36
|
+
| { status: "ok"; path: string; tool: string; quality: Quality }
|
|
37
|
+
| { status: "needs-ocr"; pages: number } // renders fine, has no text layer — OCR is the only route
|
|
38
|
+
| { status: "unsupported" } // no extractor for this format
|
|
39
|
+
| { status: "failed"; reason: string }; // tried, got nothing usable
|
|
40
|
+
|
|
41
|
+
export function extractedPath(hash: string): string {
|
|
42
|
+
return join(EXTRACTED, `${hash}.md`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Cached text minus the provenance header — what a `raw/` copy actually wants. */
|
|
46
|
+
export function extractedBody(path: string): string {
|
|
47
|
+
const text = readFileSync(path, "utf8");
|
|
48
|
+
const m = text.match(/^---\n[\s\S]*?\n---\n\n?/);
|
|
49
|
+
return m ? text.slice(m[0].length) : text;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* How a cache entry was produced. Read back rather than re-derived, so a `raw/` copy made
|
|
54
|
+
* from cache carries the same `extract_tool`/`quality` as the run that filled it.
|
|
55
|
+
*/
|
|
56
|
+
export function extractedMeta(path: string): { tool: string; quality: Quality } {
|
|
57
|
+
const head = readFileSync(path, "utf8").slice(0, 2048);
|
|
58
|
+
const tool = head.match(/^tool: (.*)$/m)?.[1]?.trim() || "unknown";
|
|
59
|
+
const quality = head.match(/^quality: (.*)$/m)?.[1]?.trim() === "low" ? "low" : "high";
|
|
60
|
+
return { tool, quality };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type LibResult = { text: string; pages?: number };
|
|
64
|
+
|
|
65
|
+
const unxml = (s: string) =>
|
|
66
|
+
s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
67
|
+
|
|
68
|
+
/** Strip tags from an OOXML/ODF fragment, keeping paragraph breaks. */
|
|
69
|
+
const stripXml = (xml: string, breakOn: RegExp) =>
|
|
70
|
+
unxml(xml.replace(breakOn, "\n").replace(/<[^>]+>/g, ""));
|
|
71
|
+
|
|
72
|
+
/** "BD" → 55. Preserves gaps so an empty cell doesn't shift the rest of the row left. */
|
|
73
|
+
function colIndex(ref: string): number {
|
|
74
|
+
const letters = ref.match(/^[A-Z]+/)?.[0] ?? "A";
|
|
75
|
+
let n = 0;
|
|
76
|
+
for (const ch of letters) n = n * 26 + (ch.charCodeAt(0) - 64);
|
|
77
|
+
return n - 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Built-in, pure-JS extractors. Loaded lazily so `khb init` never pays for them. */
|
|
81
|
+
const LIBRARY: Record<string, (file: string) => Promise<LibResult>> = {
|
|
82
|
+
".pdf": async (file) => {
|
|
83
|
+
const { extractText, getDocumentProxy } = await import("unpdf");
|
|
84
|
+
const doc = await getDocumentProxy(new Uint8Array(readFileSync(file)));
|
|
85
|
+
const { totalPages, text } = await extractText(doc, { mergePages: true });
|
|
86
|
+
return { text, pages: totalPages };
|
|
87
|
+
},
|
|
88
|
+
".docx": async (file) => {
|
|
89
|
+
const mammoth = (await import("mammoth")).default;
|
|
90
|
+
// Markdown keeps headings and lists, which are exactly the structure curation reads.
|
|
91
|
+
const { value } = await mammoth.convertToMarkdown({ path: file });
|
|
92
|
+
// mammoth escapes markdown punctuation defensively ("Non\-Disclosure", "2026\-02\-01").
|
|
93
|
+
// That noise ends up in model snippets and in raw/, so undo it.
|
|
94
|
+
return { text: value.replace(/\\([-_*#+.!\[\]()`])/g, "$1") };
|
|
95
|
+
},
|
|
96
|
+
".odt": async (file) => {
|
|
97
|
+
// ODT is a zip of XML, same shape as DOCX — cheap to support once fflate is here.
|
|
98
|
+
const { unzipSync, strFromU8 } = await import("fflate");
|
|
99
|
+
const zip = unzipSync(new Uint8Array(readFileSync(file)));
|
|
100
|
+
const xml = strFromU8(zip["content.xml"] ?? new Uint8Array());
|
|
101
|
+
return { text: stripXml(xml, /<text:(?:h|p)\b[^>]*>/g) };
|
|
102
|
+
},
|
|
103
|
+
// XLSX and PPTX are the same zip+XML shape as ODT, so they cost one parser each and no
|
|
104
|
+
// new dependency. Spreadsheets in particular are worth having: a budget or a tracker is
|
|
105
|
+
// knowledge, and it reaching curation as a blank row was the single biggest gap.
|
|
106
|
+
".xlsx": async (file) => {
|
|
107
|
+
const { unzipSync, strFromU8 } = await import("fflate");
|
|
108
|
+
const zip = unzipSync(new Uint8Array(readFileSync(file)));
|
|
109
|
+
const at = (n: string) => (zip[n] ? strFromU8(zip[n]) : "");
|
|
110
|
+
|
|
111
|
+
// Cell values are indices into one shared string table; resolve it before the sheets.
|
|
112
|
+
const shared = [...at("xl/sharedStrings.xml").matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) =>
|
|
113
|
+
[...m[1].matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((t) => unxml(t[1])).join(""),
|
|
114
|
+
);
|
|
115
|
+
const names = [...at("xl/workbook.xml").matchAll(/<sheet[^>]*name="([^"]*)"/g)].map((m) => unxml(m[1]));
|
|
116
|
+
const sheets = Object.keys(zip)
|
|
117
|
+
.filter((n) => /^xl\/worksheets\/sheet\d+\.xml$/.test(n))
|
|
118
|
+
.sort((a, b) => Number(a.match(/\d+/)![0]) - Number(b.match(/\d+/)![0]));
|
|
119
|
+
|
|
120
|
+
const out: string[] = [];
|
|
121
|
+
sheets.forEach((sheetFile, i) => {
|
|
122
|
+
const rows: string[][] = [];
|
|
123
|
+
for (const rm of at(sheetFile).matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)) {
|
|
124
|
+
const cells: string[] = [];
|
|
125
|
+
for (const cm of rm[1].matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
|
|
126
|
+
const ref = cm[1].match(/r="([A-Z]+\d+)"/)?.[1];
|
|
127
|
+
const type = cm[1].match(/t="([^"]*)"/)?.[1];
|
|
128
|
+
const v = cm[2].match(/<v>([\s\S]*?)<\/v>/)?.[1];
|
|
129
|
+
const value =
|
|
130
|
+
type === "s" ? (shared[Number(v)] ?? "")
|
|
131
|
+
: type === "inlineStr" ? [...cm[2].matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((t) => unxml(t[1])).join("")
|
|
132
|
+
: unxml(v ?? "");
|
|
133
|
+
if (ref) cells[colIndex(ref)] = value;
|
|
134
|
+
}
|
|
135
|
+
// A sheet is mostly empty cells; drop rows that carry nothing at all.
|
|
136
|
+
const filled = [...cells].map((c) => (c ?? "").replaceAll("|", "\\|").trim());
|
|
137
|
+
if (filled.some(Boolean)) rows.push(filled);
|
|
138
|
+
}
|
|
139
|
+
if (!rows.length) return;
|
|
140
|
+
// Pad every row to the widest one: a sparse sheet whose row 1 is narrower than row 5
|
|
141
|
+
// otherwise renders as a ragged table, which no markdown reader will parse.
|
|
142
|
+
const width = Math.max(...rows.map((r) => r.length));
|
|
143
|
+
const line = (r: string[]) => `| ${Array.from({ length: width }, (_, j) => r[j] ?? "").join(" | ")} |`;
|
|
144
|
+
// A header separator after row 1 makes the sheet render as a table wherever raw/ is
|
|
145
|
+
// read, and costs nothing when row 1 isn't really a header.
|
|
146
|
+
const body = [line(rows[0]), `|${"---|".repeat(width)}`, ...rows.slice(1).map(line)];
|
|
147
|
+
out.push(`## ${names[i] ?? basename(sheetFile)}\n\n${body.join("\n")}`);
|
|
148
|
+
});
|
|
149
|
+
return { text: out.join("\n\n") };
|
|
150
|
+
},
|
|
151
|
+
".pptx": async (file) => {
|
|
152
|
+
const { unzipSync, strFromU8 } = await import("fflate");
|
|
153
|
+
const zip = unzipSync(new Uint8Array(readFileSync(file)));
|
|
154
|
+
const slides = Object.keys(zip)
|
|
155
|
+
.filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n))
|
|
156
|
+
.sort((a, b) => Number(a.match(/\d+/)![0]) - Number(b.match(/\d+/)![0]));
|
|
157
|
+
const out = slides.map((s, i) => {
|
|
158
|
+
const body = [...strFromU8(zip[s]).matchAll(/<a:t>([\s\S]*?)<\/a:t>/g)].map((m) => unxml(m[1])).join("\n");
|
|
159
|
+
return body.trim() ? `## Slide ${i + 1}\n\n${body}` : "";
|
|
160
|
+
});
|
|
161
|
+
return { text: out.filter(Boolean).join("\n\n") };
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Optional second attempt: better fidelity, but only if the user happens to have them. */
|
|
166
|
+
const CLI: Record<string, (file: string) => string[]> = {
|
|
167
|
+
".pdf": (f) => ["pdftotext", "-layout", f, "-"],
|
|
168
|
+
".docx": (f) => ["pandoc", f, "-t", "gfm"],
|
|
169
|
+
".odt": (f) => ["pandoc", f, "-t", "gfm"],
|
|
170
|
+
".pptx": (f) => ["pandoc", f, "-t", "gfm"],
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
async function runCli(argv: string[]): Promise<string> {
|
|
174
|
+
try {
|
|
175
|
+
const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "ignore" });
|
|
176
|
+
const text = await new Response(proc.stdout).text();
|
|
177
|
+
return (await proc.exited) === 0 ? text : "";
|
|
178
|
+
} catch {
|
|
179
|
+
return ""; // not installed — expected, and not worth a warning
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function writeCache(dest: string, path: string, tool: string, quality: Quality, text: string): string {
|
|
184
|
+
mkdirSync(EXTRACTED, { recursive: true });
|
|
185
|
+
const fm =
|
|
186
|
+
`---\nsource: ${path.replaceAll("\\", "/")}\nextracted: ${new Date().toISOString()}\n` +
|
|
187
|
+
`tool: ${tool}\nquality: ${quality}\n---\n\n`;
|
|
188
|
+
writeFileSync(dest, fm + text);
|
|
189
|
+
return dest;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function cacheHit(dest: string): Extraction {
|
|
193
|
+
return { status: "ok", path: dest, ...extractedMeta(dest) };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Fill the cache for this file's extracted text and say what happened.
|
|
198
|
+
* Never throws — a run over thousands of files degrades per file instead of aborting.
|
|
199
|
+
*/
|
|
200
|
+
export async function extractCached(path: string, hash: string, ext: string): Promise<Extraction> {
|
|
201
|
+
const dest = extractedPath(hash);
|
|
202
|
+
if (existsSync(dest)) return cacheHit(dest);
|
|
203
|
+
if (!LIBRARY[ext]) return { status: "unsupported" };
|
|
204
|
+
|
|
205
|
+
let pages: number | undefined;
|
|
206
|
+
let text = "";
|
|
207
|
+
let tool = ext.slice(1);
|
|
208
|
+
try {
|
|
209
|
+
const r = await LIBRARY[ext](path);
|
|
210
|
+
text = r.text;
|
|
211
|
+
pages = r.pages;
|
|
212
|
+
} catch {
|
|
213
|
+
text = ""; // corrupt, encrypted, or a format surprise — the CLI may still cope
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!text.trim() && CLI[ext]) {
|
|
217
|
+
const bin = CLI[ext](path)[0];
|
|
218
|
+
note(`built-in reader recovered nothing — retrying with ${bin} if installed …`);
|
|
219
|
+
text = await runCli(CLI[ext](path));
|
|
220
|
+
if (text.trim()) tool = bin;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Pages but (near-)no characters is the signature of a scan. Check before declaring
|
|
224
|
+
// success: a scan stamped with a page number yields a few characters, not zero, and
|
|
225
|
+
// calling that "extracted" would hide a document that OCR could actually read.
|
|
226
|
+
const trimmed = text.trim();
|
|
227
|
+
if (pages && trimmed.length / pages < SCANNED_CHARS_PER_PAGE) return { status: "needs-ocr", pages };
|
|
228
|
+
if (trimmed) return { status: "ok", path: writeCache(dest, path, tool, "high", trimmed), tool, quality: "high" };
|
|
229
|
+
return { status: "failed", reason: "no text recovered" };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Lazily loaded OCR stack, shared by the PDF and bare-image paths. The install hint is
|
|
234
|
+
* printed once per process, not once per file — a corpus of scans would otherwise bury its
|
|
235
|
+
* own summary under hundreds of identical warnings.
|
|
236
|
+
*/
|
|
237
|
+
let ocrWarned = false;
|
|
238
|
+
async function ocrDeps() {
|
|
239
|
+
try {
|
|
240
|
+
const { createWorker } = await import("tesseract.js");
|
|
241
|
+
return { createWorker };
|
|
242
|
+
} catch {
|
|
243
|
+
if (!ocrWarned) {
|
|
244
|
+
// Resolution is relative to the khb package, not the hub — say where, because for a
|
|
245
|
+
// global install those are different directories and `bun add` in the hub is a no-op.
|
|
246
|
+
const { PKG } = await import("./paths");
|
|
247
|
+
console.warn(` OCR unavailable. Install it where khb resolves modules from:`);
|
|
248
|
+
console.warn(` cd ${PKG} && bun add @hyzyla/pdfium sharp tesseract.js`);
|
|
249
|
+
ocrWarned = true;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* OCR a scanned PDF: render each page, then read the pixels. Runs automatically during
|
|
257
|
+
* ingest when a PDF turns out to have no text layer — a scan is not a failure, it just
|
|
258
|
+
* needs a different reader — but the deps are optional (~75 MB of WASM plus a one-time
|
|
259
|
+
* language-data download), so a missing stack degrades to a pending ledger row.
|
|
260
|
+
*
|
|
261
|
+
* bun add @hyzyla/pdfium sharp tesseract.js
|
|
262
|
+
*/
|
|
263
|
+
export async function ocrCached(path: string, hash: string, dpi = 216): Promise<Extraction> {
|
|
264
|
+
const dest = extractedPath(hash);
|
|
265
|
+
if (existsSync(dest)) return cacheHit(dest);
|
|
266
|
+
|
|
267
|
+
const deps = await ocrDeps();
|
|
268
|
+
if (!deps) return { status: "failed", reason: "OCR dependencies not installed" };
|
|
269
|
+
let PDFiumLibrary, sharp;
|
|
270
|
+
try {
|
|
271
|
+
({ PDFiumLibrary } = await import("@hyzyla/pdfium"));
|
|
272
|
+
sharp = (await import("sharp")).default;
|
|
273
|
+
} catch {
|
|
274
|
+
return { status: "failed", reason: "OCR dependencies not installed" };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const lib = await PDFiumLibrary.init();
|
|
278
|
+
const worker = await deps.createWorker("eng");
|
|
279
|
+
const tool = `tesseract.js @ ${dpi}dpi`;
|
|
280
|
+
try {
|
|
281
|
+
const doc = await lib.loadDocument(readFileSync(path));
|
|
282
|
+
// Materialize the page list for the denominator: this loop is the longest thing khb
|
|
283
|
+
// does, and "page 7/94" is the difference between waiting and killing the process.
|
|
284
|
+
const all = [...doc.pages()];
|
|
285
|
+
const pages: string[] = [];
|
|
286
|
+
for (const [i, page] of all.entries()) {
|
|
287
|
+
const img = await page.render({
|
|
288
|
+
scale: dpi / 72,
|
|
289
|
+
render: (o: { data: Buffer; width: number; height: number }) =>
|
|
290
|
+
sharp(o.data, { raw: { width: o.width, height: o.height, channels: 4 } }).png().toBuffer(),
|
|
291
|
+
});
|
|
292
|
+
const { data } = await worker.recognize(Buffer.from(img.data));
|
|
293
|
+
const text = data.text.trim();
|
|
294
|
+
note(` page ${i + 1}/${all.length} — ${text.length} chars`);
|
|
295
|
+
pages.push(text);
|
|
296
|
+
}
|
|
297
|
+
doc.destroy();
|
|
298
|
+
const text = pages.filter(Boolean).join("\n\n---\n\n");
|
|
299
|
+
if (!text) return { status: "failed", reason: "OCR produced no text" };
|
|
300
|
+
return { status: "ok", path: writeCache(dest, path, tool, "low", text), tool, quality: "low" };
|
|
301
|
+
} catch (e) {
|
|
302
|
+
return { status: "failed", reason: `OCR failed: ${e}` };
|
|
303
|
+
} finally {
|
|
304
|
+
await worker.terminate();
|
|
305
|
+
lib.destroy();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* OCR a bare image (screenshot, photographed page, scanned receipt). Same tesseract pass
|
|
311
|
+
* as a scanned PDF minus the render step, since the pixels are already the file.
|
|
312
|
+
*
|
|
313
|
+
* This deliberately does NOT try to understand a diagram or a chart — OCR reads glyphs.
|
|
314
|
+
* The `quality: low` marker plus the source path in the header is how curation knows to
|
|
315
|
+
* escalate to a vision read when the text comes back thin or nonsensical.
|
|
316
|
+
*/
|
|
317
|
+
export async function ocrImageCached(path: string, hash: string): Promise<Extraction> {
|
|
318
|
+
const dest = extractedPath(hash);
|
|
319
|
+
if (existsSync(dest)) return cacheHit(dest);
|
|
320
|
+
|
|
321
|
+
const deps = await ocrDeps();
|
|
322
|
+
if (!deps) return { status: "failed", reason: "OCR dependencies not installed" };
|
|
323
|
+
|
|
324
|
+
const worker = await deps.createWorker("eng");
|
|
325
|
+
try {
|
|
326
|
+
const { data } = await worker.recognize(readFileSync(path));
|
|
327
|
+
const text = data.text.trim();
|
|
328
|
+
if (!text) return { status: "failed", reason: "no text in image — vision read may still help" };
|
|
329
|
+
return { status: "ok", path: writeCache(dest, path, "tesseract.js", "low", text), tool: "tesseract.js", quality: "low" };
|
|
330
|
+
} catch (e) {
|
|
331
|
+
return { status: "failed", reason: `OCR failed: ${e}` };
|
|
332
|
+
} finally {
|
|
333
|
+
await worker.terminate();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Probe once per process: spawning `--help` per file would cost more than it saves. */
|
|
338
|
+
let whisper: string | undefined;
|
|
339
|
+
async function whisperBin(): Promise<string> {
|
|
340
|
+
if (whisper !== undefined) return whisper;
|
|
341
|
+
whisper = (await runCli(["whisper", "--help"])) ? "whisper"
|
|
342
|
+
: (await runCli(["faster-whisper", "--help"])) ? "faster-whisper"
|
|
343
|
+
: "";
|
|
344
|
+
if (!whisper) console.warn(` whisper not on PATH — transcription skipped. Install: pip install -U openai-whisper`);
|
|
345
|
+
return whisper;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Transcribe audio or video with a local whisper binary. Video needs no demux step —
|
|
350
|
+
* whisper reads the audio track directly.
|
|
351
|
+
*
|
|
352
|
+
* Minutes of CPU per file, so this is the one extractor worth interrupting: `khb ingest
|
|
353
|
+
* --skip-audio` leaves the rows pending and everything else proceeds. Still local and
|
|
354
|
+
* still deterministic-enough to belong in khb rather than in an agent pass.
|
|
355
|
+
*/
|
|
356
|
+
export async function transcribeCached(path: string, hash: string, model = "base"): Promise<Extraction> {
|
|
357
|
+
const dest = extractedPath(hash);
|
|
358
|
+
if (existsSync(dest)) return cacheHit(dest);
|
|
359
|
+
|
|
360
|
+
const bin = await whisperBin();
|
|
361
|
+
if (!bin) return { status: "failed", reason: "whisper not installed" };
|
|
362
|
+
|
|
363
|
+
// whisper writes <name>.txt into --output_dir rather than to stdout; give it a scratch
|
|
364
|
+
// directory of its own so a stray sibling .txt never gets mistaken for the transcript.
|
|
365
|
+
const out = join(INBOX, "tmp", hash.slice(0, 12));
|
|
366
|
+
mkdirSync(out, { recursive: true });
|
|
367
|
+
const tool = `${bin} (${model})`;
|
|
368
|
+
try {
|
|
369
|
+
const proc = Bun.spawn([bin, path, "--model", model, "--output_format", "txt", "--output_dir", out], {
|
|
370
|
+
stdout: "ignore",
|
|
371
|
+
stderr: "ignore",
|
|
372
|
+
});
|
|
373
|
+
if ((await proc.exited) !== 0) return { status: "failed", reason: `${bin} exited non-zero` };
|
|
374
|
+
const txt = readdirSync(out).find((f) => f.endsWith(".txt"));
|
|
375
|
+
if (!txt) return { status: "failed", reason: `${bin} produced no transcript` };
|
|
376
|
+
const text = readFileSync(join(out, txt), "utf8").trim();
|
|
377
|
+
if (!text) return { status: "failed", reason: "empty transcript" };
|
|
378
|
+
return { status: "ok", path: writeCache(dest, path, tool, "low", text), tool, quality: "low" };
|
|
379
|
+
} catch (e) {
|
|
380
|
+
return { status: "failed", reason: `transcription failed: ${e}` };
|
|
381
|
+
} finally {
|
|
382
|
+
rmSync(out, { recursive: true, force: true });
|
|
383
|
+
}
|
|
384
|
+
}
|