@konneal/engine 0.2.179 → 0.2.182
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/dist/{ask-PWT4B6YE.js → ask-YWYXQ6PE.js} +4 -4
- package/dist/{chunk-CLDMP5YH.js → chunk-4J47P52J.js} +1 -1
- package/dist/{chunk-ROXOL4GF.js → chunk-VRWNS4AI.js} +2 -2
- package/dist/{chunk-KINFFYQH.js → chunk-YGQCVPX6.js} +6 -0
- package/dist/{chunk-LDSGL62W.js → chunk-YVQVT7GD.js} +7 -7
- package/dist/{chunk-MUWO2EV6.js → chunk-ZZGKC6WO.js} +2 -2
- package/dist/{env-CNO44VN2.js → env-BZWHCSCL.js} +1 -1
- package/dist/env.d.ts +2 -0
- package/dist/openapi-surface.gen.d.ts +1 -1
- package/dist/openapi-types.d.ts +160 -0
- package/dist/{search-KNPPTHMG.js → search-N37WULEM.js} +3 -3
- package/dist/{tools-UCUNOJR4.js → tools-HLA7O2ZK.js} +2 -2
- package/dist/userDocuments.d.ts +66 -0
- package/dist/worker_public/src/index.js +268 -7
- package/package.json +1 -1
- package/workers/worker_public/migrations/0020_user_documents.sql +18 -0
- package/workers/worker_public/openapi.yaml +72 -0
- package/workers/worker_public/schema.sql +16 -0
- package/workers/worker_public/src/ask.ts +13 -10
- package/workers/worker_public/src/env.ts +2 -0
- package/workers/worker_public/src/index.ts +48 -1
- package/workers/worker_public/src/openapi-surface.gen.ts +24 -0
- package/workers/worker_public/src/ports/cloudflare/adapters.ts +3 -0
- package/workers/worker_public/src/ports/vector.ts +3 -0
- package/workers/worker_public/src/userDocuments.ts +294 -0
- package/workers/worker_public/wrangler.toml +8 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// The member's own documents (TODO.sota/20 v1): import → privately
|
|
2
|
+
// index → answer beside the corpus. The structural isolation law: user
|
|
3
|
+
// documents live in a USER lane — their own Vectorize index (USER_DOCS)
|
|
4
|
+
// and their own R2 bucket (USER_DOCS) — never the public production
|
|
5
|
+
// index with a filter (the 2026-09-02 incident's law). Every vector in
|
|
6
|
+
// the lane carries its owner's sub; the ask-path federation (slice 4)
|
|
7
|
+
// queries the lane ONLY with the session's own sub — mandatory by
|
|
8
|
+
// construction, the query helper below takes no unfiltered path.
|
|
9
|
+
//
|
|
10
|
+
// Retention, stated to the member verbatim in the UI: 10 documents, 20
|
|
11
|
+
// MB each, held until the member deletes; deletion is immediate and
|
|
12
|
+
// total (the R2 object, the lane vectors and the row go together).
|
|
13
|
+
//
|
|
14
|
+
// Extraction v1, honest about formats: images ride the ratified vision
|
|
15
|
+
// lane (the nameplate message pattern — the image rides its OWN short
|
|
16
|
+
// trailing user message); digital-born PDFs give up their text layer
|
|
17
|
+
// (inflate + the text-op scan) and the model structures it; a scanned
|
|
18
|
+
// PDF with no text layer is answered honestly (status no_text_layer) —
|
|
19
|
+
// the rasterization lane is future work, not a silent empty import.
|
|
20
|
+
// One model call classifies and extracts (kind + typed fields + a
|
|
21
|
+
// retrieval summary); the Clef instrument joins when the import eval
|
|
22
|
+
// runs on the first real documents.
|
|
23
|
+
//
|
|
24
|
+
// Port purity: this module names no provider binding — the route table
|
|
25
|
+
// wires cfStore/cfBlobs/cfVectorIndex/cfModelRunner; tests inject
|
|
26
|
+
// fakes.
|
|
27
|
+
|
|
28
|
+
import type { Blobs } from "./ports/blobs.ts";
|
|
29
|
+
import type { StoreQuery } from "./ports/store.ts";
|
|
30
|
+
import type { VectorIndex } from "./ports/vector.ts";
|
|
31
|
+
import type { ModelRunner } from "./ports/model.ts";
|
|
32
|
+
import { MODELS } from "./config.ts";
|
|
33
|
+
|
|
34
|
+
export const USER_DOC_LIMIT = 10;
|
|
35
|
+
export const USER_DOC_MAX_BYTES = 20 * 1024 * 1024;
|
|
36
|
+
const ACCEPTED_MIMES = new Set(["image/png", "image/jpeg", "image/webp", "application/pdf"]);
|
|
37
|
+
|
|
38
|
+
export interface UserDocumentRow {
|
|
39
|
+
id: string;
|
|
40
|
+
owner_sub: string;
|
|
41
|
+
name: string;
|
|
42
|
+
kind: string | null;
|
|
43
|
+
status: string;
|
|
44
|
+
bytes: number;
|
|
45
|
+
r2_key: string | null;
|
|
46
|
+
fields: string | null;
|
|
47
|
+
error: string | null;
|
|
48
|
+
created_at: string;
|
|
49
|
+
updated_at: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface UserDocumentPorts {
|
|
53
|
+
store: StoreQuery;
|
|
54
|
+
blobs: Blobs;
|
|
55
|
+
lane: VectorIndex;
|
|
56
|
+
model: ModelRunner;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface UploadBlob {
|
|
60
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
61
|
+
size: number;
|
|
62
|
+
type: string;
|
|
63
|
+
name: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The lane's one chunk id per document — deterministic, so deletion
|
|
67
|
+
* can drop it by id without an index listing round-trip. */
|
|
68
|
+
export function userDocumentChunkId(docId: string): string {
|
|
69
|
+
return `ud-${docId}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function listUserDocuments(store: StoreQuery, sub: string): Promise<{ documents: UserDocumentRow[] }> {
|
|
73
|
+
const res = await store
|
|
74
|
+
.prepare("SELECT id, owner_sub, name, kind, status, bytes, r2_key, fields, error, created_at, updated_at FROM user_documents WHERE owner_sub = ?1 ORDER BY created_at DESC")
|
|
75
|
+
.bind(sub)
|
|
76
|
+
.all();
|
|
77
|
+
return { documents: (res.results ?? []) as unknown as UserDocumentRow[] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function getUserDocument(store: StoreQuery, sub: string, id: string): Promise<UserDocumentRow | null> {
|
|
81
|
+
const row = await store
|
|
82
|
+
.prepare("SELECT id, owner_sub, name, kind, status, bytes, r2_key, fields, error, created_at, updated_at FROM user_documents WHERE id = ?1 AND owner_sub = ?2")
|
|
83
|
+
.bind(id, sub)
|
|
84
|
+
.first();
|
|
85
|
+
return (row as unknown as UserDocumentRow) ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The quota guard: 10 documents per member. Returns the refusal
|
|
89
|
+
* response or null. */
|
|
90
|
+
export async function quotaRefusal(store: StoreQuery, sub: string): Promise<Response | null> {
|
|
91
|
+
const row = await store.prepare("SELECT COUNT(*) AS n FROM user_documents WHERE owner_sub = ?1").bind(sub).first<{ n: number }>();
|
|
92
|
+
const n = Number(row?.n ?? 0);
|
|
93
|
+
if (n >= USER_DOC_LIMIT) {
|
|
94
|
+
return new Response(JSON.stringify({ error: { code: "quota_exceeded", message: `You hold the maximum of ${USER_DOC_LIMIT} documents. Delete one to import another.` } }), { status: 403, headers: { "content-type": "application/json" } });
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The import: validate, store the original, record the row, and run
|
|
100
|
+
* the extraction under the request's waitUntil — the route answers
|
|
101
|
+
* 202 with the importing row and the status endpoint tells the rest.
|
|
102
|
+
* Every quota rule is checked BEFORE anything is stored. */
|
|
103
|
+
export async function createImport(ports: UserDocumentPorts, waitUntil: (p: Promise<unknown>) => void, sub: string, file: UploadBlob): Promise<{ document: UserDocumentRow } | { error: Response }> {
|
|
104
|
+
const mime = file.type || "";
|
|
105
|
+
if (!ACCEPTED_MIMES.has(mime)) {
|
|
106
|
+
return { error: new Response(JSON.stringify({ error: { code: "invalid_type", message: "Upload a PNG, JPEG, WebP image or a PDF." } }), { status: 415, headers: { "content-type": "application/json" } }) };
|
|
107
|
+
}
|
|
108
|
+
if (file.size > USER_DOC_MAX_BYTES) {
|
|
109
|
+
return { error: new Response(JSON.stringify({ error: { code: "too_large", message: `Documents are limited to ${Math.floor(USER_DOC_MAX_BYTES / (1024 * 1024))} MB.` } }), { status: 413, headers: { "content-type": "application/json" } }) };
|
|
110
|
+
}
|
|
111
|
+
const refused = await quotaRefusal(ports.store, sub);
|
|
112
|
+
if (refused) return { error: refused };
|
|
113
|
+
|
|
114
|
+
const id = crypto.randomUUID();
|
|
115
|
+
const r2Key = `${sub}/${id}`;
|
|
116
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
117
|
+
await ports.blobs.put(r2Key, bytes.buffer as ArrayBuffer, mime);
|
|
118
|
+
const now = new Date().toISOString();
|
|
119
|
+
const row: UserDocumentRow = {
|
|
120
|
+
id, owner_sub: sub, name: file.name || "document", kind: null,
|
|
121
|
+
status: "importing", bytes: bytes.byteLength, r2_key: r2Key,
|
|
122
|
+
fields: null, error: null, created_at: now, updated_at: now,
|
|
123
|
+
};
|
|
124
|
+
await ports.store.prepare(
|
|
125
|
+
"INSERT INTO user_documents (id, owner_sub, name, kind, status, bytes, r2_key, fields, error, created_at, updated_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",
|
|
126
|
+
)
|
|
127
|
+
.bind(id, sub, row.name, row.kind, row.status, row.bytes, row.r2_key, row.fields, row.error, now, now)
|
|
128
|
+
.run();
|
|
129
|
+
waitUntil(processImport(ports, row, mime, bytes).catch(() => {}));
|
|
130
|
+
return { document: row };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface Extraction {
|
|
134
|
+
kind: "certificate" | "test_report" | "other";
|
|
135
|
+
fields: Record<string, string>;
|
|
136
|
+
summary: string;
|
|
137
|
+
text: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The worker's half of the extraction: get text (vision for images,
|
|
141
|
+
* the PDF text layer for digital-born PDFs), then one structuring
|
|
142
|
+
* call. Statuses land on the row either way — importing | ready |
|
|
143
|
+
* no_text_layer | failed, never a silent empty import. */
|
|
144
|
+
export async function processImport(ports: UserDocumentPorts, row: UserDocumentRow, mime: string, bytes: Uint8Array): Promise<void> {
|
|
145
|
+
const setStatus = async (status: string, fields: Record<string, string> | null, error: string | null) => {
|
|
146
|
+
await ports.store.prepare("UPDATE user_documents SET status = ?1, kind = ?2, fields = ?3, error = ?4, updated_at = ?5 WHERE id = ?6")
|
|
147
|
+
.bind(status, fields ? extractKind(fields) : null, fields ? JSON.stringify(fields) : null, error, new Date().toISOString(), row.id)
|
|
148
|
+
.run();
|
|
149
|
+
};
|
|
150
|
+
try {
|
|
151
|
+
let text: string;
|
|
152
|
+
if (mime === "application/pdf") {
|
|
153
|
+
text = await pdfTextLayer(bytes);
|
|
154
|
+
if (!text || text.replace(/\s+/g, "").length < 40) {
|
|
155
|
+
await setStatus("no_text_layer", null, "This PDF has no extractable text layer. Export page images (PNG/JPEG) and import those — the vision lane reads them directly.");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
// images: the vision lane transcribes (the nameplate pattern —
|
|
160
|
+
// transcription-first framing beats extract-a-JSON on plates)
|
|
161
|
+
text = await visionTranscribe(ports.model, mime, bytes);
|
|
162
|
+
if (!text || text.replace(/\s+/g, "").length < 20) {
|
|
163
|
+
await setStatus("failed", null, "The vision lane could not read this image. Try a sharper photo.");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const extraction = await structureDocument(ports.model, row.name, mime, text);
|
|
168
|
+
// the lane chunk: the summary leads, the verbatim text carries the
|
|
169
|
+
// rest — the federation cites THIS document's content
|
|
170
|
+
const fieldsLine = Object.entries(extraction.fields).map(([k, v]) => `${k}: ${v}`).join("; ");
|
|
171
|
+
const chunkText = `Member document (${extraction.kind}) — ${row.name}. ${fieldsLine}. ${extraction.summary}\n\n${extraction.text.slice(0, 8000)}`;
|
|
172
|
+
const [vector] = await ports.model.embed([chunkText]);
|
|
173
|
+
await ports.lane.upsert([
|
|
174
|
+
{
|
|
175
|
+
id: userDocumentChunkId(row.id),
|
|
176
|
+
values: vector,
|
|
177
|
+
metadata: { user_sub: row.owner_sub, doc_id: row.id, kind: extraction.kind, name: row.name },
|
|
178
|
+
},
|
|
179
|
+
]);
|
|
180
|
+
await setStatus("ready", { kind: extraction.kind, ...extraction.fields, summary: extraction.summary }, null);
|
|
181
|
+
} catch (e) {
|
|
182
|
+
await setStatus("failed", null, String(e).slice(0, 200)).catch(() => {});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function extractKind(fields: Record<string, string>): string {
|
|
187
|
+
return fields.kind || "other";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function visionTranscribe(model: ModelRunner, mime: string, bytes: Uint8Array): Promise<string> {
|
|
191
|
+
let binary = "";
|
|
192
|
+
const CHUNK = 0x8000;
|
|
193
|
+
for (let i = 0; i < bytes.length; i += CHUNK) binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
194
|
+
const dataUrl = `data:${mime};base64,${btoa(binary)}`;
|
|
195
|
+
const res = await model.run({
|
|
196
|
+
model: MODELS.member,
|
|
197
|
+
messages: [
|
|
198
|
+
{ role: "system", content: "Transcribe everything you can read in this document image — certificate numbers, holders, models, values, dates, clause references. Transcribe first; organize nothing." },
|
|
199
|
+
{ role: "user", content: [{ type: "image_url", image_url: { url: dataUrl } }] },
|
|
200
|
+
],
|
|
201
|
+
maxTokens: 2048,
|
|
202
|
+
effort: "low",
|
|
203
|
+
temperature: 0.15,
|
|
204
|
+
});
|
|
205
|
+
return res.text ?? "";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** One structuring call: classify + typed fields + a retrieval summary.
|
|
209
|
+
* The response's JSON object is taken; prose around it is ignored. */
|
|
210
|
+
async function structureDocument(model: ModelRunner, name: string, mime: string, text: string): Promise<Extraction> {
|
|
211
|
+
const res = await model.run({
|
|
212
|
+
model: MODELS.member,
|
|
213
|
+
messages: [
|
|
214
|
+
{ role: "system", content: `You classify and structure an imported metrology document. Answer with ONE JSON object: {"kind": "certificate"|"test_report"|"other", "fields": {…short typed fields…}, "summary": "one paragraph a retrieval engine can answer from"}. Certificates carry certificate numbers (like R60/2021-XX1-24.01), holders, models, issue dates. Test reports carry measured values, uncertainties, instruments, clause references. Fields use snake_case names, values verbatim from the text. Never invent a value the text does not carry.` },
|
|
215
|
+
{ role: "user", content: `Document name: ${name}\nFormat: ${mime}\n\nText:\n${text.slice(0, 12000)}` },
|
|
216
|
+
],
|
|
217
|
+
maxTokens: 2048,
|
|
218
|
+
effort: "low",
|
|
219
|
+
temperature: 0.15,
|
|
220
|
+
});
|
|
221
|
+
const raw = res.text ?? "";
|
|
222
|
+
const m = /\{[\s\S]*\}/.exec(raw);
|
|
223
|
+
if (!m) throw new Error("the structuring call returned no JSON object");
|
|
224
|
+
const parsed = JSON.parse(m[0]) as { kind?: string; fields?: Record<string, unknown>; summary?: string };
|
|
225
|
+
const fields: Record<string, string> = {};
|
|
226
|
+
for (const [k, v] of Object.entries(parsed.fields ?? {})) {
|
|
227
|
+
if (typeof v === "string" || typeof v === "number") fields[k.slice(0, 40)] = String(v).slice(0, 200);
|
|
228
|
+
}
|
|
229
|
+
const kind = parsed.kind === "certificate" || parsed.kind === "test_report" ? parsed.kind : "other";
|
|
230
|
+
return { kind, fields, summary: String(parsed.summary ?? "").slice(0, 1200), text };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The digital-born PDF's text layer: inflate FlateDecode content
|
|
234
|
+
* streams and scan the text-showing operators (Tj/TJ/'/"). Scanned
|
|
235
|
+
* PDFs have no text layer — the honest no_text_layer status answers
|
|
236
|
+
* them. No external dependency; Workers' DecompressionStream does the
|
|
237
|
+
* inflate. */
|
|
238
|
+
export async function pdfTextLayer(bytes: Uint8Array): Promise<string> {
|
|
239
|
+
const raw = new TextDecoder("latin1").decode(bytes);
|
|
240
|
+
const parts: string[] = [];
|
|
241
|
+
const streamRe = /stream\r?\n?/g;
|
|
242
|
+
let m: RegExpExecArray | null;
|
|
243
|
+
while ((m = streamRe.exec(raw))) {
|
|
244
|
+
const start = m.index + m[0].length;
|
|
245
|
+
const end = raw.indexOf("endstream", start);
|
|
246
|
+
if (end < 0) break;
|
|
247
|
+
const chunk = raw.slice(start, end);
|
|
248
|
+
streamRe.lastIndex = end;
|
|
249
|
+
// Flate streams begin 0x78; raw text streams (uncompressed) carry
|
|
250
|
+
// the operators directly
|
|
251
|
+
let content = "";
|
|
252
|
+
if (chunk.charCodeAt(0) === 0x78) {
|
|
253
|
+
try {
|
|
254
|
+
const ds = new DecompressionStream("deflate");
|
|
255
|
+
const stream = new Blob([new Uint8Array([...chunk].map((c) => c.charCodeAt(0) & 0xff))]).stream().pipeThrough(ds);
|
|
256
|
+
content = await new Response(stream).text();
|
|
257
|
+
} catch {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
} else if (/\bTj\b|\bTJ\b/.test(chunk)) {
|
|
261
|
+
content = chunk;
|
|
262
|
+
} else {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
parts.push(pdfTextOps(content));
|
|
266
|
+
if (parts.join("").length > 200_000) break;
|
|
267
|
+
}
|
|
268
|
+
return parts.join("\n");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Pull the string literals out of one content stream's text ops. */
|
|
272
|
+
export function pdfTextOps(content: string): string {
|
|
273
|
+
const out: string[] = [];
|
|
274
|
+
const re = /\((?:\\.|[^\\()])*\)/g;
|
|
275
|
+
let m: RegExpExecArray | null;
|
|
276
|
+
while ((m = re.exec(content))) {
|
|
277
|
+
let s = m[0].slice(1, -1);
|
|
278
|
+
s = s.replace(/\\([nrtbf()/\\])/g, (_, c: string) => ({ n: "\n", r: "", t: "\t", b: "", f: "", "(": "(", ")": ")", "/": "/", "\\": "\\" }[c] ?? c));
|
|
279
|
+
s = s.replace(/\\([0-7]{1,3})/g, (_, o: string) => String.fromCharCode(parseInt(o, 8)));
|
|
280
|
+
if (s.trim()) out.push(s);
|
|
281
|
+
}
|
|
282
|
+
return out.join(" ");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Deletion is immediate and total: the R2 object, the lane vectors,
|
|
286
|
+
* the row. The caller has already checked ownership. */
|
|
287
|
+
export async function deleteUserDocument(ports: UserDocumentPorts, sub: string, id: string): Promise<UserDocumentRow | null> {
|
|
288
|
+
const row = await getUserDocument(ports.store, sub, id);
|
|
289
|
+
if (!row) return null;
|
|
290
|
+
if (row.r2_key) await ports.blobs.delete(row.r2_key).catch(() => {});
|
|
291
|
+
await ports.lane.deleteIds([userDocumentChunkId(id)]).catch(() => {});
|
|
292
|
+
await ports.store.prepare("DELETE FROM user_documents WHERE id = ?1 AND owner_sub = ?2").bind(id, sub).run();
|
|
293
|
+
return row;
|
|
294
|
+
}
|
|
@@ -29,10 +29,18 @@ binding = "AI"
|
|
|
29
29
|
binding = "UNIT_ASSETS"
|
|
30
30
|
bucket_name = "rag-public-assets"
|
|
31
31
|
|
|
32
|
+
[[r2_buckets]]
|
|
33
|
+
binding = "USER_DOCS"
|
|
34
|
+
bucket_name = "rag-user-docs"
|
|
35
|
+
|
|
32
36
|
[[vectorize]]
|
|
33
37
|
binding = "VECTORIZE"
|
|
34
38
|
index_name = "idx_oiml_public_v2"
|
|
35
39
|
|
|
40
|
+
[[vectorize]]
|
|
41
|
+
binding = "USER_DOCS_INDEX"
|
|
42
|
+
index_name = "idx-user-docs"
|
|
43
|
+
|
|
36
44
|
# Comparison lanes (TODO.model-rag): the annealment experiment indexes
|
|
37
45
|
[[vectorize]]
|
|
38
46
|
binding = "EXP_PRIMMEL"
|