@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
|
@@ -4,16 +4,16 @@ import {
|
|
|
4
4
|
handleMemories,
|
|
5
5
|
scoreFaithfulness,
|
|
6
6
|
scoreJudge
|
|
7
|
-
} from "../../chunk-
|
|
7
|
+
} from "../../chunk-YVQVT7GD.js";
|
|
8
8
|
import "../../chunk-IRCDOFP4.js";
|
|
9
9
|
import {
|
|
10
10
|
TOOLS_REGISTRY,
|
|
11
11
|
bindModelNode,
|
|
12
12
|
modelGroundingBlock
|
|
13
|
-
} from "../../chunk-
|
|
13
|
+
} from "../../chunk-VRWNS4AI.js";
|
|
14
14
|
import {
|
|
15
15
|
handleSearch
|
|
16
|
-
} from "../../chunk-
|
|
16
|
+
} from "../../chunk-ZZGKC6WO.js";
|
|
17
17
|
import {
|
|
18
18
|
buildMessages,
|
|
19
19
|
citations,
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
sessionFrom,
|
|
39
39
|
telemetry,
|
|
40
40
|
understandQuery
|
|
41
|
-
} from "../../chunk-
|
|
41
|
+
} from "../../chunk-4J47P52J.js";
|
|
42
42
|
import {
|
|
43
43
|
embed,
|
|
44
44
|
generateOnce
|
|
@@ -76,8 +76,11 @@ import {
|
|
|
76
76
|
} from "../../chunk-3FYJM7LH.js";
|
|
77
77
|
import {
|
|
78
78
|
cfBlobs,
|
|
79
|
+
cfModelRunner,
|
|
80
|
+
cfStore,
|
|
81
|
+
cfVectorIndex,
|
|
79
82
|
portModelRunner
|
|
80
|
-
} from "../../chunk-
|
|
83
|
+
} from "../../chunk-YGQCVPX6.js";
|
|
81
84
|
|
|
82
85
|
// workers/worker_public/src/conversations.ts
|
|
83
86
|
var ID_RE = /^[a-zA-Z0-9_-]{8,64}$/;
|
|
@@ -197,6 +200,201 @@ async function handleAppendMessage(env, sub, req, convId) {
|
|
|
197
200
|
return json({ id: mid }, 201);
|
|
198
201
|
}
|
|
199
202
|
|
|
203
|
+
// workers/worker_public/src/userDocuments.ts
|
|
204
|
+
var USER_DOC_LIMIT = 10;
|
|
205
|
+
var USER_DOC_MAX_BYTES = 20 * 1024 * 1024;
|
|
206
|
+
var ACCEPTED_MIMES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp", "application/pdf"]);
|
|
207
|
+
function userDocumentChunkId(docId) {
|
|
208
|
+
return `ud-${docId}`;
|
|
209
|
+
}
|
|
210
|
+
async function listUserDocuments(store, sub) {
|
|
211
|
+
const res = await store.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").bind(sub).all();
|
|
212
|
+
return { documents: res.results ?? [] };
|
|
213
|
+
}
|
|
214
|
+
async function getUserDocument(store, sub, id) {
|
|
215
|
+
const row = await store.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").bind(id, sub).first();
|
|
216
|
+
return row ?? null;
|
|
217
|
+
}
|
|
218
|
+
async function quotaRefusal(store, sub) {
|
|
219
|
+
const row = await store.prepare("SELECT COUNT(*) AS n FROM user_documents WHERE owner_sub = ?1").bind(sub).first();
|
|
220
|
+
const n = Number(row?.n ?? 0);
|
|
221
|
+
if (n >= USER_DOC_LIMIT) {
|
|
222
|
+
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" } });
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
async function createImport(ports, waitUntil, sub, file) {
|
|
227
|
+
const mime = file.type || "";
|
|
228
|
+
if (!ACCEPTED_MIMES.has(mime)) {
|
|
229
|
+
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" } }) };
|
|
230
|
+
}
|
|
231
|
+
if (file.size > USER_DOC_MAX_BYTES) {
|
|
232
|
+
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" } }) };
|
|
233
|
+
}
|
|
234
|
+
const refused = await quotaRefusal(ports.store, sub);
|
|
235
|
+
if (refused) return { error: refused };
|
|
236
|
+
const id = crypto.randomUUID();
|
|
237
|
+
const r2Key = `${sub}/${id}`;
|
|
238
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
239
|
+
await ports.blobs.put(r2Key, bytes.buffer, mime);
|
|
240
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
241
|
+
const row = {
|
|
242
|
+
id,
|
|
243
|
+
owner_sub: sub,
|
|
244
|
+
name: file.name || "document",
|
|
245
|
+
kind: null,
|
|
246
|
+
status: "importing",
|
|
247
|
+
bytes: bytes.byteLength,
|
|
248
|
+
r2_key: r2Key,
|
|
249
|
+
fields: null,
|
|
250
|
+
error: null,
|
|
251
|
+
created_at: now,
|
|
252
|
+
updated_at: now
|
|
253
|
+
};
|
|
254
|
+
await ports.store.prepare(
|
|
255
|
+
"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)"
|
|
256
|
+
).bind(id, sub, row.name, row.kind, row.status, row.bytes, row.r2_key, row.fields, row.error, now, now).run();
|
|
257
|
+
waitUntil(processImport(ports, row, mime, bytes).catch(() => {
|
|
258
|
+
}));
|
|
259
|
+
return { document: row };
|
|
260
|
+
}
|
|
261
|
+
async function processImport(ports, row, mime, bytes) {
|
|
262
|
+
const setStatus = async (status, fields, error) => {
|
|
263
|
+
await ports.store.prepare("UPDATE user_documents SET status = ?1, kind = ?2, fields = ?3, error = ?4, updated_at = ?5 WHERE id = ?6").bind(status, fields ? extractKind(fields) : null, fields ? JSON.stringify(fields) : null, error, (/* @__PURE__ */ new Date()).toISOString(), row.id).run();
|
|
264
|
+
};
|
|
265
|
+
try {
|
|
266
|
+
let text;
|
|
267
|
+
if (mime === "application/pdf") {
|
|
268
|
+
text = await pdfTextLayer(bytes);
|
|
269
|
+
if (!text || text.replace(/\s+/g, "").length < 40) {
|
|
270
|
+
await setStatus("no_text_layer", null, "This PDF has no extractable text layer. Export page images (PNG/JPEG) and import those \u2014 the vision lane reads them directly.");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
text = await visionTranscribe(ports.model, mime, bytes);
|
|
275
|
+
if (!text || text.replace(/\s+/g, "").length < 20) {
|
|
276
|
+
await setStatus("failed", null, "The vision lane could not read this image. Try a sharper photo.");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const extraction = await structureDocument(ports.model, row.name, mime, text);
|
|
281
|
+
const fieldsLine = Object.entries(extraction.fields).map(([k, v]) => `${k}: ${v}`).join("; ");
|
|
282
|
+
const chunkText = `Member document (${extraction.kind}) \u2014 ${row.name}. ${fieldsLine}. ${extraction.summary}
|
|
283
|
+
|
|
284
|
+
${extraction.text.slice(0, 8e3)}`;
|
|
285
|
+
const [vector] = await ports.model.embed([chunkText]);
|
|
286
|
+
await ports.lane.upsert([
|
|
287
|
+
{
|
|
288
|
+
id: userDocumentChunkId(row.id),
|
|
289
|
+
values: vector,
|
|
290
|
+
metadata: { user_sub: row.owner_sub, doc_id: row.id, kind: extraction.kind, name: row.name }
|
|
291
|
+
}
|
|
292
|
+
]);
|
|
293
|
+
await setStatus("ready", { kind: extraction.kind, ...extraction.fields, summary: extraction.summary }, null);
|
|
294
|
+
} catch (e) {
|
|
295
|
+
await setStatus("failed", null, String(e).slice(0, 200)).catch(() => {
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function extractKind(fields) {
|
|
300
|
+
return fields.kind || "other";
|
|
301
|
+
}
|
|
302
|
+
async function visionTranscribe(model, mime, bytes) {
|
|
303
|
+
let binary = "";
|
|
304
|
+
const CHUNK = 32768;
|
|
305
|
+
for (let i = 0; i < bytes.length; i += CHUNK) binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
306
|
+
const dataUrl = `data:${mime};base64,${btoa(binary)}`;
|
|
307
|
+
const res = await model.run({
|
|
308
|
+
model: MODELS.member,
|
|
309
|
+
messages: [
|
|
310
|
+
{ role: "system", content: "Transcribe everything you can read in this document image \u2014 certificate numbers, holders, models, values, dates, clause references. Transcribe first; organize nothing." },
|
|
311
|
+
{ role: "user", content: [{ type: "image_url", image_url: { url: dataUrl } }] }
|
|
312
|
+
],
|
|
313
|
+
maxTokens: 2048,
|
|
314
|
+
effort: "low",
|
|
315
|
+
temperature: 0.15
|
|
316
|
+
});
|
|
317
|
+
return res.text ?? "";
|
|
318
|
+
}
|
|
319
|
+
async function structureDocument(model, name, mime, text) {
|
|
320
|
+
const res = await model.run({
|
|
321
|
+
model: MODELS.member,
|
|
322
|
+
messages: [
|
|
323
|
+
{ role: "system", content: `You classify and structure an imported metrology document. Answer with ONE JSON object: {"kind": "certificate"|"test_report"|"other", "fields": {\u2026short typed fields\u2026}, "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.` },
|
|
324
|
+
{ role: "user", content: `Document name: ${name}
|
|
325
|
+
Format: ${mime}
|
|
326
|
+
|
|
327
|
+
Text:
|
|
328
|
+
${text.slice(0, 12e3)}` }
|
|
329
|
+
],
|
|
330
|
+
maxTokens: 2048,
|
|
331
|
+
effort: "low",
|
|
332
|
+
temperature: 0.15
|
|
333
|
+
});
|
|
334
|
+
const raw = res.text ?? "";
|
|
335
|
+
const m = /\{[\s\S]*\}/.exec(raw);
|
|
336
|
+
if (!m) throw new Error("the structuring call returned no JSON object");
|
|
337
|
+
const parsed = JSON.parse(m[0]);
|
|
338
|
+
const fields = {};
|
|
339
|
+
for (const [k, v] of Object.entries(parsed.fields ?? {})) {
|
|
340
|
+
if (typeof v === "string" || typeof v === "number") fields[k.slice(0, 40)] = String(v).slice(0, 200);
|
|
341
|
+
}
|
|
342
|
+
const kind = parsed.kind === "certificate" || parsed.kind === "test_report" ? parsed.kind : "other";
|
|
343
|
+
return { kind, fields, summary: String(parsed.summary ?? "").slice(0, 1200), text };
|
|
344
|
+
}
|
|
345
|
+
async function pdfTextLayer(bytes) {
|
|
346
|
+
const raw = new TextDecoder("latin1").decode(bytes);
|
|
347
|
+
const parts = [];
|
|
348
|
+
const streamRe = /stream\r?\n?/g;
|
|
349
|
+
let m;
|
|
350
|
+
while (m = streamRe.exec(raw)) {
|
|
351
|
+
const start = m.index + m[0].length;
|
|
352
|
+
const end = raw.indexOf("endstream", start);
|
|
353
|
+
if (end < 0) break;
|
|
354
|
+
const chunk = raw.slice(start, end);
|
|
355
|
+
streamRe.lastIndex = end;
|
|
356
|
+
let content = "";
|
|
357
|
+
if (chunk.charCodeAt(0) === 120) {
|
|
358
|
+
try {
|
|
359
|
+
const ds = new DecompressionStream("deflate");
|
|
360
|
+
const stream = new Blob([new Uint8Array([...chunk].map((c) => c.charCodeAt(0) & 255))]).stream().pipeThrough(ds);
|
|
361
|
+
content = await new Response(stream).text();
|
|
362
|
+
} catch {
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
} else if (/\bTj\b|\bTJ\b/.test(chunk)) {
|
|
366
|
+
content = chunk;
|
|
367
|
+
} else {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
parts.push(pdfTextOps(content));
|
|
371
|
+
if (parts.join("").length > 2e5) break;
|
|
372
|
+
}
|
|
373
|
+
return parts.join("\n");
|
|
374
|
+
}
|
|
375
|
+
function pdfTextOps(content) {
|
|
376
|
+
const out = [];
|
|
377
|
+
const re = /\((?:\\.|[^\\()])*\)/g;
|
|
378
|
+
let m;
|
|
379
|
+
while (m = re.exec(content)) {
|
|
380
|
+
let s = m[0].slice(1, -1);
|
|
381
|
+
s = s.replace(/\\([nrtbf()/\\])/g, (_, c) => ({ n: "\n", r: "", t: " ", b: "", f: "", "(": "(", ")": ")", "/": "/", "\\": "\\" })[c] ?? c);
|
|
382
|
+
s = s.replace(/\\([0-7]{1,3})/g, (_, o) => String.fromCharCode(parseInt(o, 8)));
|
|
383
|
+
if (s.trim()) out.push(s);
|
|
384
|
+
}
|
|
385
|
+
return out.join(" ");
|
|
386
|
+
}
|
|
387
|
+
async function deleteUserDocument(ports, sub, id) {
|
|
388
|
+
const row = await getUserDocument(ports.store, sub, id);
|
|
389
|
+
if (!row) return null;
|
|
390
|
+
if (row.r2_key) await ports.blobs.delete(row.r2_key).catch(() => {
|
|
391
|
+
});
|
|
392
|
+
await ports.lane.deleteIds([userDocumentChunkId(id)]).catch(() => {
|
|
393
|
+
});
|
|
394
|
+
await ports.store.prepare("DELETE FROM user_documents WHERE id = ?1 AND owner_sub = ?2").bind(id, sub).run();
|
|
395
|
+
return row;
|
|
396
|
+
}
|
|
397
|
+
|
|
200
398
|
// workers/worker_public/src/projects.ts
|
|
201
399
|
var MAX_FILES = 10;
|
|
202
400
|
var MAX_CONTENT = 8e3;
|
|
@@ -1175,13 +1373,13 @@ async function callToolOnce(env, ctx, _req, tier, key, name, args, t0) {
|
|
|
1175
1373
|
// stream:false forces the JSON lane (anon defaults to SSE)
|
|
1176
1374
|
body: JSON.stringify({ ...args, stream: false })
|
|
1177
1375
|
});
|
|
1178
|
-
const { runTool, TOOLS_REGISTRY: TOOLS_REGISTRY2 } = await import("../../tools-
|
|
1376
|
+
const { runTool, TOOLS_REGISTRY: TOOLS_REGISTRY2 } = await import("../../tools-HLA7O2ZK.js");
|
|
1179
1377
|
let payload;
|
|
1180
1378
|
if (TOOLS_REGISTRY2.some((t) => t.name === name && t.audiences.includes("mcp"))) {
|
|
1181
1379
|
const r = await runTool({ ...env, DB: env.DB }, { name, args }, "mcp");
|
|
1182
1380
|
payload = r ? { tool: r.name, query: r.query, result: r.output } : { error: { message: `tool ${name} returned nothing for the given arguments` } };
|
|
1183
1381
|
} else {
|
|
1184
|
-
const res = name === "ask" ? await (await import("../../ask-
|
|
1382
|
+
const res = name === "ask" ? await (await import("../../ask-YWYXQ6PE.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-N37WULEM.js")).handleSearch(env, ctx, inner, tier, key);
|
|
1185
1383
|
payload = await res.json().catch(() => ({ error: { message: "tool transport failed", status: res.status } }));
|
|
1186
1384
|
}
|
|
1187
1385
|
telemetry(env, ctx, tier, `mcp:${name}`, null, !payload?.error, JSON.stringify(payload).length, await sha256Hex(`${name}:${JSON.stringify(args ?? {})}`), void 0, void 0, { durationMs: Date.now() - t0 });
|
|
@@ -1363,6 +1561,26 @@ var OPENAPI_SURFACE = [
|
|
|
1363
1561
|
"pattern": "/api/memories/:id",
|
|
1364
1562
|
"operationId": "deleteMemory"
|
|
1365
1563
|
},
|
|
1564
|
+
{
|
|
1565
|
+
"method": "GET",
|
|
1566
|
+
"pattern": "/api/user-documents",
|
|
1567
|
+
"operationId": "listUserDocuments"
|
|
1568
|
+
},
|
|
1569
|
+
{
|
|
1570
|
+
"method": "POST",
|
|
1571
|
+
"pattern": "/api/user-documents",
|
|
1572
|
+
"operationId": "createUserDocument"
|
|
1573
|
+
},
|
|
1574
|
+
{
|
|
1575
|
+
"method": "GET",
|
|
1576
|
+
"pattern": "/api/user-documents/:id",
|
|
1577
|
+
"operationId": "getUserDocument"
|
|
1578
|
+
},
|
|
1579
|
+
{
|
|
1580
|
+
"method": "DELETE",
|
|
1581
|
+
"pattern": "/api/user-documents/:id",
|
|
1582
|
+
"operationId": "deleteUserDocument"
|
|
1583
|
+
},
|
|
1366
1584
|
{
|
|
1367
1585
|
"method": "GET",
|
|
1368
1586
|
"pattern": "/api/projects",
|
|
@@ -1576,6 +1794,45 @@ async function memoriesRoute(c) {
|
|
|
1576
1794
|
if (refused) return withCors(refused, corsHeaders(c.req));
|
|
1577
1795
|
return withCors(await handleMemories(c.env, who.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
|
|
1578
1796
|
}
|
|
1797
|
+
function userDocumentPorts(env) {
|
|
1798
|
+
return { store: cfStore(env.DB), blobs: cfBlobs(env.USER_DOCS), lane: cfVectorIndex(env.USER_DOCS_INDEX), model: cfModelRunner(env.AI) };
|
|
1799
|
+
}
|
|
1800
|
+
async function userDocumentsRoute(c) {
|
|
1801
|
+
const who = await opIdentity(c, "Sign in to manage your documents");
|
|
1802
|
+
if (who instanceof Response) return withCors(who, corsHeaders(c.req));
|
|
1803
|
+
if (c.req.method === "GET") {
|
|
1804
|
+
const out = await listUserDocuments(userDocumentPorts(c.env).store, who.sub);
|
|
1805
|
+
return withCors(json({ ...out, quota: { used: out.documents.length, limit: USER_DOC_LIMIT } }), corsHeaders(c.req));
|
|
1806
|
+
}
|
|
1807
|
+
if (c.req.method === "POST") {
|
|
1808
|
+
const refused = opWriteRefused(c, who);
|
|
1809
|
+
if (refused) return withCors(refused, corsHeaders(c.req));
|
|
1810
|
+
const form = await c.req.formData().catch(() => null);
|
|
1811
|
+
const file = form?.get("file");
|
|
1812
|
+
if (!file || typeof file !== "object" || !("arrayBuffer" in file) || !("size" in file)) {
|
|
1813
|
+
return err(400, "invalid_input", 'Upload a file in the multipart "file" field.');
|
|
1814
|
+
}
|
|
1815
|
+
const blob = file;
|
|
1816
|
+
const out = await createImport(userDocumentPorts(c.env), c.ctx.waitUntil.bind(c.ctx), who.sub, blob);
|
|
1817
|
+
if ("error" in out) return withCors(out.error, corsHeaders(c.req));
|
|
1818
|
+
return withCors(json({ document: out.document }, 202), corsHeaders(c.req));
|
|
1819
|
+
}
|
|
1820
|
+
return err(405, "method_not_allowed", `The path is served, but not with ${c.req.method}`);
|
|
1821
|
+
}
|
|
1822
|
+
async function userDocumentItemRoute(c) {
|
|
1823
|
+
const who = await opIdentity(c, "Sign in to manage your documents");
|
|
1824
|
+
if (who instanceof Response) return withCors(who, corsHeaders(c.req));
|
|
1825
|
+
if (c.req.method === "GET") {
|
|
1826
|
+
const row = await getUserDocument(userDocumentPorts(c.env).store, who.sub, c.params.id);
|
|
1827
|
+
if (!row) return err(404, "not_found", "No such document for this member");
|
|
1828
|
+
return withCors(json({ document: row }), corsHeaders(c.req));
|
|
1829
|
+
}
|
|
1830
|
+
const refused = opWriteRefused(c, who);
|
|
1831
|
+
if (refused) return withCors(refused, corsHeaders(c.req));
|
|
1832
|
+
const gone = await deleteUserDocument(userDocumentPorts(c.env), who.sub, c.params.id);
|
|
1833
|
+
if (!gone) return err(404, "not_found", "No such document for this member");
|
|
1834
|
+
return withCors(json({ ok: true }), corsHeaders(c.req));
|
|
1835
|
+
}
|
|
1579
1836
|
async function projectsRoute(c) {
|
|
1580
1837
|
const who = await opIdentity(c, "Sign in to use projects");
|
|
1581
1838
|
if (who instanceof Response) return withCors(who, corsHeaders(c.req));
|
|
@@ -1974,6 +2231,10 @@ var OPENAPI_HANDLERS = {
|
|
|
1974
2231
|
getAttachment: attachmentsGetRoute,
|
|
1975
2232
|
deleteAttachment: attachmentsDeleteRoute,
|
|
1976
2233
|
listMemories: memoriesRoute,
|
|
2234
|
+
listUserDocuments: userDocumentsRoute,
|
|
2235
|
+
createUserDocument: userDocumentsRoute,
|
|
2236
|
+
getUserDocument: userDocumentItemRoute,
|
|
2237
|
+
deleteUserDocument: userDocumentItemRoute,
|
|
1977
2238
|
createMemory: memoriesRoute,
|
|
1978
2239
|
deleteMemory: memoriesRoute,
|
|
1979
2240
|
listProjects: projectsRoute,
|
package/package.json
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"playwright": "^1.62.1",
|
|
28
28
|
"yaml": "^2.9.1"
|
|
29
29
|
},
|
|
30
|
-
"version": "0.2.
|
|
30
|
+
"version": "0.2.182",
|
|
31
31
|
"description": "The Konneal engine: the publisher-agnostic build pipeline and API plane for standards intelligence (retrieval, answer contract, verdicts, evaluation).",
|
|
32
32
|
"license": "BSD-3-Clause",
|
|
33
33
|
"type": "module",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- The member's own documents (TODO.sota/20 v1): imported certificates
|
|
2
|
+
-- and scanned reports, privately indexed in the user lane, answered
|
|
3
|
+
-- beside the public corpus. 10 documents per member, 20 MB each, held
|
|
4
|
+
-- until the member deletes; deletion is immediate and total.
|
|
5
|
+
CREATE TABLE IF NOT EXISTS user_documents (
|
|
6
|
+
id TEXT PRIMARY KEY,
|
|
7
|
+
owner_sub TEXT NOT NULL,
|
|
8
|
+
name TEXT NOT NULL,
|
|
9
|
+
kind TEXT,
|
|
10
|
+
status TEXT NOT NULL,
|
|
11
|
+
bytes INTEGER NOT NULL,
|
|
12
|
+
r2_key TEXT,
|
|
13
|
+
fields TEXT,
|
|
14
|
+
error TEXT,
|
|
15
|
+
created_at TEXT NOT NULL,
|
|
16
|
+
updated_at TEXT NOT NULL
|
|
17
|
+
);
|
|
18
|
+
CREATE INDEX IF NOT EXISTS idx_user_documents_owner ON user_documents (owner_sub);
|
|
@@ -633,6 +633,78 @@ paths:
|
|
|
633
633
|
responses:
|
|
634
634
|
'200':
|
|
635
635
|
description: The note is deleted.
|
|
636
|
+
/api/user-documents:
|
|
637
|
+
get:
|
|
638
|
+
tags: [UserDocuments]
|
|
639
|
+
summary: List the member's imported documents
|
|
640
|
+
operationId: listUserDocuments
|
|
641
|
+
responses:
|
|
642
|
+
'200':
|
|
643
|
+
description: The documents with their import status and the quota.
|
|
644
|
+
post:
|
|
645
|
+
tags: [UserDocuments]
|
|
646
|
+
summary: Import a document (a certificate or a test report)
|
|
647
|
+
description: >-
|
|
648
|
+
Member-gated. Upload one PNG/JPEG/WebP image or a PDF (20 MB
|
|
649
|
+
max, 10 documents held per member until deleted). The import
|
|
650
|
+
runs asynchronously: the route answers 202 with the importing
|
|
651
|
+
row, the status endpoint (or a re-list) tells the rest. Images
|
|
652
|
+
ride the vision lane; a PDF's text layer is extracted (a
|
|
653
|
+
scanned PDF with no text layer is answered honestly with the
|
|
654
|
+
no_text_layer status). Retention: held until the member
|
|
655
|
+
deletes; deletion is immediate and total.
|
|
656
|
+
operationId: createUserDocument
|
|
657
|
+
requestBody:
|
|
658
|
+
required: true
|
|
659
|
+
content:
|
|
660
|
+
multipart/form-data:
|
|
661
|
+
schema:
|
|
662
|
+
type: object
|
|
663
|
+
required: [file]
|
|
664
|
+
properties:
|
|
665
|
+
file:
|
|
666
|
+
type: string
|
|
667
|
+
format: binary
|
|
668
|
+
responses:
|
|
669
|
+
'202':
|
|
670
|
+
description: The import is running; the row carries its status.
|
|
671
|
+
'413':
|
|
672
|
+
description: The document exceeds the 20 MB limit.
|
|
673
|
+
'415':
|
|
674
|
+
description: Unsupported media type.
|
|
675
|
+
'403':
|
|
676
|
+
description: The member holds the maximum of 10 documents.
|
|
677
|
+
/api/user-documents/{id}:
|
|
678
|
+
get:
|
|
679
|
+
tags: [UserDocuments]
|
|
680
|
+
summary: One imported document's status and extracted fields
|
|
681
|
+
operationId: getUserDocument
|
|
682
|
+
parameters:
|
|
683
|
+
- name: id
|
|
684
|
+
in: path
|
|
685
|
+
required: true
|
|
686
|
+
schema: { type: string }
|
|
687
|
+
responses:
|
|
688
|
+
'200':
|
|
689
|
+
description: The document row.
|
|
690
|
+
'404':
|
|
691
|
+
description: No such document for this member.
|
|
692
|
+
delete:
|
|
693
|
+
tags: [UserDocuments]
|
|
694
|
+
summary: Delete an imported document
|
|
695
|
+
description: Immediate and total — the stored file, the private-lane vectors and the row go together.
|
|
696
|
+
operationId: deleteUserDocument
|
|
697
|
+
parameters:
|
|
698
|
+
- name: id
|
|
699
|
+
in: path
|
|
700
|
+
required: true
|
|
701
|
+
schema: { type: string }
|
|
702
|
+
responses:
|
|
703
|
+
'200':
|
|
704
|
+
description: The document is deleted.
|
|
705
|
+
'404':
|
|
706
|
+
description: No such document for this member.
|
|
707
|
+
|
|
636
708
|
/api/projects:
|
|
637
709
|
get:
|
|
638
710
|
tags: [Projects]
|
|
@@ -262,3 +262,19 @@ CREATE INDEX IF NOT EXISTS idx_attachments_sub ON attachments (sub);
|
|
|
262
262
|
|
|
263
263
|
-- Tier-2 image reuse: the user message's stored attachment
|
|
264
264
|
ALTER TABLE messages ADD COLUMN attachment_id TEXT;
|
|
265
|
+
|
|
266
|
+
-- The member's own documents (TODO.sota/20 v1): the import lane.
|
|
267
|
+
CREATE TABLE IF NOT EXISTS user_documents (
|
|
268
|
+
id TEXT PRIMARY KEY,
|
|
269
|
+
owner_sub TEXT NOT NULL,
|
|
270
|
+
name TEXT NOT NULL,
|
|
271
|
+
kind TEXT,
|
|
272
|
+
status TEXT NOT NULL,
|
|
273
|
+
bytes INTEGER NOT NULL,
|
|
274
|
+
r2_key TEXT,
|
|
275
|
+
fields TEXT,
|
|
276
|
+
error TEXT,
|
|
277
|
+
created_at TEXT NOT NULL,
|
|
278
|
+
updated_at TEXT NOT NULL
|
|
279
|
+
);
|
|
280
|
+
CREATE INDEX IF NOT EXISTS idx_user_documents_owner ON user_documents (owner_sub);
|
|
@@ -1722,20 +1722,23 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
1722
1722
|
const tq = await chargeAnswerTokens(env, bucketId, stats, messageChars(messages!), full.length, probeTokensUsed);
|
|
1723
1723
|
send({ type: "quota", quota: { ...quota, ...tq, credits: await creditBudget(env, bucketId), credit_estimate: creditPreview } });
|
|
1724
1724
|
// the blind-answer guard ON THE STREAM PATH (the JSON path has
|
|
1725
|
-
// had it since 0.2.80):
|
|
1726
|
-
//
|
|
1727
|
-
//
|
|
1728
|
-
//
|
|
1729
|
-
//
|
|
1730
|
-
//
|
|
1731
|
-
|
|
1725
|
+
// had it since 0.2.80): the register note carries rows and the
|
|
1726
|
+
// streamed text is the no-information refusal — the model
|
|
1727
|
+
// answered around the register (originally the photograph's
|
|
1728
|
+
// shape; since 0.2.179 the note rides every register-owned
|
|
1729
|
+
// question, and the text-only lane refuses just the same —
|
|
1730
|
+
// measured 2026-10-29: 11/14 standing probes with the note in
|
|
1731
|
+
// the prompt). One bounded regeneration: the retry sentinel
|
|
1732
|
+
// tells the client to clear the buffer, the agent step says
|
|
1733
|
+
// why, and the fresh tokens stream as the answer.
|
|
1734
|
+
if (register?.rows?.length && full.includes(refusalAnswer())) {
|
|
1732
1735
|
console.log("blind-answer(stream): regenerating once");
|
|
1733
1736
|
streamRestarts += 1;
|
|
1734
1737
|
mark("Re-answering from the register results");
|
|
1735
1738
|
send({ type: "retry" });
|
|
1736
1739
|
const regen = await generateStream(env, model, [
|
|
1737
1740
|
...messages.slice(0, -1),
|
|
1738
|
-
{ role: "user", content: "The
|
|
1741
|
+
{ role: "user", content: "The certificate register note above lists actual matching certificate rows. Present those rows — number, holder, model, status, and every document link — as the answer. Do not claim that nothing was provided." },
|
|
1739
1742
|
messages[messages.length - 1],
|
|
1740
1743
|
], routeEffort(routed.route));
|
|
1741
1744
|
if (regen) {
|
|
@@ -2084,12 +2087,12 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
|
2084
2087
|
// text claims nothing was provided — while the bridge succeeded and the
|
|
2085
2088
|
// register note carries actual rows. One bounded regeneration, decided on
|
|
2086
2089
|
// that exact signature; the retry carries a sterner ground instruction.
|
|
2087
|
-
if (
|
|
2090
|
+
if (register?.rows?.length && answer!.includes(refusalAnswer())) {
|
|
2088
2091
|
console.log("blind-answer: the register note carried rows but the answer refused — regenerating once");
|
|
2089
2092
|
mark("Re-answering from the register results");
|
|
2090
2093
|
const regen = await generateOnce(env, model, [
|
|
2091
2094
|
...messages.slice(0, -1),
|
|
2092
|
-
{ role: "user", content: "The
|
|
2095
|
+
{ role: "user", content: "The certificate register note above lists actual matching certificate rows. Present those rows — number, holder, model, status, and every document link — as the answer. Do not claim that nothing was provided." },
|
|
2093
2096
|
messages[messages.length - 1],
|
|
2094
2097
|
], effort);
|
|
2095
2098
|
if (regen && !regen!.includes(refusalAnswer())) {
|
|
@@ -6,6 +6,7 @@ import { handleCallback, handleLogin, handleLogout, handleMe, sessionFrom } from
|
|
|
6
6
|
import { opCfg, opMemberCanWrite, opTokenMember } from "./livedata";
|
|
7
7
|
import { handleAppendMessage, handleConversations } from "./conversations";
|
|
8
8
|
import { handleMemories } from "./memories";
|
|
9
|
+
import { createImport, deleteUserDocument, getUserDocument, listUserDocuments, USER_DOC_LIMIT, type UserDocumentPorts } from "./userDocuments";
|
|
9
10
|
import { handleProjects, handleProjectFiles } from "./projects";
|
|
10
11
|
import { handleShareConversation, handleGetShared } from "./share";
|
|
11
12
|
import { understandQuery } from "./understand";
|
|
@@ -26,7 +27,7 @@ import { handleSearch } from "./search";
|
|
|
26
27
|
import { handleEnrich, handleSectionUnit, handleCaption, handleVisionJudge, handleVectors, handleJudge, handleCreateKey, handleListKeys, handleRevokeKey, handleKeyUsage, handleFamilySummary } from "./admin";
|
|
27
28
|
import { handleResearch } from "./research";
|
|
28
29
|
import { uploadAttachment, readAttachment, deleteAttachment, type AttachmentDeps } from "./attachments";
|
|
29
|
-
import { cfBlobs } from "./ports/cloudflare/adapters";
|
|
30
|
+
import { cfBlobs, cfStore, cfVectorIndex, cfModelRunner } from "./ports/cloudflare/adapters";
|
|
30
31
|
import { handleAsk } from "./ask";
|
|
31
32
|
import { handleMcp } from "./mcp";
|
|
32
33
|
|
|
@@ -127,6 +128,48 @@ async function memoriesRoute(c: RouteContext): Promise<Response> {
|
|
|
127
128
|
return withCors(await handleMemories(c.env, who.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
function userDocumentPorts(env: Env): UserDocumentPorts {
|
|
132
|
+
return { store: cfStore(env.DB), blobs: cfBlobs(env.USER_DOCS), lane: cfVectorIndex(env.USER_DOCS_INDEX), model: cfModelRunner(env.AI) };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function userDocumentsRoute(c: RouteContext): Promise<Response> {
|
|
136
|
+
const who = await opIdentity(c, "Sign in to manage your documents");
|
|
137
|
+
if (who instanceof Response) return withCors(who, corsHeaders(c.req));
|
|
138
|
+
if (c.req.method === "GET") {
|
|
139
|
+
const out = await listUserDocuments(userDocumentPorts(c.env).store, who.sub);
|
|
140
|
+
return withCors(json({ ...out, quota: { used: out.documents.length, limit: USER_DOC_LIMIT } }), corsHeaders(c.req));
|
|
141
|
+
}
|
|
142
|
+
if (c.req.method === "POST") {
|
|
143
|
+
const refused = opWriteRefused(c, who);
|
|
144
|
+
if (refused) return withCors(refused, corsHeaders(c.req));
|
|
145
|
+
const form = await c.req.formData().catch(() => null);
|
|
146
|
+
const file = form?.get("file") as unknown;
|
|
147
|
+
if (!file || typeof file !== "object" || !("arrayBuffer" in file) || !("size" in file)) {
|
|
148
|
+
return err(400, "invalid_input", "Upload a file in the multipart \"file\" field.");
|
|
149
|
+
}
|
|
150
|
+
const blob = file as { arrayBuffer(): Promise<ArrayBuffer>; size: number; type: string; name: string };
|
|
151
|
+
const out = await createImport(userDocumentPorts(c.env), c.ctx.waitUntil.bind(c.ctx), who.sub, blob);
|
|
152
|
+
if ("error" in out) return withCors(out.error, corsHeaders(c.req));
|
|
153
|
+
return withCors(json({ document: out.document }, 202), corsHeaders(c.req));
|
|
154
|
+
}
|
|
155
|
+
return err(405, "method_not_allowed", `The path is served, but not with ${c.req.method}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function userDocumentItemRoute(c: RouteContext): Promise<Response> {
|
|
159
|
+
const who = await opIdentity(c, "Sign in to manage your documents");
|
|
160
|
+
if (who instanceof Response) return withCors(who, corsHeaders(c.req));
|
|
161
|
+
if (c.req.method === "GET") {
|
|
162
|
+
const row = await getUserDocument(userDocumentPorts(c.env).store, who.sub, c.params.id!);
|
|
163
|
+
if (!row) return err(404, "not_found", "No such document for this member");
|
|
164
|
+
return withCors(json({ document: row }), corsHeaders(c.req));
|
|
165
|
+
}
|
|
166
|
+
const refused = opWriteRefused(c, who);
|
|
167
|
+
if (refused) return withCors(refused, corsHeaders(c.req));
|
|
168
|
+
const gone = await deleteUserDocument(userDocumentPorts(c.env), who.sub, c.params.id!);
|
|
169
|
+
if (!gone) return err(404, "not_found", "No such document for this member");
|
|
170
|
+
return withCors(json({ ok: true }), corsHeaders(c.req));
|
|
171
|
+
}
|
|
172
|
+
|
|
130
173
|
async function projectsRoute(c: RouteContext): Promise<Response> {
|
|
131
174
|
const who = await opIdentity(c, "Sign in to use projects");
|
|
132
175
|
if (who instanceof Response) return withCors(who, corsHeaders(c.req));
|
|
@@ -619,6 +662,10 @@ const OPENAPI_HANDLERS: Record<OpenApiOperationId, RouteHandler> = {
|
|
|
619
662
|
getAttachment: attachmentsGetRoute,
|
|
620
663
|
deleteAttachment: attachmentsDeleteRoute,
|
|
621
664
|
listMemories: memoriesRoute,
|
|
665
|
+
listUserDocuments: userDocumentsRoute,
|
|
666
|
+
createUserDocument: userDocumentsRoute,
|
|
667
|
+
getUserDocument: userDocumentItemRoute,
|
|
668
|
+
deleteUserDocument: userDocumentItemRoute,
|
|
622
669
|
createMemory: memoriesRoute,
|
|
623
670
|
deleteMemory: memoriesRoute,
|
|
624
671
|
listProjects: projectsRoute,
|
|
@@ -36,6 +36,10 @@ export type OpenApiOperationId =
|
|
|
36
36
|
| "listMemories"
|
|
37
37
|
| "createMemory"
|
|
38
38
|
| "deleteMemory"
|
|
39
|
+
| "listUserDocuments"
|
|
40
|
+
| "createUserDocument"
|
|
41
|
+
| "getUserDocument"
|
|
42
|
+
| "deleteUserDocument"
|
|
39
43
|
| "listProjects"
|
|
40
44
|
| "createProject"
|
|
41
45
|
| "deleteProject"
|
|
@@ -212,6 +216,26 @@ export const OPENAPI_SURFACE: readonly (Omit<OpenApiRoute, "operationId"> & { op
|
|
|
212
216
|
"pattern": "/api/memories/:id",
|
|
213
217
|
"operationId": "deleteMemory"
|
|
214
218
|
},
|
|
219
|
+
{
|
|
220
|
+
"method": "GET",
|
|
221
|
+
"pattern": "/api/user-documents",
|
|
222
|
+
"operationId": "listUserDocuments"
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
"method": "POST",
|
|
226
|
+
"pattern": "/api/user-documents",
|
|
227
|
+
"operationId": "createUserDocument"
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
"method": "GET",
|
|
231
|
+
"pattern": "/api/user-documents/:id",
|
|
232
|
+
"operationId": "getUserDocument"
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
"method": "DELETE",
|
|
236
|
+
"pattern": "/api/user-documents/:id",
|
|
237
|
+
"operationId": "deleteUserDocument"
|
|
238
|
+
},
|
|
215
239
|
{
|
|
216
240
|
"method": "GET",
|
|
217
241
|
"pattern": "/api/projects",
|
|
@@ -141,6 +141,9 @@ export function cfVectorIndex(index: unknown): VectorIndex {
|
|
|
141
141
|
// the adapter law: batches above the provider cap go in chunks
|
|
142
142
|
for (const group of by20(vectors)) await ix.upsert(group);
|
|
143
143
|
},
|
|
144
|
+
async deleteIds(ids: string[]) {
|
|
145
|
+
for (const group of by20(ids)) await ix.deleteByIds(group);
|
|
146
|
+
},
|
|
144
147
|
async getByIds(ids: string[]) {
|
|
145
148
|
// getByIds above ~20 returns EMPTY silently — chunk, always
|
|
146
149
|
const out: VectorMatch[] = [];
|
|
@@ -23,4 +23,7 @@ export interface VectorIndex {
|
|
|
23
23
|
query(q: VectorQuery): Promise<VectorMatch[]>;
|
|
24
24
|
upsert(vectors: VectorUpsert[]): Promise<void>;
|
|
25
25
|
getByIds(ids: string[]): Promise<VectorMatch[]>;
|
|
26
|
+
/** deletion rides the same chunk cap as reads — the provider's
|
|
27
|
+
* deleteByIds is batch-shaped exactly like getByIds */
|
|
28
|
+
deleteIds(ids: string[]): Promise<void>;
|
|
26
29
|
}
|