@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.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  handleAsk
3
- } from "./chunk-SU4FO7TN.js";
3
+ } from "./chunk-PH7W6HMN.js";
4
4
  import "./chunk-TPV5GPY2.js";
5
5
  import "./chunk-SPKPH54J.js";
6
6
  import "./chunk-A3QHHUN5.js";
@@ -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 cached = fresh || contextual || declaredCtx || draftAct || apiCallIntent || userImage ? null : await cacheGet(env, gen, ns, q.query, q.lang, salt);
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 (userImage) {
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: userImage } }];
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: userImage } }
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-SU4FO7TN.js";
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-27TMC7PV.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-PBOODCMQ.js")).handleSearch(env, ctx, inner, tier, key);
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.52",
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",
@@ -0,0 +1,4 @@
1
+ -- Tier-2 image reuse (TODO.new-era/8): a stored attachment rides its user
2
+ -- message, so follow-up turns can re-attach it server-side within the
3
+ -- conversation's ownership
4
+ ALTER TABLE messages ADD COLUMN attachment_id TEXT;
@@ -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 cached = fresh || contextual || declaredCtx || draftAct || apiCallIntent || userImage ? null : await cacheGet(env, gen, ns, q.query, q.lang, salt);
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 (userImage) {
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: userImage } }] as unknown as string;
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: userImage } },
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) {