Index-backed search, /api surfaces, image-only PDF detection

search now queries the Postgres index and states its freshness in every
result, with fresh=true bypassing it for a live crawl — the agent can
always get current data rather than being quietly misled by a stale
index. Adds refresh_index (per-course by default; a full crawl is ~270
requests), what_changed (generation diff — the API has no changed-since
filter of any kind), and index_status.

/api gives the CLI its backend behind the same bearer token as /mcp:
GET /manifest (cursor + per-file status), GET /files/:id (served from
the mirror with Range support, falling back to a live proxy for files
too large to mirror), GET /status, POST /refresh. Bytes go over plain
HTTP rather than MCP because base64 in JSON-RPC costs a third more and
buffers whole files. An unresolvable manifest cursor returns 409 rather
than silently meaning "everything is new", so a client cannot be tricked
into a full re-download.

Verified end to end against the live instance and a real Postgres:
crawl -> index -> German FTS -> manifest -> ranged download, with 401
on missing token, 400 on a malformed id, and 429 on a too-soon refresh.

Two findings worth recording. The build silently omitted the .sql
migrations from dist, which the store's graceful degradation turned into
"running without the index" rather than a crash — now copied by a build
step. And 3 of 4 sampled course PDFs have no embedded fonts at all: they
are scans, so extraction legitimately yields nothing. That is now
detected and reported as image-only with OCR named as the missing piece,
instead of an indistinguishable "0 characters". It revises the roadmap's
"OCR not needed" note, which held for reading images but not for
indexing them.

49 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 21:16:38 +02:00
parent a18b526267
commit c79f1b120d
13 changed files with 614 additions and 41 deletions

View File

@@ -57,7 +57,23 @@ export async function extractContent(
}
if (type === 'application/pdf' || ext === 'pdf') {
return finishText(await extractPdf(bytes), maxChars, 'PDF');
const extracted = await extractPdf(bytes);
// A PDF with no embedded fonts has no text layer: it is a scan or an
// exported image, and yielding "0 characters" would look like a parser
// failure. Say what it actually is, so the caller knows OCR — not a
// retry — is what is missing. Measured on this account: 3 of 4 sampled
// course PDFs are image-only, so this is the common case, not an edge.
if (!extracted.trim() && !hasTextLayer(bytes)) {
return {
kind: 'binary',
note:
`${fileName} is an image-only PDF (${formatBytes(bytes.length)}, no embedded fonts), so it ` +
`contains no extractable text. Its pages are pictures — OCR would be needed to index it. ` +
`Use download_file with raw=true to get the bytes.`,
truncated: false,
};
}
return finishText(extracted, maxChars, 'PDF');
}
if (type.includes('wordprocessingml') || ext === 'docx') {
@@ -110,6 +126,18 @@ function finishText(raw: string, maxChars: number, label: string): Extraction {
};
}
/**
* Whether a PDF embeds any font, i.e. has a real text layer.
*
* A crude scan of the raw bytes rather than a parse: font resources are
* declared as `/Font` in the object dictionaries, and their absence is a
* reliable signal that every page is imagery.
*/
function hasTextLayer(bytes: Buffer): boolean {
// Latin-1 keeps byte values intact, which is all the marker search needs.
return bytes.toString('latin1').includes('/Font');
}
async function extractPdf(bytes: Buffer): Promise<string> {
const { extractText, getDocumentProxy } = await import('unpdf');
const document = await getDocumentProxy(new Uint8Array(bytes));