@hraness/sponge 0.6.1 → 0.6.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/bin/sponge.js +4 -0
- package/dist/capture/worker.js +107 -107
- package/dist/src/app/article.d.ts +2 -0
- package/dist/src/app/article.js +72 -3
- package/dist/src/cli/commands.js +36 -2
- package/dist/src/cli/status.d.ts +26 -0
- package/dist/src/cli/status.js +63 -0
- package/dist/src/importers/apply.js +2 -1
- package/dist/src/pdf/handler.d.ts +22 -1
- package/dist/src/pdf/handler.js +47 -36
- package/dist/src/pdf/title.d.ts +3 -0
- package/dist/src/pdf/title.js +33 -4
- package/dist/src/search/hybrid.js +18 -13
- package/dist/src/search/lexical.d.ts +10 -1
- package/dist/src/search/lexical.js +34 -5
- package/native/runtime-manifest.json +5 -5
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ErrorEnvelope, JobCheckpoint } from '../../contracts/index.js';
|
|
2
2
|
import { type CaptureRequest, type CaptureOutcome, type CaptureConnection } from '../acquisition/index.js';
|
|
3
3
|
import type { LibraryStore, AttemptToken } from '../store/index.js';
|
|
4
|
+
import { type PdfCaptureRequest, type CaptureOutcome as PdfCaptureOutcome } from '../pdf/capture.js';
|
|
4
5
|
interface ArticleResult {
|
|
5
6
|
ok: true;
|
|
6
7
|
itemId: string;
|
|
@@ -17,6 +18,7 @@ export interface ArticleOptions {
|
|
|
17
18
|
signal?: AbortSignal;
|
|
18
19
|
allowPrivateNetwork?: boolean;
|
|
19
20
|
capture?: (request: CaptureRequest) => Promise<CaptureOutcome>;
|
|
21
|
+
capturePdf?: (request: PdfCaptureRequest) => Promise<PdfCaptureOutcome>;
|
|
20
22
|
}
|
|
21
23
|
/** One attempt. Durable queue policy and process recovery live above this function. */
|
|
22
24
|
export declare function ingestArticle(store: LibraryStore, token: AttemptToken, options: ArticleOptions): Promise<ArticleOutcome>;
|
package/dist/src/app/article.js
CHANGED
|
@@ -7,6 +7,8 @@ import { bundleBytes, bundleText } from './bundle-files.js';
|
|
|
7
7
|
import { selectedMetadata } from '../metadata/article.js';
|
|
8
8
|
import { localizeMarkdown } from './markdown.js';
|
|
9
9
|
import { SpongeError } from '../errors.js';
|
|
10
|
+
import { capturePdf } from '../pdf/capture.js';
|
|
11
|
+
import { storePdfCapture } from '../pdf/handler.js';
|
|
10
12
|
const classify = classifyErrors([
|
|
11
13
|
'integrity',
|
|
12
14
|
'limit-exceeded',
|
|
@@ -41,7 +43,45 @@ export function ingestArticle(store, token, options) {
|
|
|
41
43
|
validateCapture(capture);
|
|
42
44
|
if (capture.kind !== 'article' || capture.requestId !== token.attemptId || capture.sourceUrl !== source.locator)
|
|
43
45
|
throw new SpongeError('integrity');
|
|
44
|
-
const original = await bundleText(result.bundleRoot, capture.markdown)
|
|
46
|
+
const original = await bundleText(result.bundleRoot, capture.markdown);
|
|
47
|
+
const selectedTitle = selectedMetadata(capture.metadata, 'title')?.value;
|
|
48
|
+
const title = typeof selectedTitle === 'string' ? selectedTitle : undefined;
|
|
49
|
+
const paperUrl = proseWords(original) < landingPageWords ? fullTextPdf(capture.metadata, source.locator) : undefined;
|
|
50
|
+
if (paperUrl) {
|
|
51
|
+
const paper = await (options.capturePdf ?? capturePdf)({
|
|
52
|
+
runProcess: options.runProcess,
|
|
53
|
+
stagingRoot: join(store.root, 'staging'),
|
|
54
|
+
sourceUrl: paperUrl,
|
|
55
|
+
requestId: token.attemptId,
|
|
56
|
+
signal: options.signal,
|
|
57
|
+
allowPrivateNetwork: options.allowPrivateNetwork,
|
|
58
|
+
});
|
|
59
|
+
if (paper.ok) {
|
|
60
|
+
attempt.hold({ dispose: () => Promise.all([result.dispose(), paper.dispose()]).then(() => undefined) });
|
|
61
|
+
const pdf = await storePdfCapture(store, token, paper, paperUrl, options.signal);
|
|
62
|
+
const pageFields = new Set(capture.metadata.map((claim) => claim.field));
|
|
63
|
+
const bundleHash = sha256(JSON.stringify(['article-pdf-full-text-v1', capture.manifestSha256, pdf.capture.manifestSha256]));
|
|
64
|
+
await options.checkpoint?.('promoting');
|
|
65
|
+
const revision = store.revisions.commit({
|
|
66
|
+
...token,
|
|
67
|
+
itemId: source.itemId,
|
|
68
|
+
bundleHash,
|
|
69
|
+
markdown: pdf.markdown,
|
|
70
|
+
title: title ?? pdf.title,
|
|
71
|
+
extractor: pdf.capture.extractor,
|
|
72
|
+
status: pdf.status,
|
|
73
|
+
metadata: [...capture.metadata, ...pdf.capture.metadata.filter((claim) => !pageFields.has(claim.field))],
|
|
74
|
+
metadataWarnings: capture.metadataWarnings,
|
|
75
|
+
assets: pdf.assets,
|
|
76
|
+
warnings: pdf.warnings,
|
|
77
|
+
});
|
|
78
|
+
return { ok: true, itemId: source.itemId, revisionId: revision.revisionId, status: pdf.status };
|
|
79
|
+
}
|
|
80
|
+
// Without the paper, the landing page is still what the reader saved.
|
|
81
|
+
if (paper.code === 'cleanup-required')
|
|
82
|
+
attempt.cleanup = 'pending';
|
|
83
|
+
}
|
|
84
|
+
const localized = localizeMarkdown(original, capture.assets);
|
|
45
85
|
const assets = [];
|
|
46
86
|
for (const asset of capture.assets) {
|
|
47
87
|
if (options.signal?.aborted)
|
|
@@ -53,8 +93,6 @@ export function ingestArticle(store, token, options) {
|
|
|
53
93
|
store.queue.cancel(token.jobId);
|
|
54
94
|
throw new SpongeError('cancelled');
|
|
55
95
|
}
|
|
56
|
-
const selectedTitle = selectedMetadata(capture.metadata, 'title')?.value;
|
|
57
|
-
const title = typeof selectedTitle === 'string' ? selectedTitle : undefined;
|
|
58
96
|
const status = localized.missingImages ? 'partial' : capture.status;
|
|
59
97
|
// Representation algorithm participates in identity: later extraction changes cannot reuse an old payload.
|
|
60
98
|
const bundleHash = sha256(JSON.stringify(['article-local-markdown-v2', capture.manifestSha256]));
|
|
@@ -75,3 +113,34 @@ export function ingestArticle(store, token, options) {
|
|
|
75
113
|
return { ok: true, itemId: source.itemId, revisionId: revision.revisionId, status };
|
|
76
114
|
});
|
|
77
115
|
}
|
|
116
|
+
/** Prose words under which a page that names its paper's PDF is only a landing page: a title, authors and abstract. */
|
|
117
|
+
const landingPageWords = 1000;
|
|
118
|
+
function proseWords(markdown) {
|
|
119
|
+
const body = markdown.startsWith('---\n') ? markdown.slice(Math.max(0, markdown.indexOf('\n---', 4))) : markdown;
|
|
120
|
+
const prose = body.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ').replace(/\[([^\]]*)\]\([^)]*\)/g, '$1');
|
|
121
|
+
return (prose.match(/\p{L}[\p{L}\p{M}'’-]*/gu) ?? []).length;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The full text a scholarly landing page declares in `citation_pdf_url` (arXiv, NBER, OSF and most journals), when
|
|
125
|
+
* it is served from the page's own site or a subdomain relation of it; other sites are never fetched on its word.
|
|
126
|
+
*/
|
|
127
|
+
function fullTextPdf(metadata, pageUrl) {
|
|
128
|
+
const page = new URL(pageUrl).hostname;
|
|
129
|
+
for (const claim of metadata) {
|
|
130
|
+
if (claim.field !== 'alternate' || !claim.locator?.includes('citation_pdf_url'))
|
|
131
|
+
continue;
|
|
132
|
+
for (const value of Array.isArray(claim.value) ? claim.value : [claim.value]) {
|
|
133
|
+
let url;
|
|
134
|
+
try {
|
|
135
|
+
url = new URL(value);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const host = url.hostname;
|
|
141
|
+
if (url.protocol === 'https:' && (host === page || host.endsWith('.' + page) || page.endsWith('.' + host)))
|
|
142
|
+
return url.href;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
package/dist/src/cli/commands.js
CHANGED
|
@@ -8,6 +8,7 @@ import { queueAction } from './queue.js';
|
|
|
8
8
|
import { portabilityAction } from './portability.js';
|
|
9
9
|
import { restoreLibrary } from '../portability/backup.js';
|
|
10
10
|
import { workerAction, serveWorker, hasDueAcquisition, workerDoctor } from './worker.js';
|
|
11
|
+
import { libraryStatus, statusText } from './status.js';
|
|
11
12
|
import { nativeSetupPlan, provisionNative, inspectNative, optionalNative } from '../models/runtime.js';
|
|
12
13
|
import { digestModel, digestSetupPlan, inspectDigest, provisionDigest } from '../models/digest.js';
|
|
13
14
|
import { withDigestServer } from '../digest/server.js';
|
|
@@ -423,11 +424,44 @@ export const commands = [
|
|
|
423
424
|
states[kind] = publicCode(error, 'runtime-missing');
|
|
424
425
|
}
|
|
425
426
|
}
|
|
427
|
+
let digest;
|
|
428
|
+
try {
|
|
429
|
+
await inspectDigest(c.nativeRoot, store.digests.settings().model ?? undefined);
|
|
430
|
+
digest = 'ready';
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
digest = publicCode(error, 'runtime-missing');
|
|
434
|
+
}
|
|
426
435
|
const cleanup = store.queue.pendingCleanup(), worker = await workerDoctor(store, c.version), workerBroken = !['ready', 'not-installed', 'unsupported'].includes(worker.worker);
|
|
427
|
-
|
|
436
|
+
// Each runtime that is not ready names the command that installs it.
|
|
437
|
+
const line = (label, state, fix) => `${label}: ${state}${state === 'runtime-missing' ? ` (${fix})` : ''}`;
|
|
438
|
+
c.emit({ library: 'ready', capture, pdf, ...states, digest, pendingCleanup: cleanup, worker }, [
|
|
439
|
+
'Library: ready',
|
|
440
|
+
`Capture: ${capture}`,
|
|
441
|
+
`PDF: ${pdf === 'ready' ? 'ready' : 'Poppler not found (brew install poppler)'}`,
|
|
442
|
+
line('Media', states.media, 'sponge setup --capture'),
|
|
443
|
+
line('Speech-to-text', states.asr, 'sponge setup --asr'),
|
|
444
|
+
line('Embedding', states.embedding, 'sponge setup --embedding'),
|
|
445
|
+
line('Digest model', digest, 'sponge setup --digest'),
|
|
446
|
+
`Pending cleanup: ${cleanup}${cleanup ? ' (sponge queue recover)' : ''}`,
|
|
447
|
+
`Worker: ${worker.worker}${Array.isArray(worker.drift) ? ` (${worker.drift.join(', ')})` : ''}${worker.repair ? `\n ${worker.repair}` : ''}`,
|
|
448
|
+
].join('\n'));
|
|
428
449
|
return cleanup || workerBroken ? 4 : 0;
|
|
429
450
|
},
|
|
430
451
|
},
|
|
452
|
+
{
|
|
453
|
+
name: 'status',
|
|
454
|
+
summary: 'Overview of items, index, digests and queue',
|
|
455
|
+
usage: [['status', 'Overview of items, index, digests and queue']],
|
|
456
|
+
positional: 0,
|
|
457
|
+
flags: {},
|
|
458
|
+
library: 'open',
|
|
459
|
+
handler: async (c) => {
|
|
460
|
+
const status = libraryStatus(c.store), worker = (await workerDoctor(c.store, c.version)).worker;
|
|
461
|
+
c.emit({ ...status, worker }, statusText(status, worker));
|
|
462
|
+
return 0;
|
|
463
|
+
},
|
|
464
|
+
},
|
|
431
465
|
{
|
|
432
466
|
name: 'export',
|
|
433
467
|
summary: 'Export Markdown and local assets',
|
|
@@ -802,7 +836,7 @@ const row = ([synopsis, description]) => !description
|
|
|
802
836
|
const notes = `Global flags: --library /absolute/path --json --debug (or SPONGE_DEBUG=1: print error causes to stderr)
|
|
803
837
|
Items are public by default only when captured from a public source.
|
|
804
838
|
Use -- before an option-like search query. Capture setup is explicit.
|
|
805
|
-
Digest endpoints must be literal loopback HTTP (127.0.0.1 or [::1]);
|
|
839
|
+
Digest endpoints must be literal loopback HTTP (127.0.0.1 or [::1]); --draft starts Sponge's own server there.
|
|
806
840
|
Media: --language en enables local English ASR; --force-local bypasses captions.
|
|
807
841
|
These flags apply to --wait, run --drain, or worker serve.
|
|
808
842
|
worker install --language en saves the English ASR choice for background work.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { LibraryStore } from '../store/index.js';
|
|
2
|
+
/** What the library holds, what is waiting or failed, and what to run next: one screen for `sponge status`. */
|
|
3
|
+
export declare function libraryStatus(store: LibraryStore): {
|
|
4
|
+
items: {
|
|
5
|
+
total: number;
|
|
6
|
+
};
|
|
7
|
+
index: {
|
|
8
|
+
embedded: number;
|
|
9
|
+
expected: number;
|
|
10
|
+
pending: number;
|
|
11
|
+
} | null;
|
|
12
|
+
digests: {
|
|
13
|
+
auto: boolean;
|
|
14
|
+
accepted: number;
|
|
15
|
+
rejected: number;
|
|
16
|
+
draft: number;
|
|
17
|
+
none: number;
|
|
18
|
+
};
|
|
19
|
+
jobs: {
|
|
20
|
+
operation: string;
|
|
21
|
+
state: string;
|
|
22
|
+
reason: string | null;
|
|
23
|
+
}[];
|
|
24
|
+
pendingCleanup: number;
|
|
25
|
+
};
|
|
26
|
+
export declare function statusText(s: ReturnType<typeof libraryStatus>, worker: string): string;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const plural = (n, one, many = one + 's') => `${n} ${n === 1 ? one : many}`;
|
|
2
|
+
/** What the library holds, what is waiting or failed, and what to run next: one screen for `sponge status`. */
|
|
3
|
+
export function libraryStatus(store) {
|
|
4
|
+
const items = store.list(), kinds = {}, digests = { accepted: 0, rejected: 0, draft: 0, none: 0 };
|
|
5
|
+
for (const item of items) {
|
|
6
|
+
kinds[item.kind] = (kinds[item.kind] ?? 0) + 1;
|
|
7
|
+
const gist = store.derivations.current('digest', item.itemId)?.review.gist;
|
|
8
|
+
digests[gist === undefined ? 'none' : gist === 'accepted' || gist === 'rejected' ? gist : 'draft']++;
|
|
9
|
+
}
|
|
10
|
+
// Settled jobs are history; what is left is waiting, running or needs the reader.
|
|
11
|
+
// Only each target's newest job counts: a failure that a later capture of the item (or index job) fixed is history.
|
|
12
|
+
const newest = new Map();
|
|
13
|
+
for (const job of [...store.queue.list()].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at))))
|
|
14
|
+
newest.set(job.operation === 'index' ? 'index' : `${job.operation} ${job.item_id}`, job);
|
|
15
|
+
const jobs = [...newest.values()]
|
|
16
|
+
.filter((job) => !['succeeded', 'cancelled'].includes(String(job.state)))
|
|
17
|
+
.map((job) => ({
|
|
18
|
+
operation: String(job.operation),
|
|
19
|
+
state: String(job.state),
|
|
20
|
+
reason: job.reason ?? null,
|
|
21
|
+
}));
|
|
22
|
+
const { generation } = store.index.overview();
|
|
23
|
+
return {
|
|
24
|
+
items: { total: items.length, ...kinds },
|
|
25
|
+
index: generation
|
|
26
|
+
? { embedded: generation.completed, expected: generation.expected, pending: generation.pending }
|
|
27
|
+
: null,
|
|
28
|
+
digests: { ...digests, auto: store.digests.settings().auto },
|
|
29
|
+
jobs,
|
|
30
|
+
pendingCleanup: store.queue.pendingCleanup(),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function statusText(s, worker) {
|
|
34
|
+
const { total, ...kinds } = s.items, names = {
|
|
35
|
+
article: ['article', 'articles'],
|
|
36
|
+
pdf: ['PDF', 'PDFs'],
|
|
37
|
+
media: ['media item', 'media items'],
|
|
38
|
+
};
|
|
39
|
+
const counts = {};
|
|
40
|
+
for (const job of s.jobs) {
|
|
41
|
+
const key = `${job.operation} ${job.state}${job.reason ? ` (${job.reason})` : ''}`;
|
|
42
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
43
|
+
}
|
|
44
|
+
const failed = s.jobs.some((job) => !['queued', 'running'].includes(job.state));
|
|
45
|
+
const d = s.digests, drafted = d.accepted + d.rejected + d.draft;
|
|
46
|
+
return [
|
|
47
|
+
`Items: ${total}${total
|
|
48
|
+
? ` (${Object.entries(kinds)
|
|
49
|
+
.map(([kind, n]) => plural(n, ...(names[kind] ?? [kind, kind])))
|
|
50
|
+
.join(', ')})`
|
|
51
|
+
: ''}`,
|
|
52
|
+
`Search index: ${s.index ? `${s.index.embedded} of ${s.index.expected} chunks and images embedded${s.index.pending ? `; run sponge index run or let the worker` : ''}` : 'none; run sponge setup --embedding, then sponge index create'}`,
|
|
53
|
+
`Digests: ${drafted} of ${total} items (${d.accepted} accepted, ${d.rejected} rejected, ${d.draft} unreviewed); auto-digest ${d.auto ? 'on' : 'off'}`,
|
|
54
|
+
`Queue: ${s.jobs.length
|
|
55
|
+
? Object.entries(counts)
|
|
56
|
+
.map(([key, n]) => `${n} ${key}`)
|
|
57
|
+
.join(', ')
|
|
58
|
+
: 'nothing waiting'}`,
|
|
59
|
+
...(failed ? [' See sponge queue status; sponge queue retry JOB once the cause is fixed.'] : []),
|
|
60
|
+
...(s.pendingCleanup ? [`Pending cleanup: ${s.pendingCleanup}; run sponge queue recover`] : []),
|
|
61
|
+
`Worker: ${worker}`,
|
|
62
|
+
].join('\n');
|
|
63
|
+
}
|
|
@@ -3,6 +3,7 @@ import { runAttempt } from '../ingest/attempt.js';
|
|
|
3
3
|
import { bundleBytes, bundleText } from '../app/bundle-files.js';
|
|
4
4
|
import { localizeMarkdown } from '../app/markdown.js';
|
|
5
5
|
import { selectedMetadata } from '../metadata/article.js';
|
|
6
|
+
import { pdfTitle } from '../pdf/title.js';
|
|
6
7
|
import { drainQueue } from '../worker/engine.js';
|
|
7
8
|
import { readImportManifest, importBundleRoot } from './manifest.js';
|
|
8
9
|
import { inspectImportBundle, ownedBundleVerifier } from './inspect.js';
|
|
@@ -40,7 +41,7 @@ function promote(context, bundle, namespace, entry, sourceHash) {
|
|
|
40
41
|
if (signal.aborted)
|
|
41
42
|
throw new SpongeError('cancelled');
|
|
42
43
|
await checkpoint('promoting');
|
|
43
|
-
const title = selectedMetadata(bundle.metadata, 'title')?.value;
|
|
44
|
+
const title = bundle.kind === 'pdf' ? pdfTitle(bundle.metadata, markdown) : selectedMetadata(bundle.metadata, 'title')?.value;
|
|
44
45
|
store.revisions.commit({
|
|
45
46
|
...attempt,
|
|
46
47
|
itemId: source.itemId,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ErrorEnvelope, JobCheckpoint } from '../../contracts/index.js';
|
|
1
|
+
import type { CaptureResult, ErrorEnvelope, JobCheckpoint } from '../../contracts/index.js';
|
|
2
2
|
import { type CaptureConnection } from '../acquisition/index.js';
|
|
3
3
|
import { type PdfCaptureRequest as CaptureRequest, type CaptureOutcome } from './capture.js';
|
|
4
4
|
import type { LibraryStore, AttemptToken } from '../store/index.js';
|
|
@@ -21,4 +21,25 @@ export interface PdfOptions {
|
|
|
21
21
|
}
|
|
22
22
|
/** One attempt. Durable queue policy and process recovery live above this function. */
|
|
23
23
|
export declare function ingestPdf(store: LibraryStore, token: AttemptToken, options: PdfOptions): Promise<PdfOutcome>;
|
|
24
|
+
/**
|
|
25
|
+
* A verified PDF capture of `sourceUrl` in its stored form: blobs put (the source PDF among them), page markers as
|
|
26
|
+
* headings, images localized. The caller commits the revision.
|
|
27
|
+
*/
|
|
28
|
+
export declare function storePdfCapture(store: LibraryStore, token: AttemptToken, result: {
|
|
29
|
+
capture: CaptureResult;
|
|
30
|
+
bundleRoot: string;
|
|
31
|
+
}, sourceUrl: string, signal?: AbortSignal): Promise<{
|
|
32
|
+
capture: CaptureResult;
|
|
33
|
+
markdown: string;
|
|
34
|
+
assets: {
|
|
35
|
+
mediaType: string;
|
|
36
|
+
relationship: string;
|
|
37
|
+
sha256: string;
|
|
38
|
+
bytes: number;
|
|
39
|
+
relativePath: string;
|
|
40
|
+
}[];
|
|
41
|
+
title: string | undefined;
|
|
42
|
+
status: "partial" | "complete";
|
|
43
|
+
warnings: string[];
|
|
44
|
+
}>;
|
|
24
45
|
export {};
|
package/dist/src/pdf/handler.js
CHANGED
|
@@ -4,9 +4,8 @@ import { capturePdf } from './capture.js';
|
|
|
4
4
|
import { runAttempt, classifyErrors } from '../ingest/attempt.js';
|
|
5
5
|
import { sha256 } from '../util/hash.js';
|
|
6
6
|
import { bundleBytes, bundleText } from '../app/bundle-files.js';
|
|
7
|
-
import { selectedMetadata } from '../metadata/article.js';
|
|
8
7
|
import { localizeMarkdown } from '../app/markdown.js';
|
|
9
|
-
import {
|
|
8
|
+
import { pdfTitle } from './title.js';
|
|
10
9
|
import { SpongeError } from '../errors.js';
|
|
11
10
|
const classify = classifyErrors([
|
|
12
11
|
'integrity',
|
|
@@ -38,46 +37,58 @@ export function ingestPdf(store, token, options) {
|
|
|
38
37
|
}
|
|
39
38
|
attempt.hold(result);
|
|
40
39
|
await options.checkpoint?.('extracting');
|
|
41
|
-
const
|
|
42
|
-
validateCapture(capture);
|
|
43
|
-
if (capture.kind !== 'pdf' || capture.requestId !== token.attemptId || capture.sourceUrl !== source.locator)
|
|
44
|
-
throw new SpongeError('integrity');
|
|
45
|
-
const original = await bundleText(result.bundleRoot, capture.markdown);
|
|
46
|
-
const allAssets = [...capture.assets, capture.sourcePdf];
|
|
47
|
-
const localized = localizeMarkdown(original.replace(/^<!-- pdf-page: ([1-9][0-9]*) -->$/gm, '## Page $1'), allAssets);
|
|
48
|
-
const assets = [];
|
|
49
|
-
for (const asset of allAssets) {
|
|
50
|
-
if (options.signal?.aborted)
|
|
51
|
-
throw new SpongeError('cancelled');
|
|
52
|
-
const blob = await store.blobs.put(token, asset.sha256, asset.bytes, bundleBytes(result.bundleRoot, asset));
|
|
53
|
-
assets.push({
|
|
54
|
-
...blob,
|
|
55
|
-
mediaType: asset.mediaType,
|
|
56
|
-
relationship: asset === capture.sourcePdf ? 'source-pdf' : 'image',
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
if (options.signal?.aborted) {
|
|
60
|
-
store.queue.cancel(token.jobId);
|
|
61
|
-
throw new SpongeError('cancelled');
|
|
62
|
-
}
|
|
63
|
-
const selectedTitle = selectedMetadata(capture.metadata, 'title')?.value;
|
|
64
|
-
const title = typeof selectedTitle === 'string' ? selectedTitle : pdfHeadingTitle(original);
|
|
65
|
-
const status = localized.missingImages ? 'partial' : capture.status;
|
|
40
|
+
const pdf = await storePdfCapture(store, token, result, source.locator, options.signal);
|
|
66
41
|
// Representation algorithm participates in identity: later extraction changes cannot reuse an old payload.
|
|
67
|
-
const bundleHash = sha256(JSON.stringify(['pdf-local-markdown-v1', capture.manifestSha256]));
|
|
42
|
+
const bundleHash = sha256(JSON.stringify(['pdf-local-markdown-v1', pdf.capture.manifestSha256]));
|
|
68
43
|
await options.checkpoint?.('promoting');
|
|
69
44
|
const revision = store.revisions.commit({
|
|
70
45
|
...token,
|
|
71
46
|
itemId: source.itemId,
|
|
72
47
|
bundleHash,
|
|
73
|
-
markdown:
|
|
74
|
-
title,
|
|
75
|
-
extractor: capture.extractor,
|
|
76
|
-
status,
|
|
77
|
-
metadata: capture.metadata,
|
|
78
|
-
assets,
|
|
79
|
-
warnings:
|
|
48
|
+
markdown: pdf.markdown,
|
|
49
|
+
title: pdf.title,
|
|
50
|
+
extractor: pdf.capture.extractor,
|
|
51
|
+
status: pdf.status,
|
|
52
|
+
metadata: pdf.capture.metadata,
|
|
53
|
+
assets: pdf.assets,
|
|
54
|
+
warnings: pdf.warnings,
|
|
80
55
|
});
|
|
81
|
-
return { ok: true, itemId: source.itemId, revisionId: revision.revisionId, status };
|
|
56
|
+
return { ok: true, itemId: source.itemId, revisionId: revision.revisionId, status: pdf.status };
|
|
82
57
|
});
|
|
83
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* A verified PDF capture of `sourceUrl` in its stored form: blobs put (the source PDF among them), page markers as
|
|
61
|
+
* headings, images localized. The caller commits the revision.
|
|
62
|
+
*/
|
|
63
|
+
export async function storePdfCapture(store, token, result, sourceUrl, signal) {
|
|
64
|
+
const { capture } = result;
|
|
65
|
+
validateCapture(capture);
|
|
66
|
+
if (capture.kind !== 'pdf' || capture.requestId !== token.attemptId || capture.sourceUrl !== sourceUrl)
|
|
67
|
+
throw new SpongeError('integrity');
|
|
68
|
+
const original = await bundleText(result.bundleRoot, capture.markdown);
|
|
69
|
+
const allAssets = [...capture.assets, capture.sourcePdf];
|
|
70
|
+
const localized = localizeMarkdown(original.replace(/^<!-- pdf-page: ([1-9][0-9]*) -->$/gm, '## Page $1'), allAssets);
|
|
71
|
+
const assets = [];
|
|
72
|
+
for (const asset of allAssets) {
|
|
73
|
+
if (signal?.aborted)
|
|
74
|
+
throw new SpongeError('cancelled');
|
|
75
|
+
const blob = await store.blobs.put(token, asset.sha256, asset.bytes, bundleBytes(result.bundleRoot, asset));
|
|
76
|
+
assets.push({
|
|
77
|
+
...blob,
|
|
78
|
+
mediaType: asset.mediaType,
|
|
79
|
+
relationship: asset === capture.sourcePdf ? 'source-pdf' : 'image',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (signal?.aborted) {
|
|
83
|
+
store.queue.cancel(token.jobId);
|
|
84
|
+
throw new SpongeError('cancelled');
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
capture,
|
|
88
|
+
markdown: localized.markdown,
|
|
89
|
+
assets,
|
|
90
|
+
title: pdfTitle(capture.metadata, original),
|
|
91
|
+
status: localized.missingImages ? 'partial' : capture.status,
|
|
92
|
+
warnings: [...new Set([...capture.warnings, ...(localized.missingImages ? ['partial'] : [])])],
|
|
93
|
+
};
|
|
94
|
+
}
|
package/dist/src/pdf/title.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import type { MetadataClaim } from '../../contracts/index.js';
|
|
1
2
|
/**
|
|
2
3
|
* The first heading Wordcell detected on page 1, for a PDF whose metadata has no title.
|
|
3
4
|
* Wordcell writes body headings at level 2 or deeper and escapes Markdown punctuation in them.
|
|
4
5
|
*/
|
|
5
6
|
export declare function pdfHeadingTitle(markdown: string): string | undefined;
|
|
7
|
+
/** A PDF's captured title: its metadata title, else the first page-1 heading, else the file name its bundle records. */
|
|
8
|
+
export declare function pdfTitle(metadata: readonly MetadataClaim[], markdown: string): string | undefined;
|
package/dist/src/pdf/title.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { selectedMetadata } from '../metadata/article.js';
|
|
1
2
|
// Section names that open many papers but never name one.
|
|
2
3
|
const generic = /^(?:abstract|contents|table of contents|introduction|preface|foreword|acknowledge?ments?|summary|executive summary|references)$/i;
|
|
3
4
|
/**
|
|
@@ -13,11 +14,21 @@ export function pdfHeadingTitle(markdown) {
|
|
|
13
14
|
.replace(/\\([\\`*_[\]{}<>#|])/g, '$1')
|
|
14
15
|
.replace(/\s+/g, ' ')
|
|
15
16
|
.trim());
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
// A lowercase first word with no capital in it: rStar and iPhone open titles, "with" and "reproduce" open prose.
|
|
18
|
+
const lowercase = (text = '') => /^\p{Ll}/u.test(text) && !/\p{Lu}/u.test(text.split(' ')[0]);
|
|
19
|
+
for (let index = 0; index < headings.length; index++) {
|
|
20
|
+
let title = headings[index];
|
|
21
|
+
// Text Wordcell read from an image.
|
|
22
|
+
if (/^Text visible in (?:.+ )?image$/.test(title) || lowercase(title))
|
|
20
23
|
continue;
|
|
24
|
+
if (lowercase(headings[index + 1])) {
|
|
25
|
+
// Prose wrapped across headings, such as a copyright notice, breaks after a function word or a comma.
|
|
26
|
+
if (/(?:,|\b(?:a|an|and|by|for|from|in|of|on|or|the|to|with))$/i.test(title))
|
|
27
|
+
continue;
|
|
28
|
+
// A title wrapped across two headings continues in lowercase.
|
|
29
|
+
while (lowercase(headings[index + 1]))
|
|
30
|
+
title += ' ' + headings[++index];
|
|
31
|
+
}
|
|
21
32
|
if (title.length < 3 || title.length > 300)
|
|
22
33
|
continue;
|
|
23
34
|
// Body sections have begun, so the title was never detected as a heading.
|
|
@@ -26,3 +37,21 @@ export function pdfHeadingTitle(markdown) {
|
|
|
26
37
|
return title;
|
|
27
38
|
}
|
|
28
39
|
}
|
|
40
|
+
/** The bundle's own frontmatter title: Sponge and Wordcell both write the source file name there. */
|
|
41
|
+
function frontmatterTitle(markdown) {
|
|
42
|
+
const quoted = /^---\n(?:.*\n)*?title: (".*")\n(?:.*\n)*?---\n/.exec(markdown)?.[1];
|
|
43
|
+
try {
|
|
44
|
+
const title = quoted === undefined ? undefined : JSON.parse(quoted);
|
|
45
|
+
return typeof title === 'string' && title.trim() ? title.trim() : undefined;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** A PDF's captured title: its metadata title, else the first page-1 heading, else the file name its bundle records. */
|
|
52
|
+
export function pdfTitle(metadata, markdown) {
|
|
53
|
+
const selected = selectedMetadata(metadata, 'title')?.value;
|
|
54
|
+
if (typeof selected === 'string' && selected.trim())
|
|
55
|
+
return selected;
|
|
56
|
+
return pdfHeadingTitle(markdown) ?? frontmatterTitle(markdown);
|
|
57
|
+
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { chunkDocument } from '../indexing/index.js';
|
|
2
|
-
import {
|
|
2
|
+
import { anyWordMatch, lexicalChannel, phraseMatch } from './lexical.js';
|
|
3
3
|
import { pinSearchGeneration, vectorSearch } from './vector.js';
|
|
4
4
|
import { SpongeError } from '../errors.js';
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Weighted reciprocal rank fusion after per-channel item collapsing; chunks cannot amplify rank. The any-word channel
|
|
7
|
+
* counts a quarter: at full weight its loose matches outranked exact phrases and close meanings (scripts/eval/search.ts).
|
|
8
|
+
*/
|
|
9
|
+
function fuse(channels, limit) {
|
|
7
10
|
const hits = new Map();
|
|
8
|
-
for (const
|
|
9
|
-
['lexical', lexicalHits],
|
|
10
|
-
['semantic', semanticHits],
|
|
11
|
-
]) {
|
|
11
|
+
for (const { method, rows, weight } of channels) {
|
|
12
12
|
const seen = new Set();
|
|
13
13
|
let rank = 0;
|
|
14
14
|
for (const row of rows) {
|
|
@@ -26,8 +26,9 @@ function fuse(lexicalHits, semanticHits, limit) {
|
|
|
26
26
|
}
|
|
27
27
|
: { kind: 'document' };
|
|
28
28
|
if (previous) {
|
|
29
|
-
previous.score +=
|
|
30
|
-
previous.methods.
|
|
29
|
+
previous.score += weight / (60 + rank);
|
|
30
|
+
if (!previous.methods.includes(method))
|
|
31
|
+
previous.methods.push(method);
|
|
31
32
|
if (vector)
|
|
32
33
|
previous.anchor = anchor;
|
|
33
34
|
}
|
|
@@ -36,7 +37,7 @@ function fuse(lexicalHits, semanticHits, limit) {
|
|
|
36
37
|
itemId: row.itemId,
|
|
37
38
|
revisionId: row.revisionId,
|
|
38
39
|
title: row.title,
|
|
39
|
-
score:
|
|
40
|
+
score: weight / (60 + rank),
|
|
40
41
|
methods: [method],
|
|
41
42
|
anchor,
|
|
42
43
|
});
|
|
@@ -84,7 +85,7 @@ function projectAnchors(db, hits) {
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
export async function hybridSearch(db, query, options = {}) {
|
|
87
|
-
const limit = options.limit ?? 20, timeout = options.semanticTimeoutMs ?? 10000;
|
|
88
|
+
const limit = options.limit ?? 20, depth = Math.min(500, limit * 5), timeout = options.semanticTimeoutMs ?? 10000;
|
|
88
89
|
if (!query.trim() ||
|
|
89
90
|
Buffer.byteLength(query) > 4096 ||
|
|
90
91
|
!Number.isSafeInteger(limit) ||
|
|
@@ -130,14 +131,18 @@ export async function hybridSearch(db, query, options = {}) {
|
|
|
130
131
|
let semantic = [];
|
|
131
132
|
if (pin && encoded) {
|
|
132
133
|
try {
|
|
133
|
-
semantic = vectorSearch(db, pin, encoded,
|
|
134
|
+
semantic = vectorSearch(db, pin, encoded, depth);
|
|
134
135
|
}
|
|
135
136
|
catch {
|
|
136
137
|
warnings.push('semantic-unavailable');
|
|
137
138
|
}
|
|
138
139
|
}
|
|
139
140
|
const result = {
|
|
140
|
-
hits: fuse(
|
|
141
|
+
hits: fuse([
|
|
142
|
+
{ method: 'lexical', rows: lexicalChannel(db, phraseMatch(query), depth), weight: 1 },
|
|
143
|
+
{ method: 'lexical', rows: lexicalChannel(db, anyWordMatch(query), depth), weight: 0.25 },
|
|
144
|
+
{ method: 'semantic', rows: semantic, weight: 1 },
|
|
145
|
+
], limit),
|
|
141
146
|
warnings,
|
|
142
147
|
generation: pin,
|
|
143
148
|
};
|
|
@@ -7,5 +7,14 @@ export interface LexicalHit {
|
|
|
7
7
|
/** Contentless FTS rows are keyed by readable_fts_rows.id; items.rowid is not VACUUM-stable. Callers own the transaction. */
|
|
8
8
|
export declare function indexReadable(db: DatabaseSync, itemId: string, revisionId: string, title: string, markdown: string): void;
|
|
9
9
|
export declare function unindexReadable(db: DatabaseSync, itemId: string): void;
|
|
10
|
-
/**
|
|
10
|
+
/** The query as one literal phrase; surrounding double quotes are the user's way of asking for exactly that. */
|
|
11
|
+
export declare function phraseMatch(query: string): string;
|
|
12
|
+
/** Any content word of the query; undefined for a quoted query or one of function words only. */
|
|
13
|
+
export declare function anyWordMatch(query: string): string | undefined;
|
|
14
|
+
/** bm25 with title matches weighted five times body, then item id; excludes tombstones and superseded revisions. */
|
|
15
|
+
export declare function lexicalChannel(db: DatabaseSync, expression: string | undefined, limit: number): LexicalHit[];
|
|
16
|
+
/**
|
|
17
|
+
* Items with the exact phrase first, then items with any of its content words. The phrase alone missed most vague
|
|
18
|
+
* queries, and any word alone buried the precise matches (scripts/eval/search.ts).
|
|
19
|
+
*/
|
|
11
20
|
export declare function lexicalSearch(db: DatabaseSync, query: string, limit: number): LexicalHit[];
|
|
@@ -11,10 +11,39 @@ export function unindexReadable(db, itemId) {
|
|
|
11
11
|
if (row)
|
|
12
12
|
db.prepare('DELETE FROM readable_fts WHERE rowid=?').run(row.id);
|
|
13
13
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
// Function words match nearly every item, so they never decide whether one matches.
|
|
15
|
+
const stopwords = new Set('a an and are as at be but by can do does for from had has have how i if in into is it its me my no not of on or our so than that the their them then there these they this to too us was we were what when where which who why will with you your'.split(' '));
|
|
16
|
+
const literal = (text) => '"' + text.replaceAll('"', '""') + '"';
|
|
17
|
+
/** The query as one literal phrase; surrounding double quotes are the user's way of asking for exactly that. */
|
|
18
|
+
export function phraseMatch(query) {
|
|
19
|
+
const trimmed = query.trim();
|
|
20
|
+
return literal(/^"[^"]+"$/.test(trimmed) ? trimmed.slice(1, -1) : trimmed);
|
|
21
|
+
}
|
|
22
|
+
/** Any content word of the query; undefined for a quoted query or one of function words only. */
|
|
23
|
+
export function anyWordMatch(query) {
|
|
24
|
+
const trimmed = query.trim();
|
|
25
|
+
if (/^"[^"]+"$/.test(trimmed))
|
|
26
|
+
return undefined;
|
|
27
|
+
const words = [...new Set(trimmed.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [])].filter((w) => !stopwords.has(w));
|
|
28
|
+
return words.length ? words.map(literal).join(' OR ') : undefined;
|
|
29
|
+
}
|
|
30
|
+
/** bm25 with title matches weighted five times body, then item id; excludes tombstones and superseded revisions. */
|
|
31
|
+
export function lexicalChannel(db, expression, limit) {
|
|
32
|
+
if (expression === undefined)
|
|
33
|
+
return [];
|
|
17
34
|
return db
|
|
18
|
-
.prepare('SELECT i.id AS itemId,i.title,i.current_revision AS revisionId FROM readable_fts f JOIN readable_fts_rows m ON m.id=f.rowid JOIN items i ON i.id=m.item_id WHERE readable_fts MATCH ? AND i.deleted_at IS NULL AND i.current_revision=m.revision_id ORDER BY
|
|
19
|
-
.all(
|
|
35
|
+
.prepare('SELECT i.id AS itemId,i.title,i.current_revision AS revisionId FROM readable_fts f JOIN readable_fts_rows m ON m.id=f.rowid JOIN items i ON i.id=m.item_id WHERE readable_fts MATCH ? AND i.deleted_at IS NULL AND i.current_revision=m.revision_id ORDER BY bm25(readable_fts,5.0,1.0),i.id LIMIT ?')
|
|
36
|
+
.all(expression, limit);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Items with the exact phrase first, then items with any of its content words. The phrase alone missed most vague
|
|
40
|
+
* queries, and any word alone buried the precise matches (scripts/eval/search.ts).
|
|
41
|
+
*/
|
|
42
|
+
export function lexicalSearch(db, query, limit) {
|
|
43
|
+
const hits = new Map();
|
|
44
|
+
for (const expression of [phraseMatch(query), anyWordMatch(query)])
|
|
45
|
+
for (const hit of lexicalChannel(db, expression, limit))
|
|
46
|
+
if (!hits.has(hit.itemId))
|
|
47
|
+
hits.set(hit.itemId, hit);
|
|
48
|
+
return [...hits.values()].slice(0, limit);
|
|
20
49
|
}
|
|
@@ -33,16 +33,16 @@
|
|
|
33
33
|
}
|
|
34
34
|
],
|
|
35
35
|
"build": {
|
|
36
|
-
"asrSourceSha256": "
|
|
36
|
+
"asrSourceSha256": "a094bd8c3363c466f2c17c8822f6780d2db04f2a6ebcacebf9a0b4d5ca3cf940",
|
|
37
37
|
"clangVersion": "Apple clang version 21.0.0 (clang-2100.1.1.101)",
|
|
38
|
-
"embeddingSourceSha256": "
|
|
39
|
-
"mediaSourceSha256": "
|
|
38
|
+
"embeddingSourceSha256": "c3340bff6e1c2d3e7733c6e2491e272202644d138f01ac528a7679909ce26e3b",
|
|
39
|
+
"mediaSourceSha256": "297f87728d318d62852a6f0f8f8b20d4fa65ffb0486761fd22e48728e7ae84b2",
|
|
40
40
|
"rustcVersion": "rustc 1.97.1 (8bab26f4f 2026-07-14)",
|
|
41
41
|
"swiftVersion": "Apple Swift version 6.3.3 (swiftlang-6.3.3.1.3 clang-2100.1.1.101)"
|
|
42
42
|
},
|
|
43
43
|
"minimumMacOS": "26.0",
|
|
44
|
-
"packageVersion": "0.6.
|
|
44
|
+
"packageVersion": "0.6.3",
|
|
45
45
|
"platform": "darwin",
|
|
46
46
|
"schemaVersion": 1,
|
|
47
|
-
"sourceCommit": "
|
|
47
|
+
"sourceCommit": "f0db55223b258afe0b29cca01e489a70d10bca82"
|
|
48
48
|
}
|