@konneal/engine 0.2.52 → 0.2.53
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-27TMC7PV.js → ask-5KXW7WIA.js} +1 -1
- package/dist/{chunk-SU4FO7TN.js → chunk-PH7W6HMN.js} +37 -4
- package/dist/worker_public/src/index.js +12 -6
- package/package.json +1 -1
- package/workers/worker_public/migrations/0018_messages_attachment.sql +4 -0
- package/workers/worker_public/schema.sql +3 -0
- package/workers/worker_public/src/ask.ts +49 -4
- package/workers/worker_public/src/conversations.ts +12 -4
|
@@ -2181,13 +2181,31 @@ async function handleAsk(env, ctx, req, tier, key) {
|
|
|
2181
2181
|
if (body?.image != null && !userImage) {
|
|
2182
2182
|
return err(400, "invalid_image", "image must be a data URL (data:image/png|jpeg|webp|gif;base64,\u2026) up to 6 MB");
|
|
2183
2183
|
}
|
|
2184
|
+
let storedImage = null;
|
|
2185
|
+
const attachIds = Array.isArray(body?.attachment_ids) ? body.attachment_ids.filter((x) => typeof x === "string" && /^[0-9a-f-]{36}$/i.test(x)).slice(0, 2) : [];
|
|
2186
|
+
if (attachIds.length && env.DB && member) {
|
|
2187
|
+
for (const aid of attachIds) {
|
|
2188
|
+
const row = await env.DB.prepare("SELECT mime, r2_key FROM attachments WHERE id = ?1 AND sub = ?2").bind(aid, member.sub).first();
|
|
2189
|
+
if (!row) continue;
|
|
2190
|
+
try {
|
|
2191
|
+
const obj = await env.CHAT_UPLOADS.get(row.r2_key);
|
|
2192
|
+
if (!obj) continue;
|
|
2193
|
+
const b64 = btoa(String.fromCharCode(...new Uint8Array(await obj.arrayBuffer())));
|
|
2194
|
+
storedImage = `data:${row.mime};base64,${b64}`;
|
|
2195
|
+
break;
|
|
2196
|
+
} catch {
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
const effectiveImage = userImage ?? storedImage;
|
|
2184
2201
|
const budget = num(env, "INPUT_TOKEN_BUDGET", LIMITS.inputTokenBudget);
|
|
2185
2202
|
const { kept: keptHistory, overflow } = splitHistory(history, budget);
|
|
2186
2203
|
const summary = overflow.length >= 2 ? await summarizeHistory(env, MODELS.understand, overflow) ?? void 0 : void 0;
|
|
2187
2204
|
let retrieved;
|
|
2188
2205
|
const fresh = freshRequested(body);
|
|
2189
2206
|
const gen = await corpusGen(env.CACHE);
|
|
2190
|
-
const
|
|
2207
|
+
const withImage = effectiveImage || storedImage;
|
|
2208
|
+
const cached = fresh || contextual || declaredCtx || draftAct || apiCallIntent || withImage ? null : await cacheGet(env, gen, ns, q.query, q.lang, salt);
|
|
2191
2209
|
const wantsStream = body?.stream === true || tier === "anon" && body?.stream !== false;
|
|
2192
2210
|
if (cached) {
|
|
2193
2211
|
telemetry(env, ctx, tier, "ask", null, true, (cached.value.answer ?? "").length, cached.value.query_hash, q.lang, "exact", telemetryMeta());
|
|
@@ -2204,6 +2222,21 @@ async function handleAsk(env, ctx, req, tier, key) {
|
|
|
2204
2222
|
const warmQuery = retrievalQuery(q.query, prev);
|
|
2205
2223
|
const warmEmbed = embedWarm(env, warmQuery);
|
|
2206
2224
|
const conversationId = typeof body?.conversation_id === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(body.conversation_id) ? body.conversation_id : null;
|
|
2225
|
+
if (!effectiveImage && !attachIds.length && conversationId && env.DB && member) {
|
|
2226
|
+
const prior = await env.DB.prepare(
|
|
2227
|
+
"SELECT m.attachment_id AS aid, a.mime, a.r2_key FROM messages m JOIN attachments a ON a.id = m.attachment_id WHERE m.conversation_id = ?1 AND m.role = 'user' AND m.attachment_id IS NOT NULL ORDER BY m.created_at DESC LIMIT 1"
|
|
2228
|
+
).bind(conversationId, member.sub).first();
|
|
2229
|
+
if (prior) {
|
|
2230
|
+
try {
|
|
2231
|
+
const obj = await env.CHAT_UPLOADS.get(prior.r2_key);
|
|
2232
|
+
if (obj) {
|
|
2233
|
+
const b64 = btoa(String.fromCharCode(...new Uint8Array(await obj.arrayBuffer())));
|
|
2234
|
+
storedImage = `data:${prior.mime};base64,${b64}`;
|
|
2235
|
+
}
|
|
2236
|
+
} catch {
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2207
2240
|
let convEntities = [];
|
|
2208
2241
|
if (conversationId) {
|
|
2209
2242
|
try {
|
|
@@ -2651,18 +2684,18 @@ Answer account questions from these records ONLY: name the record when you use i
|
|
|
2651
2684
|
budget
|
|
2652
2685
|
);
|
|
2653
2686
|
await attachFigureImages(env, messages, usedHits, q.query);
|
|
2654
|
-
if (
|
|
2687
|
+
if (withImage) {
|
|
2655
2688
|
const wantsMarkings = /\b(mark(ing|ings)?|nameplate|label|inscription|engrav|sticker|plate)\b|certificat/i.test(q.query);
|
|
2656
2689
|
const last = messages[messages.length - 1];
|
|
2657
2690
|
const note = wantsMarkings ? "\n\n(The user attached a photo with this question. First transcribe every inscription you can actually read in the image \u2014 names, model references, accuracy classes, numeric values with their units, certificate or approval numbers \u2014 quoting them verbatim. Then answer the question from the numbered passages, citing the requirement each interpretation rests on. If an inscription is unreadable, say so; never invent a marking.)" : "\n\n(The user attached an image with this question; interpret it directly when answering.)";
|
|
2658
2691
|
if (Array.isArray(last.content)) {
|
|
2659
2692
|
const textPart = last.content.find((p) => p.type === "text");
|
|
2660
2693
|
if (textPart) textPart.text += note;
|
|
2661
|
-
last.content = [...last.content, { type: "image_url", image_url: { url:
|
|
2694
|
+
last.content = [...last.content, { type: "image_url", image_url: { url: withImage } }];
|
|
2662
2695
|
} else {
|
|
2663
2696
|
last.content = [
|
|
2664
2697
|
{ type: "text", text: last.content + note },
|
|
2665
|
-
{ type: "image_url", image_url: { url:
|
|
2698
|
+
{ type: "image_url", image_url: { url: withImage } }
|
|
2666
2699
|
];
|
|
2667
2700
|
}
|
|
2668
2701
|
console.log("user image attached to generation");
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
scoreFaithfulness,
|
|
11
11
|
scoreJudge,
|
|
12
12
|
standardForDocNumber
|
|
13
|
-
} from "../../chunk-
|
|
13
|
+
} from "../../chunk-PH7W6HMN.js";
|
|
14
14
|
import {
|
|
15
15
|
buildMessages,
|
|
16
16
|
cfBlobs,
|
|
@@ -103,11 +103,11 @@ async function handleConversations(env, sub, req, route) {
|
|
|
103
103
|
let msgs;
|
|
104
104
|
try {
|
|
105
105
|
msgs = await env.DB.prepare(
|
|
106
|
-
"SELECT id, role, content, citations, model, context_applied, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC"
|
|
106
|
+
"SELECT id, role, content, citations, model, context_applied, attachment_id, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC"
|
|
107
107
|
).bind(id).all();
|
|
108
108
|
} catch {
|
|
109
109
|
msgs = await env.DB.prepare(
|
|
110
|
-
"SELECT id, role, content, citations, model, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC"
|
|
110
|
+
"SELECT id, role, content, citations, model, attachment_id, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC"
|
|
111
111
|
).bind(id).all();
|
|
112
112
|
}
|
|
113
113
|
return json({
|
|
@@ -151,6 +151,12 @@ async function handleAppendMessage(env, sub, req, convId) {
|
|
|
151
151
|
if (role !== "user" && role !== "assistant" || !content.trim() || content.length > LIMITS.maxOutputTokens * 4) {
|
|
152
152
|
return err(400, "invalid_input", "role (user|assistant) and content are required");
|
|
153
153
|
}
|
|
154
|
+
let attachmentId = null;
|
|
155
|
+
if (typeof body?.attachment_id === "string" && body.attachment_id) {
|
|
156
|
+
const owned = await env.DB.prepare("SELECT id FROM attachments WHERE id = ?1 AND sub = ?2").bind(body.attachment_id, sub).first();
|
|
157
|
+
if (!owned) return err(404, "not_found", "No such attachment");
|
|
158
|
+
attachmentId = body.attachment_id;
|
|
159
|
+
}
|
|
154
160
|
let citations2 = null;
|
|
155
161
|
if (body?.citations != null) {
|
|
156
162
|
if (!Array.isArray(body.citations) || body.citations.length > 16) {
|
|
@@ -166,8 +172,8 @@ async function handleAppendMessage(env, sub, req, convId) {
|
|
|
166
172
|
try {
|
|
167
173
|
await env.DB.batch([
|
|
168
174
|
env.DB.prepare(
|
|
169
|
-
"INSERT INTO messages (id, conversation_id, role, content, citations, model, context_applied, created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)"
|
|
170
|
-
).bind(mid, convId, role, content, citations2, typeof body?.model === "string" ? body.model.slice(0, 80) : null, applied ? JSON.stringify(applied) : null, now),
|
|
175
|
+
"INSERT INTO messages (id, conversation_id, role, content, citations, model, context_applied, attachment_id, created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)"
|
|
176
|
+
).bind(mid, convId, role, content, citations2, typeof body?.model === "string" ? body.model.slice(0, 80) : null, applied ? JSON.stringify(applied) : null, attachmentId, now),
|
|
171
177
|
env.DB.prepare("UPDATE conversations SET updated_at = ?1 WHERE id = ?2 AND sub = ?3").bind(now, convId, sub)
|
|
172
178
|
]);
|
|
173
179
|
} catch (e) {
|
|
@@ -954,7 +960,7 @@ async function handleMcp(env, ctx, req, tier, key) {
|
|
|
954
960
|
// stream:false forces the JSON lane (anon defaults to SSE)
|
|
955
961
|
body: JSON.stringify({ ...args, stream: false })
|
|
956
962
|
});
|
|
957
|
-
const res = name === "ask" ? await (await import("../../ask-
|
|
963
|
+
const res = name === "ask" ? await (await import("../../ask-5KXW7WIA.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-PBOODCMQ.js")).handleSearch(env, ctx, inner, tier, key);
|
|
958
964
|
return res.json().catch(() => ({ error: { message: "tool transport failed", status: res.status } }));
|
|
959
965
|
});
|
|
960
966
|
if (out.ok && "accepted" in out) return new Response(null, { status: 202 });
|
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.53",
|
|
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",
|
|
@@ -258,3 +258,6 @@ CREATE TABLE IF NOT EXISTS attachments (
|
|
|
258
258
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
259
259
|
);
|
|
260
260
|
CREATE INDEX IF NOT EXISTS idx_attachments_sub ON attachments (sub);
|
|
261
|
+
|
|
262
|
+
-- Tier-2 image reuse: the user message's stored attachment
|
|
263
|
+
ALTER TABLE messages ADD COLUMN attachment_id TEXT;
|
|
@@ -379,6 +379,31 @@ async function handleAsk(
|
|
|
379
379
|
if (body?.image != null && !userImage) {
|
|
380
380
|
return err(400, "invalid_image", "image must be a data URL (data:image/png|jpeg|webp|gif;base64,…) up to 6 MB");
|
|
381
381
|
}
|
|
382
|
+
// Tier-2 image reuse (TODO.new-era/8): stored attachments materialize
|
|
383
|
+
// as the question's image. Explicit attachment_ids win (member-gated,
|
|
384
|
+
// ownership-checked, at most one — the multimodal shape rule); with
|
|
385
|
+
// none given, the conversation's most recent image re-attaches itself,
|
|
386
|
+
// so "identify this" works turns after the upload.
|
|
387
|
+
let storedImage: string | null = null;
|
|
388
|
+
const attachIds: string[] = Array.isArray(body?.attachment_ids)
|
|
389
|
+
? body.attachment_ids.filter((x: unknown) => typeof x === "string" && /^[0-9a-f-]{36}$/i.test(x)).slice(0, 2)
|
|
390
|
+
: [];
|
|
391
|
+
if (attachIds.length && env.DB && member) {
|
|
392
|
+
for (const aid of attachIds) {
|
|
393
|
+
const row = await env.DB.prepare("SELECT mime, r2_key FROM attachments WHERE id = ?1 AND sub = ?2").bind(aid, member.sub).first<{ mime: string; r2_key: string }>();
|
|
394
|
+
if (!row) continue;
|
|
395
|
+
try {
|
|
396
|
+
const obj = await (env as any).CHAT_UPLOADS.get(row.r2_key);
|
|
397
|
+
if (!obj) continue;
|
|
398
|
+
const b64 = btoa(String.fromCharCode(...new Uint8Array(await obj.arrayBuffer())));
|
|
399
|
+
storedImage = `data:${row.mime};base64,${b64}`;
|
|
400
|
+
break; // the multimodal shape rule: one image per question
|
|
401
|
+
} catch {
|
|
402
|
+
/* the object's gone — ask without it */
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const effectiveImage = userImage ?? storedImage;
|
|
382
407
|
// history compaction: turns beyond the budget slice are summarized into a
|
|
383
408
|
// continuity block (below) instead of silently dropped
|
|
384
409
|
const budget = num(env as any, "INPUT_TOKEN_BUDGET", LIMITS.inputTokenBudget);
|
|
@@ -395,7 +420,8 @@ async function handleAsk(
|
|
|
395
420
|
// answer caches; corpus surgery bumps it (scripts/invalidate_answer_
|
|
396
421
|
// cache.py) and old-generation entries miss (oimlsmart/rag#72)
|
|
397
422
|
const gen = await corpusGen(env.CACHE);
|
|
398
|
-
const
|
|
423
|
+
const withImage = effectiveImage || storedImage;
|
|
424
|
+
const cached = fresh || contextual || declaredCtx || draftAct || apiCallIntent || withImage ? null : await cacheGet(env, gen, ns, q.query, q.lang, salt);
|
|
399
425
|
const wantsStream = body?.stream === true || (tier === "anon" && body?.stream !== false);
|
|
400
426
|
|
|
401
427
|
if (cached) {
|
|
@@ -424,6 +450,25 @@ async function handleAsk(
|
|
|
424
450
|
// (present when the client passes conversation_id) make pronoun
|
|
425
451
|
// follow-ups O(1) — "it / the 2017 one" resolve against the map
|
|
426
452
|
const conversationId = typeof body?.conversation_id === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(body.conversation_id) ? body.conversation_id : null;
|
|
453
|
+
|
|
454
|
+
// Tier-2 continuation: no new image, but the conversation carries one —
|
|
455
|
+
// its most recent stored image re-attaches to this question
|
|
456
|
+
if (!effectiveImage && !attachIds.length && conversationId && env.DB && member) {
|
|
457
|
+
const prior = await env.DB.prepare(
|
|
458
|
+
"SELECT m.attachment_id AS aid, a.mime, a.r2_key FROM messages m JOIN attachments a ON a.id = m.attachment_id WHERE m.conversation_id = ?1 AND m.role = 'user' AND m.attachment_id IS NOT NULL ORDER BY m.created_at DESC LIMIT 1",
|
|
459
|
+
).bind(conversationId, member.sub).first<{ aid: string; mime: string; r2_key: string }>();
|
|
460
|
+
if (prior) {
|
|
461
|
+
try {
|
|
462
|
+
const obj = await (env as any).CHAT_UPLOADS.get(prior.r2_key);
|
|
463
|
+
if (obj) {
|
|
464
|
+
const b64 = btoa(String.fromCharCode(...new Uint8Array(await obj.arrayBuffer())));
|
|
465
|
+
storedImage = `data:${prior.mime};base64,${b64}`;
|
|
466
|
+
}
|
|
467
|
+
} catch {
|
|
468
|
+
/* the object's gone */
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
427
472
|
let convEntities: Array<{ entity: string; kind: string }> = [];
|
|
428
473
|
if (conversationId) {
|
|
429
474
|
try {
|
|
@@ -1081,7 +1126,7 @@ async function handleAsk(
|
|
|
1081
1126
|
budget,
|
|
1082
1127
|
);
|
|
1083
1128
|
await attachFigureImages(env, messages, usedHits, q.query);
|
|
1084
|
-
if (
|
|
1129
|
+
if (withImage) {
|
|
1085
1130
|
// the user's own image rides on the question message — retrieval stays
|
|
1086
1131
|
// text-driven; the answer model reads the image as question context.
|
|
1087
1132
|
// A markings/nameplate/certificate photo gets a TRANSCRIPTION note:
|
|
@@ -1096,11 +1141,11 @@ async function handleAsk(
|
|
|
1096
1141
|
if (Array.isArray(last.content)) {
|
|
1097
1142
|
const textPart = last.content.find((p: any) => p.type === "text");
|
|
1098
1143
|
if (textPart) textPart.text += note;
|
|
1099
|
-
last.content = [...last.content, { type: "image_url", image_url: { url:
|
|
1144
|
+
last.content = [...last.content, { type: "image_url", image_url: { url: withImage } }] as unknown as string;
|
|
1100
1145
|
} else {
|
|
1101
1146
|
last.content = [
|
|
1102
1147
|
{ type: "text", text: last.content + note },
|
|
1103
|
-
{ type: "image_url", image_url: { url:
|
|
1148
|
+
{ type: "image_url", image_url: { url: withImage } },
|
|
1104
1149
|
] as unknown as string;
|
|
1105
1150
|
}
|
|
1106
1151
|
console.log("user image attached to generation");
|
|
@@ -66,13 +66,13 @@ export async function handleConversations(
|
|
|
66
66
|
let msgs: any;
|
|
67
67
|
try {
|
|
68
68
|
msgs = await env.DB.prepare(
|
|
69
|
-
"SELECT id, role, content, citations, model, context_applied, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC",
|
|
69
|
+
"SELECT id, role, content, citations, model, context_applied, attachment_id, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC",
|
|
70
70
|
)
|
|
71
71
|
.bind(id)
|
|
72
72
|
.all();
|
|
73
73
|
} catch {
|
|
74
74
|
msgs = await env.DB.prepare(
|
|
75
|
-
"SELECT id, role, content, citations, model, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC",
|
|
75
|
+
"SELECT id, role, content, citations, model, attachment_id, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC",
|
|
76
76
|
)
|
|
77
77
|
.bind(id)
|
|
78
78
|
.all();
|
|
@@ -124,6 +124,14 @@ export async function handleAppendMessage(env: any, sub: string, req: Request, c
|
|
|
124
124
|
if ((role !== "user" && role !== "assistant") || !content.trim() || content.length > LIMITS.maxOutputTokens * 4) {
|
|
125
125
|
return err(400, "invalid_input", "role (user|assistant) and content are required");
|
|
126
126
|
}
|
|
127
|
+
// Tier-2 image reuse: the message may carry a stored attachment id —
|
|
128
|
+
// the member's own, validated against the attachments table
|
|
129
|
+
let attachmentId: string | null = null;
|
|
130
|
+
if (typeof body?.attachment_id === "string" && body.attachment_id) {
|
|
131
|
+
const owned = await env.DB.prepare("SELECT id FROM attachments WHERE id = ?1 AND sub = ?2").bind(body.attachment_id, sub).first();
|
|
132
|
+
if (!owned) return err(404, "not_found", "No such attachment");
|
|
133
|
+
attachmentId = body.attachment_id;
|
|
134
|
+
}
|
|
127
135
|
let citations: string | null = null;
|
|
128
136
|
if (body?.citations != null) {
|
|
129
137
|
if (!Array.isArray(body.citations) || body.citations.length > 16) {
|
|
@@ -142,8 +150,8 @@ export async function handleAppendMessage(env: any, sub: string, req: Request, c
|
|
|
142
150
|
try {
|
|
143
151
|
await env.DB.batch([
|
|
144
152
|
env.DB.prepare(
|
|
145
|
-
"INSERT INTO messages (id, conversation_id, role, content, citations, model, context_applied, created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
|
|
146
|
-
).bind(mid, convId, role, content, citations, typeof body?.model === "string" ? body.model.slice(0, 80) : null, applied ? JSON.stringify(applied) : null, now),
|
|
153
|
+
"INSERT INTO messages (id, conversation_id, role, content, citations, model, context_applied, attachment_id, created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
|
|
154
|
+
).bind(mid, convId, role, content, citations, typeof body?.model === "string" ? body.model.slice(0, 80) : null, applied ? JSON.stringify(applied) : null, attachmentId, now),
|
|
147
155
|
env.DB.prepare("UPDATE conversations SET updated_at = ?1 WHERE id = ?2 AND sub = ?3").bind(now, convId, sub),
|
|
148
156
|
]);
|
|
149
157
|
} catch (e) {
|