@hraness/sponge 0.2.3 → 0.2.5
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/README.md +53 -0
- package/dist/migrations/005.d.ts +3 -0
- package/dist/migrations/005.js +19 -0
- package/dist/migrations/006.d.ts +3 -0
- package/dist/migrations/006.js +9 -0
- package/dist/migrations/007.d.ts +3 -0
- package/dist/migrations/007.js +10 -0
- package/dist/src/acquisition/lock.d.ts +1 -1
- package/dist/src/acquisition/lock.js +1 -1
- package/dist/src/acquisition/pins.js +3 -3
- package/dist/src/annotations/quote.d.ts +32 -0
- package/dist/src/annotations/quote.js +135 -0
- package/dist/src/annotations/store.d.ts +50 -0
- package/dist/src/annotations/store.js +89 -0
- package/dist/src/app/source.d.ts +2 -0
- package/dist/src/app/source.js +13 -17
- package/dist/src/cli/arguments.js +24 -7
- package/dist/src/cli/digest.d.ts +10 -0
- package/dist/src/cli/digest.js +63 -0
- package/dist/src/cli/import.d.ts +11 -0
- package/dist/src/cli/import.js +10 -0
- package/dist/src/cli/indexing.d.ts +3 -0
- package/dist/src/cli/indexing.js +5 -2
- package/dist/src/cli/main.js +94 -8
- package/dist/src/cli/runtimes.d.ts +10 -0
- package/dist/src/cli/runtimes.js +32 -0
- package/dist/src/digest/editorial.d.ts +53 -0
- package/dist/src/digest/editorial.js +81 -0
- package/dist/src/digest/endpoint.d.ts +20 -0
- package/dist/src/digest/endpoint.js +103 -0
- package/dist/src/digest/pipeline.d.ts +148 -0
- package/dist/src/digest/pipeline.js +410 -0
- package/dist/src/digest/schema.d.ts +85 -0
- package/dist/src/digest/schema.js +118 -0
- package/dist/src/digest/store.d.ts +57 -0
- package/dist/src/digest/store.js +77 -0
- package/dist/src/identity/query-policy.d.ts +21 -0
- package/dist/src/identity/query-policy.js +55 -0
- package/dist/src/identity/uri.d.ts +30 -0
- package/dist/src/identity/uri.js +94 -0
- package/dist/src/identity/visibility.d.ts +24 -0
- package/dist/src/identity/visibility.js +75 -0
- package/dist/src/importers/apply.d.ts +10 -0
- package/dist/src/importers/apply.js +25 -22
- package/dist/src/importers/jungle-digest.d.ts +97 -0
- package/dist/src/importers/jungle-digest.js +221 -0
- package/dist/src/importers/jungle-store.d.ts +45 -0
- package/dist/src/importers/jungle-store.js +95 -0
- package/dist/src/importers/jungle.d.ts +54 -0
- package/dist/src/importers/jungle.js +134 -0
- package/dist/src/indexing/index.d.ts +7 -0
- package/dist/src/indexing/index.js +31 -8
- package/dist/src/indexing/prune-run.d.ts +22 -0
- package/dist/src/indexing/prune-run.js +67 -0
- package/dist/src/indexing/prune.d.ts +30 -0
- package/dist/src/indexing/prune.js +95 -0
- package/dist/src/indexing/runner.js +5 -5
- package/dist/src/inference/embedding/index.d.ts +25 -0
- package/dist/src/inference/embedding/index.js +11 -0
- package/dist/src/models/artifacts.js +175 -33
- package/dist/src/portability/database.js +3 -0
- package/dist/src/publish/component.d.ts +8 -0
- package/dist/src/publish/component.js +114 -0
- package/dist/src/publish/feed.d.ts +24 -0
- package/dist/src/publish/feed.js +77 -0
- package/dist/src/publish/identity.d.ts +6 -0
- package/dist/src/publish/identity.js +68 -0
- package/dist/src/publish/index.d.ts +34 -0
- package/dist/src/publish/index.js +137 -0
- package/dist/src/publish/projection.d.ts +29 -0
- package/dist/src/publish/projection.js +43 -0
- package/dist/src/search/hybrid.js +2 -2
- package/dist/src/search/lexical.d.ts +11 -0
- package/dist/src/search/lexical.js +6 -0
- package/dist/src/search/query.d.ts +7 -3
- package/dist/src/search/query.js +52 -33
- package/dist/src/search/vector.d.ts +1 -1
- package/dist/src/search/vector.js +6 -5
- package/dist/src/store/changes.d.ts +42 -0
- package/dist/src/store/changes.js +178 -0
- package/dist/src/store/foundation.d.ts +7 -0
- package/dist/src/store/foundation.js +7 -0
- package/dist/src/store/index.d.ts +69 -0
- package/dist/src/store/index.js +134 -51
- package/dist/src/worker/runtime-gc.d.ts +30 -0
- package/dist/src/worker/runtime-gc.js +296 -0
- package/native/runtime-manifest.json +4 -4
- package/package.json +1 -1
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { foldText, locateAll, locateLoose, words } from '../annotations/quote.js';
|
|
3
|
+
import { checkDigest, digestLimits as L, forms, ideaKinds, parseDigest } from './schema.js';
|
|
4
|
+
import { StoreError } from '../store/types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Model-agnostic Digest v1 drafting. The model sees numbered passages and cites a passage id plus a short exact
|
|
7
|
+
* span; code snaps, orders, prunes and checks. Nothing leaves this module except through the injected model.
|
|
8
|
+
*/
|
|
9
|
+
export const PROMPT_VERSION = 'sponge-digest-prompt-v1';
|
|
10
|
+
/** 24k characters is roughly 6k tokens of passages, leaving room for instructions and output in an 8k context on an 8 GB Mac. */
|
|
11
|
+
export const defaultDigestParams = { temperature: 0, seed: 1, maxTokens: 2048, contextChars: 24000, maxSections: 24, timeoutMs: 300000, deadlineMs: 1800000, maxOutputBytes: 65536 };
|
|
12
|
+
export class DigestError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
issues;
|
|
15
|
+
constructor(code, issues) {
|
|
16
|
+
super(code);
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.issues = issues;
|
|
19
|
+
this.name = 'DigestError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Quotes under this many words carry no evidence; the checker is gaining the same floor. */
|
|
23
|
+
export const minQuoteWords = 4;
|
|
24
|
+
const sha256 = (v) => createHash('sha256').update(v).digest('hex');
|
|
25
|
+
const collapse = (v) => v.replace(/\s+/g, ' ').trim();
|
|
26
|
+
const object = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
27
|
+
/** Sorted-key JSON; the only serialization that enters input hashes. */
|
|
28
|
+
export function canonicalJson(value) {
|
|
29
|
+
if (Array.isArray(value))
|
|
30
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
31
|
+
if (object(value))
|
|
32
|
+
return `{${Object.keys(value).filter(k => value[k] !== undefined).sort().map(k => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
|
|
33
|
+
if (typeof value === 'number' && !Number.isFinite(value))
|
|
34
|
+
throw new DigestError('invalid-input');
|
|
35
|
+
return JSON.stringify(value ?? null);
|
|
36
|
+
}
|
|
37
|
+
/** Paragraph-level passages with raw offsets. Headings label passages; code fences and image-only blocks are not quotable. */
|
|
38
|
+
export function passages(markdown, maxChars = 1200) {
|
|
39
|
+
const out = [], blocks = [];
|
|
40
|
+
let at = 0, start = -1, fence = false, fenced = false;
|
|
41
|
+
for (const line of markdown.split('\n')) {
|
|
42
|
+
const end = at + line.length, blank = !line.trim(), marker = /^\s{0,3}(```|~~~)/.test(line);
|
|
43
|
+
if (marker)
|
|
44
|
+
fence = !fence;
|
|
45
|
+
if (blank && !fence && !marker) {
|
|
46
|
+
if (start >= 0)
|
|
47
|
+
blocks.push({ start, end: at - 1, fence: fenced });
|
|
48
|
+
start = -1;
|
|
49
|
+
fenced = false;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
if (start < 0)
|
|
53
|
+
start = at;
|
|
54
|
+
if (marker || fence)
|
|
55
|
+
fenced = true;
|
|
56
|
+
}
|
|
57
|
+
at = end + 1;
|
|
58
|
+
}
|
|
59
|
+
if (start >= 0)
|
|
60
|
+
blocks.push({ start, end: markdown.length, fence: fenced });
|
|
61
|
+
let heading = '';
|
|
62
|
+
for (const b of blocks) {
|
|
63
|
+
const raw = markdown.slice(b.start, b.end).trimEnd(), end = b.start + raw.length;
|
|
64
|
+
if (b.fence || !raw.trim())
|
|
65
|
+
continue;
|
|
66
|
+
const h = /^\s{0,3}#{1,6}\s+(.+)$/.exec(raw);
|
|
67
|
+
if (h && !raw.includes('\n')) {
|
|
68
|
+
heading = collapse(h[1]).slice(0, 200);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (/^(\s*!\[[^\]]*\]\([^)]*\)\s*)+$/.test(raw))
|
|
72
|
+
continue;
|
|
73
|
+
// Long blocks split at sentence ends, then at whitespace, so every passage fits a prompt line.
|
|
74
|
+
let from = b.start;
|
|
75
|
+
while (end - from > maxChars) {
|
|
76
|
+
const window = markdown.slice(from, from + maxChars), sentence = lastSentenceEnd(window), space = window.lastIndexOf(' ');
|
|
77
|
+
const stop = from + (sentence > maxChars / 4 ? sentence : space > maxChars / 4 ? space : maxChars);
|
|
78
|
+
out.push({ id: out.length + 1, start: from, end: stop, heading, text: markdown.slice(from, stop) });
|
|
79
|
+
from = stop;
|
|
80
|
+
while (from < end && /\s/.test(markdown[from]))
|
|
81
|
+
from++;
|
|
82
|
+
}
|
|
83
|
+
if (end > from)
|
|
84
|
+
out.push({ id: out.length + 1, start: from, end, heading, text: markdown.slice(from, end) });
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
function lastSentenceEnd(window) { let best = -1; for (const m of window.matchAll(/[.!?]["')\]]*(?=\s)/g))
|
|
89
|
+
best = m.index + m[0].length; return best; }
|
|
90
|
+
const display = (p) => `[${p.id}] ${collapse(p.text)}`;
|
|
91
|
+
/** Pack passages into sections of at most `budget` display characters, preferring heading boundaries past half a budget. */
|
|
92
|
+
export function sectionsOf(list, budget) {
|
|
93
|
+
const out = [];
|
|
94
|
+
let current = [], size = 0;
|
|
95
|
+
for (const p of list) {
|
|
96
|
+
const n = display(p).length + 1;
|
|
97
|
+
if (current.length && (size + n > budget || p.heading !== current[current.length - 1].heading && size > budget / 2)) {
|
|
98
|
+
out.push(current);
|
|
99
|
+
current = [];
|
|
100
|
+
size = 0;
|
|
101
|
+
}
|
|
102
|
+
current.push(p);
|
|
103
|
+
size += n;
|
|
104
|
+
}
|
|
105
|
+
if (current.length)
|
|
106
|
+
out.push(current);
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
const sourceWords = (markdown) => words(markdown);
|
|
110
|
+
export const quoteBudget = (markdown) => Math.min(L.totalQuoteWords, Math.max(L.quoteWords, Math.floor(sourceWords(markdown) * L.sourceQuoteShare)));
|
|
111
|
+
const str = (maxLength) => ({ type: 'string', minLength: 1, maxLength });
|
|
112
|
+
/** JSON Schema for the model's draft. Tags become an enum when a vocabulary is given. */
|
|
113
|
+
export function draftSchema(passageCount, vocabulary) {
|
|
114
|
+
const quote = { type: 'object', additionalProperties: false, required: ['passage', 'text'], properties: { passage: { type: 'integer', minimum: 1, maximum: Math.max(1, passageCount) }, text: str(300), speaker: str(120) } };
|
|
115
|
+
const idea = { type: 'object', additionalProperties: false, required: ['claim', 'detail', 'kind', 'voice', 'quotes'], properties: { claim: str(200), detail: str(600), kind: { type: 'string', enum: [...ideaKinds] }, voice: { type: 'object', additionalProperties: false, required: ['type'], properties: { type: { type: 'string', enum: ['author', 'speaker', 'reported'] }, name: str(120) } }, quotes: { type: 'array', minItems: 1, maxItems: L.maxQuotesPerIdea, items: quote } } };
|
|
116
|
+
const tag = vocabulary?.length ? { type: 'string', enum: [...vocabulary] } : { type: 'string', pattern: '^[a-z0-9][a-z0-9-]{0,63}$' };
|
|
117
|
+
return { type: 'object', additionalProperties: false, required: ['lead', 'ideas', 'tags'], properties: { lead: str(L.leadChars), context: str(400), ideas: { type: 'array', minItems: L.minIdeas, maxItems: L.maxIdeas, items: idea }, tags: { type: 'array', minItems: 1, maxItems: L.maxTags, items: tag } } };
|
|
118
|
+
}
|
|
119
|
+
export function notesSchema(first, last) {
|
|
120
|
+
return { type: 'object', additionalProperties: false, required: ['notes'], properties: { notes: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'object', additionalProperties: false, required: ['point', 'passage', 'quote'], properties: { point: str(300), passage: { type: 'integer', minimum: first, maximum: last }, quote: str(300) } } } } };
|
|
121
|
+
}
|
|
122
|
+
const system = (budget, vocabulary) => `You write a Digest v1 of one source for a private reading library. Reply with JSON only, matching the schema.
|
|
123
|
+
- lead: one sentence of at most ${L.leadChars} characters that names the author or organization when known and states the main finding.
|
|
124
|
+
- context: optional, at most ${L.contextWords} words of essential background.
|
|
125
|
+
- ideas: ${L.minIdeas} to ${L.maxIdeas}, in the order they appear in the source. Each idea has:
|
|
126
|
+
- claim: one sentence of at most ${L.claimWords} words that asserts something; never a topic label.
|
|
127
|
+
- detail: at most ${L.detailWords} words in at most ${L.detailSentences} sentences: evidence, mechanism or limits.
|
|
128
|
+
- kind: ${ideaKinds.join(', ')}.
|
|
129
|
+
- voice: "author" when the author makes the claim; "speaker" with name for a speaker in a talk or interview; "reported" with name when the author reports someone else's claim.
|
|
130
|
+
- quotes: 1 or ${L.maxQuotesPerIdea}, each copied character for character from one numbered passage, ${minQuoteWords} to ${L.quoteWords} words, with that passage number. No added quotation marks or ellipses. Give speaker when the voice is not the author. Prefer reader highlights.
|
|
131
|
+
- All quotes together stay within ${budget} words and never overlap.
|
|
132
|
+
- tags: 1 to ${L.maxTags} ${vocabulary?.length ? `from: ${vocabulary.join(', ')}` : 'lowercase kebab-case topics'}.
|
|
133
|
+
Use only what the source says.`;
|
|
134
|
+
function sourceBlock(title, form, list, highlights) {
|
|
135
|
+
let heading;
|
|
136
|
+
const lines = [];
|
|
137
|
+
for (const p of list) {
|
|
138
|
+
if (p.heading !== heading) {
|
|
139
|
+
heading = p.heading;
|
|
140
|
+
if (heading)
|
|
141
|
+
lines.push(`## ${heading}`);
|
|
142
|
+
}
|
|
143
|
+
lines.push(display(p));
|
|
144
|
+
}
|
|
145
|
+
const marks = highlights.map((h, i) => `[h${i + 1}${h.passage ? `, passage ${h.passage}` : ''}] ${collapse(h.text).slice(0, 400)}`);
|
|
146
|
+
return `Form: ${form}${title ? `\nTitle: ${collapse(title).slice(0, 300)}` : ''}${marks.length ? `\n\nReader highlights (preferred quotes):\n${marks.join('\n')}` : ''}\n\nPassages:\n${lines.join('\n')}`;
|
|
147
|
+
}
|
|
148
|
+
const rule = { lead: `one sentence, at most ${L.leadChars} characters`, context: `at most ${L.contextWords} words`, claim: `one distinct sentence, at most ${L.claimWords} words`, detail: `at most ${L.detailWords} words and ${L.detailSentences} sentences`, quotes: `1-${L.maxQuotesPerIdea} quotes, each copied exactly from its passage, ${minQuoteWords} to ${L.quoteWords} words, not overlapping another quote`, ideas: `${L.minIdeas}-${L.maxIdeas} ideas; quotes within the total budget`, tags: `1-${L.maxTags} distinct allowed tags` };
|
|
149
|
+
function repairPrompt(issues) {
|
|
150
|
+
const lines = issues.slice(0, 40).map(i => { const last = i.path.split('.').filter(s => !/^\d+$/.test(s)).pop() ?? ''; return `- ${i.path || 'output'}: ${i.code}${rule[last] ? ` (rule: ${rule[last]})` : ''}`; });
|
|
151
|
+
return `That draft broke these rules:\n${lines.join('\n')}\nReturn the complete corrected JSON. Copy quotes exactly from the numbered passages.`;
|
|
152
|
+
}
|
|
153
|
+
/** Map the model's draft shape into DigestV1; parseDigest then decides whether the shape is valid. */
|
|
154
|
+
function fromModel(text, form) {
|
|
155
|
+
let v;
|
|
156
|
+
try {
|
|
157
|
+
v = JSON.parse(text);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return { issues: [{ path: '', code: 'json' }] };
|
|
161
|
+
}
|
|
162
|
+
if (!object(v) || !Array.isArray(v.ideas) || !Array.isArray(v.tags))
|
|
163
|
+
return { issues: [{ path: '', code: 'shape' }] };
|
|
164
|
+
const trim = (s) => typeof s === 'string' ? s.trim() : s, cites = [];
|
|
165
|
+
const ideas = v.ideas.map((idea) => {
|
|
166
|
+
if (!object(idea)) {
|
|
167
|
+
cites.push([]);
|
|
168
|
+
return idea;
|
|
169
|
+
}
|
|
170
|
+
const voice = object(idea.voice) && idea.voice.type !== 'author' ? { type: idea.voice.type, name: trim(idea.voice.name) } : { type: 'author' };
|
|
171
|
+
const quotes = Array.isArray(idea.quotes) ? idea.quotes : [];
|
|
172
|
+
cites.push(quotes.map((q) => Number.isSafeInteger(q?.passage) ? q.passage : undefined));
|
|
173
|
+
return { claim: trim(idea.claim), detail: trim(idea.detail), kind: idea.kind, voice, quotes: quotes.map((q) => { const speaker = trim(q?.speaker) || (voice.type !== 'author' ? voice.name : undefined); return { text: trim(q?.text), ...(speaker ? { speaker } : {}) }; }) };
|
|
174
|
+
});
|
|
175
|
+
const context = trim(v.context);
|
|
176
|
+
const parsed = parseDigest({ schemaVersion: 1, form, gist: { lead: trim(v.lead), ...(context ? { context } : {}) }, ideas, tags: v.tags.map(trim) });
|
|
177
|
+
return parsed.digest ? { digest: parsed.digest, cites } : { issues: parsed.issues };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Make a quote exactly verbatim when that is unambiguous: a unique folded or Markdown-insensitive match, inside
|
|
181
|
+
* `within` first, becomes the raw source span. Ambiguous folded matches keep their text for the checker; absent ones are 'missing'.
|
|
182
|
+
*/
|
|
183
|
+
export function snapQuote(source, text, within) {
|
|
184
|
+
if (source.includes(text))
|
|
185
|
+
return { text, match: 'exact' };
|
|
186
|
+
const inside = (m) => !!within && m.start >= within.start && m.end <= within.end;
|
|
187
|
+
const pick = (all) => { const local = all.filter(inside); return local.length === 1 ? local[0] : all.length === 1 ? all[0] : undefined; };
|
|
188
|
+
const folded = locateAll(source, text);
|
|
189
|
+
if (folded.length) {
|
|
190
|
+
const m = pick(folded);
|
|
191
|
+
return { text: m ? source.slice(m.start, m.end) : text, match: 'folded' };
|
|
192
|
+
}
|
|
193
|
+
const loose = (within && pick(locateLoose(source, text, within))) || pick(locateLoose(source, text));
|
|
194
|
+
if (!loose)
|
|
195
|
+
return { text, match: 'missing' };
|
|
196
|
+
// Keep emphasis and code markers balanced: a span that starts or ends inside `**bold**` takes the adjacent markers.
|
|
197
|
+
let { start, end } = loose;
|
|
198
|
+
while (start > 0 && /[*_`]/.test(source[start - 1]))
|
|
199
|
+
start--;
|
|
200
|
+
while (end < source.length && /[*_`]/.test(source[end]))
|
|
201
|
+
end++;
|
|
202
|
+
return { text: source.slice(start, end), match: 'snapped' };
|
|
203
|
+
}
|
|
204
|
+
/** Snap every quote in place and drop quotes under `minQuoteWords`. */
|
|
205
|
+
export function snapQuotes(draft, source, list) {
|
|
206
|
+
const stats = { exact: 0, folded: 0, snapped: 0, missing: 0, short: 0 }, byId = new Map(list.map(p => [p.id, p]));
|
|
207
|
+
draft.digest.ideas.forEach((idea, i) => {
|
|
208
|
+
idea.quotes.forEach((q, j) => { const r = snapQuote(source, q.text, byId.get(draft.cites[i]?.[j] ?? 0)); q.text = r.text; stats[r.match]++; });
|
|
209
|
+
const keep = idea.quotes.map(q => words(q.text) >= minQuoteWords);
|
|
210
|
+
stats.short += keep.filter(k => !k).length;
|
|
211
|
+
idea.quotes = idea.quotes.filter((_, j) => keep[j]);
|
|
212
|
+
draft.cites[i] = (draft.cites[i] ?? []).filter((_, j) => keep[j]);
|
|
213
|
+
});
|
|
214
|
+
return stats;
|
|
215
|
+
}
|
|
216
|
+
/** Ideas and their quotes in source order, by first located occurrence: the checker requires non-decreasing idea starts. */
|
|
217
|
+
function order(draft, source) {
|
|
218
|
+
const at = (text) => locateAll(source, text)[0]?.start ?? Number.MAX_SAFE_INTEGER;
|
|
219
|
+
const rows = draft.digest.ideas.map((idea, i) => { const qs = idea.quotes.map((q, j) => ({ q, cite: draft.cites[i]?.[j], at: at(q.text) })).sort((a, b) => a.at - b.at); return { idea: { ...idea, quotes: qs.map(x => x.q) }, cites: qs.map(x => x.cite), at: qs[0]?.at ?? Number.MAX_SAFE_INTEGER }; });
|
|
220
|
+
rows.sort((a, b) => a.at - b.at);
|
|
221
|
+
draft.digest.ideas = rows.map(r => r.idea);
|
|
222
|
+
draft.cites = rows.map(r => r.cites);
|
|
223
|
+
}
|
|
224
|
+
const pruneable = new Set(['not-verbatim', 'overlap', 'unsupported', 'too-long', 'speaker-required']);
|
|
225
|
+
/** Deterministic last resort: drop failing quotes, then quoteless or duplicate ideas, invalid tags, and second quotes over budget. */
|
|
226
|
+
function prune(draft, issues, source, options) {
|
|
227
|
+
const dropped = { quotes: 0, ideas: 0, tags: 0 }, d = draft.digest, bad = new Set(issues.filter(i => /^ideas\.\d+\.quotes\.\d+$/.test(i.path) && pruneable.has(i.code)).map(i => i.path));
|
|
228
|
+
d.ideas.forEach((idea, i) => { const keep = idea.quotes.map((_, j) => !bad.has(`ideas.${i}.quotes.${j}`)).map((k, j) => k && j < L.maxQuotesPerIdea); dropped.quotes += keep.filter(k => !k).length; idea.quotes = idea.quotes.filter((_, j) => keep[j]); draft.cites[i] = (draft.cites[i] ?? []).filter((_, j) => keep[j]); });
|
|
229
|
+
const seen = new Set();
|
|
230
|
+
const keepIdea = d.ideas.map(idea => { const key = foldText(idea.claim).toLowerCase(), ok = idea.quotes.length > 0 && !seen.has(key); seen.add(key); return ok; });
|
|
231
|
+
dropped.ideas = keepIdea.filter(k => !k).length;
|
|
232
|
+
d.ideas = d.ideas.filter((_, i) => keepIdea[i]);
|
|
233
|
+
draft.cites = draft.cites.filter((_, i) => keepIdea[i]);
|
|
234
|
+
if (d.ideas.length > L.maxIdeas) {
|
|
235
|
+
dropped.ideas += d.ideas.length - L.maxIdeas;
|
|
236
|
+
d.ideas = d.ideas.slice(0, L.maxIdeas);
|
|
237
|
+
draft.cites = draft.cites.slice(0, L.maxIdeas);
|
|
238
|
+
}
|
|
239
|
+
const tags = [...new Set(d.tags)].filter(t => options.tags ? options.tags.includes(t) : /^[a-z0-9][a-z0-9-]{0,63}$/.test(t)).slice(0, L.maxTags);
|
|
240
|
+
dropped.tags = d.tags.length - tags.length;
|
|
241
|
+
d.tags = tags;
|
|
242
|
+
const budget = quoteBudget(source), total = () => d.ideas.reduce((n, idea) => n + idea.quotes.reduce((m, q) => m + words(q.text), 0), 0);
|
|
243
|
+
while (total() > budget) {
|
|
244
|
+
const i = d.ideas.map((idea, k) => ({ k, n: idea.quotes.length > 1 ? words(idea.quotes[1].text) : -1 })).sort((a, b) => b.n - a.n)[0];
|
|
245
|
+
if (!i || i.n < 0)
|
|
246
|
+
break;
|
|
247
|
+
d.ideas[i.k].quotes.pop();
|
|
248
|
+
draft.cites[i.k]?.pop();
|
|
249
|
+
dropped.quotes++;
|
|
250
|
+
}
|
|
251
|
+
return dropped;
|
|
252
|
+
}
|
|
253
|
+
const hashedParams = (p) => ({ temperature: p.temperature, seed: p.seed, maxTokens: p.maxTokens, contextChars: p.contextChars, maxSections: p.maxSections });
|
|
254
|
+
function resolveParams(params = {}) {
|
|
255
|
+
const p = { ...defaultDigestParams, ...params }, int = (v, min, max) => Number.isSafeInteger(v) && v >= min && v <= max;
|
|
256
|
+
if (!(p.temperature >= 0 && p.temperature <= 2) || !int(p.seed, 0, 2 ** 31) || !int(p.maxTokens, 256, 32768) || !int(p.contextChars, 2000, 1_000_000) || !int(p.maxSections, 1, 256) || !int(p.timeoutMs, 1, 3_600_000) || !int(p.deadlineMs, 1, 14_400_000) || !int(p.maxOutputBytes, 1024, 4 * 1024 * 1024))
|
|
257
|
+
throw new DigestError('invalid-input');
|
|
258
|
+
return p;
|
|
259
|
+
}
|
|
260
|
+
function validIdentity(id) {
|
|
261
|
+
const ok = (v) => typeof v === 'string' && v.length > 0 && v.length <= 256 && !/[\u0000-\u001f\u007f]/.test(v);
|
|
262
|
+
if (!object(id) || !ok(id.model) || !ok(id.quantization) || !ok(id.runtime))
|
|
263
|
+
throw new DigestError('invalid-input');
|
|
264
|
+
return { model: id.model, quantization: id.quantization, runtime: id.runtime };
|
|
265
|
+
}
|
|
266
|
+
/** sha256 over canonical JSON of the revision, its Markdown, the prompt version, the model identity and every output-shaping input. */
|
|
267
|
+
export function digestInputHash(input) {
|
|
268
|
+
return sha256(canonicalJson({ revisionId: input.revisionId, markdownSha256: sha256(input.markdown), promptVersion: PROMPT_VERSION, model: validIdentity(input.model), params: { ...hashedParams(resolveParams(input.params)), form: input.form, vocabulary: input.vocabulary ? [...input.vocabulary] : null, highlights: (input.highlights ?? []).map(h => h.text), support: input.embed ? input.supportThreshold ?? defaultSupportThreshold : null } }));
|
|
269
|
+
}
|
|
270
|
+
/** Unqualified on real claims: a deliberately low bar that only catches quotes unrelated to their claim. */
|
|
271
|
+
export const defaultSupportThreshold = 0.35;
|
|
272
|
+
export async function draftDigest(input) {
|
|
273
|
+
const p = resolveParams(input.params), identity = validIdentity(input.model.identity);
|
|
274
|
+
if (typeof input.markdown !== 'string' || !input.markdown.trim() || !forms.includes(input.form))
|
|
275
|
+
throw new DigestError('invalid-input');
|
|
276
|
+
if (input.vocabulary && (!input.vocabulary.length || input.vocabulary.length > 256 || input.vocabulary.some(t => !/^[a-z0-9][a-z0-9-]{0,63}$/.test(t))))
|
|
277
|
+
throw new DigestError('invalid-input');
|
|
278
|
+
const inputHash = digestInputHash({ ...input, model: identity }), source = input.markdown, list = passages(source);
|
|
279
|
+
if (list.length === 0)
|
|
280
|
+
throw new DigestError('invalid-input');
|
|
281
|
+
const userSignal = input.signal ?? new AbortController().signal, overall = AbortSignal.any([userSignal, AbortSignal.timeout(p.deadlineMs)]);
|
|
282
|
+
const trace = { strategy: 'single', sections: 1, calls: 0, repaired: false, attempts: [], dropped: { quotes: 0, ideas: 0, tags: 0 } };
|
|
283
|
+
const call = async (messages, schema) => {
|
|
284
|
+
if (userSignal.aborted)
|
|
285
|
+
throw new DigestError('cancelled');
|
|
286
|
+
if (overall.aborted)
|
|
287
|
+
throw new DigestError('limit-exceeded');
|
|
288
|
+
const signal = AbortSignal.any([overall, AbortSignal.timeout(p.timeoutMs)]);
|
|
289
|
+
trace.calls++;
|
|
290
|
+
let text;
|
|
291
|
+
try {
|
|
292
|
+
text = await input.model.generate(messages, schema, { signal, maxOutputBytes: p.maxOutputBytes, maxTokens: p.maxTokens, temperature: p.temperature, seed: p.seed });
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
if (userSignal.aborted)
|
|
296
|
+
throw new DigestError('cancelled');
|
|
297
|
+
if (signal.aborted)
|
|
298
|
+
throw new DigestError('limit-exceeded');
|
|
299
|
+
if (error instanceof DigestError)
|
|
300
|
+
throw error;
|
|
301
|
+
throw new DigestError('unavailable');
|
|
302
|
+
}
|
|
303
|
+
if (userSignal.aborted)
|
|
304
|
+
throw new DigestError('cancelled');
|
|
305
|
+
if (typeof text !== 'string' || Buffer.byteLength(text) > p.maxOutputBytes)
|
|
306
|
+
throw new DigestError('limit-exceeded');
|
|
307
|
+
return text;
|
|
308
|
+
};
|
|
309
|
+
const highlights = (input.highlights ?? []).filter(h => typeof h?.text === 'string' && h.text.trim()).slice(0, 32).map(h => { const at = h.start ?? locateAll(source, h.text)[0]?.start; return { text: h.text, passage: at === undefined ? undefined : list.find(x => at >= x.start && at < x.end)?.id }; });
|
|
310
|
+
let shown = list, notesText;
|
|
311
|
+
const total = list.reduce((n, x) => n + display(x).length + 1, 0);
|
|
312
|
+
if (total > p.contextChars) {
|
|
313
|
+
// Long source: notes per section, then one combined pass over the cited and highlighted passages only.
|
|
314
|
+
const sections = sectionsOf(list, p.contextChars);
|
|
315
|
+
if (sections.length > p.maxSections)
|
|
316
|
+
throw new DigestError('limit-exceeded');
|
|
317
|
+
trace.strategy = 'sections';
|
|
318
|
+
trace.sections = sections.length;
|
|
319
|
+
const notes = [];
|
|
320
|
+
for (const [k, section] of sections.entries()) {
|
|
321
|
+
const first = section[0].id, last = section[section.length - 1].id;
|
|
322
|
+
const text = await call([{ role: 'system', content: `You take notes on part ${k + 1} of ${sections.length} of a longer source. Reply with JSON only. Give 1 to 4 notes, each a point of at most 25 words that the section asserts, with one passage number from this part and a short quote copied exactly from that passage.` }, { role: 'user', content: sourceBlock(input.title, input.form, section, []) }], notesSchema(first, last));
|
|
323
|
+
let v;
|
|
324
|
+
try {
|
|
325
|
+
v = JSON.parse(text);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (object(v) && Array.isArray(v.notes))
|
|
331
|
+
for (const n of v.notes.slice(0, 4))
|
|
332
|
+
if (object(n) && typeof n.point === 'string' && Number.isSafeInteger(n.passage) && n.passage >= first && n.passage <= last && typeof n.quote === 'string')
|
|
333
|
+
notes.push({ point: collapse(n.point).slice(0, 300), passage: n.passage, quote: collapse(n.quote).slice(0, 300) });
|
|
334
|
+
}
|
|
335
|
+
const wanted = new Set([...notes.map(n => n.passage), ...highlights.flatMap(h => h.passage ? [h.passage] : [])]);
|
|
336
|
+
notesText = notes.map(n => `- [${n.passage}] ${n.point}`).join('\n');
|
|
337
|
+
shown = [];
|
|
338
|
+
let size = notesText.length;
|
|
339
|
+
for (const x of list)
|
|
340
|
+
if (wanted.has(x.id) && size + display(x).length + 1 <= p.contextChars) {
|
|
341
|
+
shown.push(x);
|
|
342
|
+
size += display(x).length + 1;
|
|
343
|
+
}
|
|
344
|
+
if (!shown.length)
|
|
345
|
+
throw new DigestError('unavailable', [{ path: '', code: 'no-notes' }]);
|
|
346
|
+
}
|
|
347
|
+
const budget = quoteBudget(source), schema = draftSchema(list.length, input.vocabulary);
|
|
348
|
+
const messages = [{ role: 'system', content: system(budget, input.vocabulary) }, { role: 'user', content: (notesText ? `Notes from each part of the source, in order:\n${notesText}\n\n` : '') + sourceBlock(input.title, input.form, shown, highlights) }];
|
|
349
|
+
const cache = new Map(), options = { ...(input.vocabulary ? { tags: input.vocabulary } : {}) };
|
|
350
|
+
const embedKey = (t) => Buffer.from(foldText(t)).subarray(0, 900).toString();
|
|
351
|
+
if (input.embed)
|
|
352
|
+
Object.assign(options, { supportThreshold: input.supportThreshold ?? defaultSupportThreshold, supports: (claim, quote) => { const a = cache.get(embedKey(claim)), b = cache.get(embedKey(quote)); return a && b ? a.reduce((n, x, i) => n + x * b[i], 0) : 0; } });
|
|
353
|
+
const check = async (draft) => {
|
|
354
|
+
if (input.embed) {
|
|
355
|
+
const missing = [...new Set(draft.digest.ideas.flatMap(i => [i.claim, ...i.quotes.map(q => q.text)]).map(embedKey))].filter(t => !cache.has(t));
|
|
356
|
+
if (missing.length) {
|
|
357
|
+
const vectors = await input.embed(missing, overall);
|
|
358
|
+
if (!Array.isArray(vectors) || vectors.length !== missing.length)
|
|
359
|
+
throw new DigestError('unavailable');
|
|
360
|
+
missing.forEach((t, i) => cache.set(t, vectors[i]));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return checkDigest(draft.digest, source, options).issues;
|
|
364
|
+
};
|
|
365
|
+
const attempt = async (text) => {
|
|
366
|
+
const draft = fromModel(text, input.form);
|
|
367
|
+
if ('issues' in draft) {
|
|
368
|
+
trace.attempts.push({ issues: draft.issues });
|
|
369
|
+
return { issues: draft.issues };
|
|
370
|
+
}
|
|
371
|
+
const quotes = snapQuotes(draft, source, list);
|
|
372
|
+
order(draft, source);
|
|
373
|
+
const issues = await check(draft);
|
|
374
|
+
trace.attempts.push({ issues, quotes });
|
|
375
|
+
return { draft, issues };
|
|
376
|
+
};
|
|
377
|
+
const first = await call(messages, schema);
|
|
378
|
+
let result = await attempt(first);
|
|
379
|
+
if (result.issues.length) {
|
|
380
|
+
trace.repaired = true;
|
|
381
|
+
result = await attempt(await call([...messages, { role: 'assistant', content: first }, { role: 'user', content: repairPrompt(result.issues) }], schema));
|
|
382
|
+
}
|
|
383
|
+
if (!result.draft)
|
|
384
|
+
throw new DigestError('unavailable', result.issues);
|
|
385
|
+
let issues = result.issues;
|
|
386
|
+
if (issues.length) {
|
|
387
|
+
trace.dropped = prune(result.draft, issues, source, options);
|
|
388
|
+
issues = await check(result.draft);
|
|
389
|
+
if (result.draft.digest.ideas.length < L.minIdeas)
|
|
390
|
+
issues = [...issues.filter(i => !(i.path === 'ideas' && i.code === 'count')), { path: 'ideas', code: 'count' }];
|
|
391
|
+
if (issues.length)
|
|
392
|
+
throw new DigestError('unavailable', issues);
|
|
393
|
+
}
|
|
394
|
+
const producer = { pipeline: 'sponge-digest', promptVersion: PROMPT_VERSION, model: identity, params: hashedParams(p), strategy: trace.strategy, sections: trace.sections, calls: trace.calls, repaired: trace.repaired, dropped: trace.dropped, ...(input.embed ? { supportThreshold: options.supportThreshold } : {}) };
|
|
395
|
+
return { output: result.draft.digest, inputHash, producer, options, trace };
|
|
396
|
+
}
|
|
397
|
+
const formFor = (kind) => kind === 'pdf' ? 'pdf' : kind === 'media' ? 'video' : 'article';
|
|
398
|
+
/** Draft and store a digest for the item's current revision. A matching input hash returns the stored draft without calling the model. */
|
|
399
|
+
export async function createDigest(store, itemId, model, options = {}) {
|
|
400
|
+
const item = store.read(itemId);
|
|
401
|
+
if (!item)
|
|
402
|
+
throw new StoreError('unavailable');
|
|
403
|
+
const highlights = store.listAnnotations(itemId).filter(a => a.kind === 'highlight' && a.revisionId === item.revisionId && a.anchored !== false).map(a => ({ text: a.anchor.exact, start: a.anchor.start }));
|
|
404
|
+
const input = { ...options, revisionId: item.revisionId, markdown: item.markdown, title: item.title, form: formFor(item.kind), model, highlights };
|
|
405
|
+
const prior = store.findDigest(item.revisionId, digestInputHash({ ...input, model: model.identity }));
|
|
406
|
+
if (prior)
|
|
407
|
+
return { derivation: prior, duplicate: true };
|
|
408
|
+
const draft = await draftDigest(input), stored = store.putDigest({ itemId, revisionId: item.revisionId, producer: draft.producer, inputHash: draft.inputHash, output: draft.output, options: draft.options });
|
|
409
|
+
return { derivation: store.readDigest(stored.id), duplicate: stored.duplicate, trace: draft.trace };
|
|
410
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type TextQuote } from '../annotations/quote.js';
|
|
2
|
+
/**
|
|
3
|
+
* Digest v1: a one-sentence gist and 3-5 ideas, each a claim with detail and 1-2 verbatim quotes.
|
|
4
|
+
* The model proposes; this validator decides. Nothing here calls a model.
|
|
5
|
+
*/
|
|
6
|
+
export declare const ideaKinds: readonly ["finding", "argument", "method", "evidence", "recommendation", "counterpoint"];
|
|
7
|
+
export declare const forms: readonly ["article", "video", "paper", "thread", "pdf"];
|
|
8
|
+
export type Voice = {
|
|
9
|
+
type: 'author';
|
|
10
|
+
} | {
|
|
11
|
+
type: 'speaker' | 'reported';
|
|
12
|
+
name: string;
|
|
13
|
+
};
|
|
14
|
+
export type QuoteLocator = {
|
|
15
|
+
type: 'timestamp';
|
|
16
|
+
ms: number;
|
|
17
|
+
} | {
|
|
18
|
+
type: 'page';
|
|
19
|
+
page: number;
|
|
20
|
+
};
|
|
21
|
+
export interface DigestQuote {
|
|
22
|
+
text: string;
|
|
23
|
+
speaker?: string;
|
|
24
|
+
locator?: QuoteLocator;
|
|
25
|
+
}
|
|
26
|
+
export interface DigestIdea {
|
|
27
|
+
claim: string;
|
|
28
|
+
detail: string;
|
|
29
|
+
kind: typeof ideaKinds[number];
|
|
30
|
+
voice: Voice;
|
|
31
|
+
quotes: DigestQuote[];
|
|
32
|
+
}
|
|
33
|
+
export interface DigestV1 {
|
|
34
|
+
schemaVersion: 1;
|
|
35
|
+
form: typeof forms[number];
|
|
36
|
+
gist: {
|
|
37
|
+
lead: string;
|
|
38
|
+
context?: string;
|
|
39
|
+
};
|
|
40
|
+
ideas: DigestIdea[];
|
|
41
|
+
tags: string[];
|
|
42
|
+
}
|
|
43
|
+
export declare const digestLimits: {
|
|
44
|
+
readonly leadChars: 155;
|
|
45
|
+
readonly contextWords: 50;
|
|
46
|
+
readonly minIdeas: 3;
|
|
47
|
+
readonly maxIdeas: 5;
|
|
48
|
+
readonly claimWords: 20;
|
|
49
|
+
readonly detailWords: 60;
|
|
50
|
+
readonly detailSentences: 3;
|
|
51
|
+
readonly maxQuotesPerIdea: 2;
|
|
52
|
+
readonly minQuoteWords: 4;
|
|
53
|
+
readonly quoteWords: 25;
|
|
54
|
+
readonly totalQuoteWords: 150;
|
|
55
|
+
readonly sourceQuoteShare: 0.1;
|
|
56
|
+
readonly maxTags: 3;
|
|
57
|
+
};
|
|
58
|
+
export interface DigestIssue {
|
|
59
|
+
path: string;
|
|
60
|
+
code: string;
|
|
61
|
+
}
|
|
62
|
+
export interface LocatedQuote {
|
|
63
|
+
idea: number;
|
|
64
|
+
quote: number;
|
|
65
|
+
anchor: TextQuote;
|
|
66
|
+
}
|
|
67
|
+
export interface DigestCheck {
|
|
68
|
+
ok: boolean;
|
|
69
|
+
issues: DigestIssue[];
|
|
70
|
+
quotes: LocatedQuote[];
|
|
71
|
+
}
|
|
72
|
+
export interface DigestOptions {
|
|
73
|
+
/** Controlled tag vocabulary; omitted means any 1-3 lowercase tags. */
|
|
74
|
+
tags?: readonly string[];
|
|
75
|
+
/** Optional semantic check that a quote supports its claim, e.g. embedding cosine. */
|
|
76
|
+
supports?: (claim: string, quote: string) => number;
|
|
77
|
+
supportThreshold?: number;
|
|
78
|
+
}
|
|
79
|
+
/** Parse an untrusted model output into a DigestV1 shape, or report why not. */
|
|
80
|
+
export declare function parseDigest(value: unknown): {
|
|
81
|
+
digest?: DigestV1;
|
|
82
|
+
issues: DigestIssue[];
|
|
83
|
+
};
|
|
84
|
+
/** Check every v1 rule that code can decide against the captured Markdown. */
|
|
85
|
+
export declare function checkDigest(digest: DigestV1, source: string, options?: DigestOptions): DigestCheck;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { foldText, locateAll, textQuote, words } from '../annotations/quote.js';
|
|
2
|
+
/**
|
|
3
|
+
* Digest v1: a one-sentence gist and 3-5 ideas, each a claim with detail and 1-2 verbatim quotes.
|
|
4
|
+
* The model proposes; this validator decides. Nothing here calls a model.
|
|
5
|
+
*/
|
|
6
|
+
export const ideaKinds = ['finding', 'argument', 'method', 'evidence', 'recommendation', 'counterpoint'];
|
|
7
|
+
export const forms = ['article', 'video', 'paper', 'thread', 'pdf'];
|
|
8
|
+
export const digestLimits = { leadChars: 155, contextWords: 50, minIdeas: 3, maxIdeas: 5, claimWords: 20, detailWords: 60, detailSentences: 3, maxQuotesPerIdea: 2, minQuoteWords: 4, quoteWords: 25, totalQuoteWords: 150, sourceQuoteShare: 0.1, maxTags: 3 };
|
|
9
|
+
const object = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
10
|
+
const keys = (v, allowed) => Object.keys(v).every(k => allowed.includes(k));
|
|
11
|
+
const str = (v, max = 2000) => typeof v === 'string' && v.trim().length > 0 && v.length <= max && !/[\u0000-\u0008\u000b-\u001f\u007f]/.test(v);
|
|
12
|
+
/** Sentence terminators followed by more text; abbreviations are rare enough in a 155-character lead to reject. */
|
|
13
|
+
const sentences = (v) => foldText(v).split(/(?<=[.!?])\s+(?=\S)/).length;
|
|
14
|
+
/** Parse an untrusted model output into a DigestV1 shape, or report why not. */
|
|
15
|
+
export function parseDigest(value) {
|
|
16
|
+
const issues = [], bad = (path, code = 'shape') => { issues.push({ path, code }); };
|
|
17
|
+
if (!object(value) || !keys(value, ['schemaVersion', 'form', 'gist', 'ideas', 'tags']) || value.schemaVersion !== 1)
|
|
18
|
+
return { issues: [{ path: '', code: 'shape' }] };
|
|
19
|
+
if (!forms.includes(value.form))
|
|
20
|
+
bad('form');
|
|
21
|
+
const gist = value.gist;
|
|
22
|
+
if (!object(gist) || !keys(gist, ['lead', 'context']) || !str(gist.lead) || gist.context !== undefined && !str(gist.context))
|
|
23
|
+
bad('gist');
|
|
24
|
+
if (!Array.isArray(value.ideas) || value.ideas.length > 8)
|
|
25
|
+
bad('ideas');
|
|
26
|
+
else
|
|
27
|
+
value.ideas.forEach((idea, i) => {
|
|
28
|
+
const at = `ideas.${i}`;
|
|
29
|
+
if (!object(idea) || !keys(idea, ['claim', 'detail', 'kind', 'voice', 'quotes']) || !str(idea.claim) || !str(idea.detail) || !ideaKinds.includes(idea.kind))
|
|
30
|
+
return bad(at);
|
|
31
|
+
const voice = idea.voice;
|
|
32
|
+
if (!object(voice) || !(voice.type === 'author' && keys(voice, ['type']) || (voice.type === 'speaker' || voice.type === 'reported') && keys(voice, ['type', 'name']) && str(voice.name, 200)))
|
|
33
|
+
bad(at + '.voice');
|
|
34
|
+
if (!Array.isArray(idea.quotes) || idea.quotes.length > 4)
|
|
35
|
+
return bad(at + '.quotes');
|
|
36
|
+
idea.quotes.forEach((quote, j) => {
|
|
37
|
+
if (!object(quote) || !keys(quote, ['text', 'speaker', 'locator']) || !str(quote.text, 600) || quote.speaker !== undefined && !str(quote.speaker, 200))
|
|
38
|
+
return bad(`${at}.quotes.${j}`);
|
|
39
|
+
const loc = quote.locator;
|
|
40
|
+
if (loc !== undefined && !(object(loc) && (loc.type === 'timestamp' && keys(loc, ['type', 'ms']) && Number.isSafeInteger(loc.ms) && loc.ms >= 0 || loc.type === 'page' && keys(loc, ['type', 'page']) && Number.isSafeInteger(loc.page) && loc.page >= 1)))
|
|
41
|
+
bad(`${at}.quotes.${j}.locator`);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
if (!Array.isArray(value.tags) || value.tags.some(t => !str(t, 64)))
|
|
45
|
+
bad('tags');
|
|
46
|
+
return issues.length ? { issues } : { digest: value, issues };
|
|
47
|
+
}
|
|
48
|
+
/** Check every v1 rule that code can decide against the captured Markdown. */
|
|
49
|
+
export function checkDigest(digest, source, options = {}) {
|
|
50
|
+
const issues = [], located = [], issue = (path, code) => { issues.push({ path, code }); }, L = digestLimits;
|
|
51
|
+
if (digest.gist.lead.length > L.leadChars)
|
|
52
|
+
issue('gist.lead', 'too-long');
|
|
53
|
+
if (sentences(digest.gist.lead) !== 1)
|
|
54
|
+
issue('gist.lead', 'one-sentence');
|
|
55
|
+
if (digest.gist.context !== undefined && words(digest.gist.context) > L.contextWords)
|
|
56
|
+
issue('gist.context', 'too-long');
|
|
57
|
+
if (digest.ideas.length < L.minIdeas || digest.ideas.length > L.maxIdeas)
|
|
58
|
+
issue('ideas', 'count');
|
|
59
|
+
const claims = new Set();
|
|
60
|
+
let quoteWords = 0;
|
|
61
|
+
digest.ideas.forEach((idea, i) => {
|
|
62
|
+
const at = `ideas.${i}`;
|
|
63
|
+
if (words(idea.claim) > L.claimWords)
|
|
64
|
+
issue(at + '.claim', 'too-long');
|
|
65
|
+
if (sentences(idea.claim) !== 1)
|
|
66
|
+
issue(at + '.claim', 'one-sentence');
|
|
67
|
+
if (claims.has(foldText(idea.claim).toLowerCase()))
|
|
68
|
+
issue(at + '.claim', 'duplicate');
|
|
69
|
+
claims.add(foldText(idea.claim).toLowerCase());
|
|
70
|
+
if (words(idea.detail) > L.detailWords)
|
|
71
|
+
issue(at + '.detail', 'too-long');
|
|
72
|
+
if (sentences(idea.detail) > L.detailSentences)
|
|
73
|
+
issue(at + '.detail', 'too-many-sentences');
|
|
74
|
+
if (idea.quotes.length < 1 || idea.quotes.length > L.maxQuotesPerIdea)
|
|
75
|
+
issue(at + '.quotes', 'count');
|
|
76
|
+
idea.quotes.forEach((quote, j) => {
|
|
77
|
+
const path = `${at}.quotes.${j}`, count = words(quote.text);
|
|
78
|
+
quoteWords += count;
|
|
79
|
+
if (count > L.quoteWords)
|
|
80
|
+
issue(path, 'too-long');
|
|
81
|
+
if (count < L.minQuoteWords)
|
|
82
|
+
issue(path, 'too-short');
|
|
83
|
+
if (idea.voice.type !== 'author' && !quote.speaker)
|
|
84
|
+
issue(path, 'speaker-required');
|
|
85
|
+
const matches = locateAll(source, quote.text);
|
|
86
|
+
if (!matches.length)
|
|
87
|
+
return issue(path, 'not-verbatim');
|
|
88
|
+
// Prefer the first occurrence not already claimed by an earlier quote.
|
|
89
|
+
const match = matches.find(m => !located.some(q => m.start < q.anchor.end && q.anchor.start < m.end));
|
|
90
|
+
if (!match)
|
|
91
|
+
return issue(path, 'overlap');
|
|
92
|
+
located.push({ idea: i, quote: j, anchor: textQuote(source, match.start, match.end) });
|
|
93
|
+
if (options.supports && options.supports(idea.claim, quote.text) < (options.supportThreshold ?? 0.5))
|
|
94
|
+
issue(path, 'unsupported');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
// Ideas follow the source: each idea's earliest located quote starts no earlier than the previous idea's.
|
|
98
|
+
let previous = -1;
|
|
99
|
+
digest.ideas.forEach((_, i) => {
|
|
100
|
+
const starts = located.filter(q => q.idea === i).map(q => q.anchor.start);
|
|
101
|
+
if (!starts.length)
|
|
102
|
+
return;
|
|
103
|
+
const first = Math.min(...starts);
|
|
104
|
+
if (first < previous)
|
|
105
|
+
issue(`ideas.${i}`, 'out-of-order');
|
|
106
|
+
previous = first;
|
|
107
|
+
});
|
|
108
|
+
if (quoteWords > L.totalQuoteWords)
|
|
109
|
+
issue('ideas', 'quote-budget');
|
|
110
|
+
// The 10% share has a 25-word floor: an exemption so a short source can still carry one full quote.
|
|
111
|
+
if (quoteWords > Math.max(L.quoteWords, Math.floor(words(source) * L.sourceQuoteShare)))
|
|
112
|
+
issue('ideas', 'quote-share');
|
|
113
|
+
if (digest.tags.length < 1 || digest.tags.length > L.maxTags || new Set(digest.tags).size !== digest.tags.length)
|
|
114
|
+
issue('tags', 'count');
|
|
115
|
+
digest.tags.forEach((tag, i) => { if (options.tags ? !options.tags.includes(tag) : !/^[a-z0-9][a-z0-9-]{0,63}$/.test(tag))
|
|
116
|
+
issue(`tags.${i}`, 'vocabulary'); });
|
|
117
|
+
return { ok: issues.length === 0, issues, quotes: located };
|
|
118
|
+
}
|