@arcforgelabs/flow 2026.10.50 → 2026.10.51

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.
@@ -0,0 +1,333 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { and, asc, eq, isNull, sql } from 'drizzle-orm';
3
+ import { callCostMicros } from './ai.js';
4
+ import { captures, jobFields } from '../db/schema.js';
5
+ import { FlowError } from './errors.js';
6
+ import { enqueueJob, runNextJob } from './queue.js';
7
+
8
+ // The vision reader. A small model reads every capture. A larger model reads
9
+ // again only when case number, practice or work is below the sure line.
10
+ // Flow code decides that; the model does not see the jobs list and does not
11
+ // write. The photo and the fields stay out of the log: a failure is a
12
+ // capture id and an error code.
13
+ //
14
+ // A failed call is tried twice more (three calls). The capture is then
15
+ // `problem` with a short reason. Type it still works. The module being off,
16
+ // or the keys missing, is "reader turned off" and is set when the photo is
17
+ // stored (server/core/captures.js), not here.
18
+
19
+ export const CAPTURE_READ_KIND = 'capture.read';
20
+ const CALLS = 3;
21
+ const VALUE_MAX = 2000;
22
+
23
+ export const JOB_FIELDS = Object.freeze([
24
+ Object.freeze({ key: 'case_number', label: 'Case number', needed: true }),
25
+ Object.freeze({ key: 'practice', label: 'Practice', needed: true }),
26
+ Object.freeze({ key: 'dentist', label: 'Dentist', needed: false }),
27
+ Object.freeze({ key: 'work', label: 'Work', needed: true }),
28
+ Object.freeze({ key: 'teeth', label: 'Teeth', needed: false }),
29
+ Object.freeze({ key: 'shade', label: 'Shade', needed: false }),
30
+ Object.freeze({ key: 'due_date', label: 'Due date', needed: false }),
31
+ Object.freeze({ key: 'notes', label: 'Notes', needed: false }),
32
+ ]);
33
+
34
+ const NEEDED = new Set(JOB_FIELDS.filter((field) => field.needed).map((field) => field.key));
35
+
36
+ // One object, every property required. Bedrock rejects a schema that leaves
37
+ // a property optional past its limit, or that omits additionalProperties: false.
38
+ const FIELD_SCHEMA = Object.freeze({
39
+ type: 'object',
40
+ properties: {
41
+ fields: {
42
+ type: 'array',
43
+ items: {
44
+ type: 'object',
45
+ properties: {
46
+ key: { type: 'string' },
47
+ value: { type: 'string' },
48
+ seen: { type: 'string' },
49
+ sure: { type: 'number' },
50
+ },
51
+ required: ['key', 'value', 'seen', 'sure'],
52
+ additionalProperties: false,
53
+ },
54
+ },
55
+ },
56
+ required: ['fields'],
57
+ additionalProperties: false,
58
+ });
59
+
60
+ const PROBLEM = new Set([
61
+ 'the reader did not answer',
62
+ 'could not read the form',
63
+ 'photo type cannot be read',
64
+ 'the photo is missing',
65
+ ]);
66
+
67
+ const READABLE = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
68
+
69
+ function problemText(error) {
70
+ return PROBLEM.has(error?.message) ? error.message : 'the reader did not answer';
71
+ }
72
+
73
+ function blank(key) {
74
+ return { key, value: '', seen: '', sure: 0 };
75
+ }
76
+
77
+ function clip(value) {
78
+ return value.trim().slice(0, VALUE_MAX);
79
+ }
80
+
81
+ function sureOf(value) {
82
+ const number = typeof value === 'number' ? value : Number(value);
83
+ if (!Number.isFinite(number)) return 0;
84
+ return Math.min(1, Math.max(0, number));
85
+ }
86
+
87
+ // One field per key the lab asked for, in that order. A key that was not
88
+ // asked for is dropped, so a patient name the model adds is not stored.
89
+ // A value that is not text is empty: an unreadable field is never a guess.
90
+ function parseFields(text, fieldList) {
91
+ let parsed;
92
+ try {
93
+ parsed = JSON.parse(text);
94
+ } catch {
95
+ const error = new FlowError('reader_unreadable', 'could not read the form');
96
+ error.retry = true;
97
+ throw error;
98
+ }
99
+ const rows = Array.isArray(parsed?.fields) ? parsed.fields : null;
100
+ if (!rows) {
101
+ const error = new FlowError('reader_unreadable', 'could not read the form');
102
+ error.retry = true;
103
+ throw error;
104
+ }
105
+ const byKey = new Map();
106
+ for (const row of rows) {
107
+ if (!row || typeof row.key !== 'string' || !row.key) continue;
108
+ if (typeof row.value !== 'string' || typeof row.seen !== 'string') {
109
+ byKey.set(row.key, blank(row.key));
110
+ continue;
111
+ }
112
+ byKey.set(row.key, { key: row.key, value: clip(row.value), seen: clip(row.seen), sure: sureOf(row.sure) });
113
+ }
114
+ return fieldList.map((field) => byKey.get(field.key) ?? blank(field.key));
115
+ }
116
+
117
+ function belowLine(fields, line) {
118
+ const byKey = new Map(fields.map((field) => [field.key, field]));
119
+ for (const key of NEEDED) {
120
+ if ((byKey.get(key)?.sure ?? 0) < line) return true;
121
+ }
122
+ return false;
123
+ }
124
+
125
+ function recorded(fields, calls, model = calls.at(-1).model) {
126
+ const inputTokens = calls.reduce((sum, call) => sum + call.inputTokens, 0);
127
+ const outputTokens = calls.reduce((sum, call) => sum + call.outputTokens, 0);
128
+ const costMicros = calls.some((call) => call.costMicros == null)
129
+ ? null
130
+ : calls.reduce((sum, call) => sum + call.costMicros, 0);
131
+ return { fields, model, inputTokens, outputTokens, costMicros, calls };
132
+ }
133
+
134
+ function instructionFor(fieldList) {
135
+ const lines = fieldList.map((field) => `- ${field.key}: ${field.label}`).join('\n');
136
+ return [
137
+ 'Read this photo of a handwritten lab form.',
138
+ 'Return one field for each key below.',
139
+ 'value is what should be stored. Use an empty string when you cannot read it. Never guess a name, a number or a date.',
140
+ 'seen is the exact text you saw, or an empty string when nothing was written.',
141
+ 'sure is a number from 0 to 1: how sure you are that value matches what was written.',
142
+ 'Ignore printed lab branding.',
143
+ 'Do not add a key that is not in this list.',
144
+ lines,
145
+ ].join('\n');
146
+ }
147
+
148
+ async function captureFields(db, organisationId) {
149
+ const rows = await db.select({ id: jobFields.id, name: jobFields.name }).from(jobFields).where(and(
150
+ eq(jobFields.organisationId, organisationId),
151
+ isNull(jobFields.archivedAt),
152
+ )).orderBy(asc(jobFields.position), asc(jobFields.name));
153
+ const labels = new Set(JOB_FIELDS.map((field) => field.label.toLowerCase()));
154
+ const custom = rows
155
+ .filter((row) => row.name && !labels.has(row.name.trim().toLowerCase()))
156
+ .map((row) => ({ key: `custom_${row.id}`, label: row.name.trim(), needed: false }));
157
+ return [...JOB_FIELDS, ...custom];
158
+ }
159
+
160
+ // Three calls at most. A call that returned token counts is kept on the
161
+ // error when every attempt fails, so the capture still records the cost.
162
+ async function ask(port, { model, price, fieldList, mediaType, bytes, instruction }) {
163
+ const spent = [];
164
+ let last = new FlowError('reader_unavailable', 'the reader did not answer');
165
+ for (let attempt = 0; attempt < CALLS; attempt += 1) {
166
+ try {
167
+ const result = await port.readImage({
168
+ model, mediaType, bytes, instruction, schema: FIELD_SCHEMA, maxTokens: 4096,
169
+ });
170
+ spent.push({
171
+ model,
172
+ inputTokens: result.inputTokens,
173
+ outputTokens: result.outputTokens,
174
+ costMicros: callCostMicros({ inputTokens: result.inputTokens, outputTokens: result.outputTokens, price }),
175
+ });
176
+ return { fields: parseFields(result.text, fieldList), spent };
177
+ } catch (error) {
178
+ last = error;
179
+ if (error?.retry === false) break;
180
+ }
181
+ }
182
+ last.spent = spent;
183
+ throw last;
184
+ }
185
+
186
+ async function mark(db, row, fields) {
187
+ const [next] = await db.update(captures).set({ ...fields, updatedAt: sql`now()` }).where(and(
188
+ eq(captures.organisationId, row.organisationId),
189
+ eq(captures.id, row.id),
190
+ eq(captures.state, 'reading'),
191
+ )).returning();
192
+ return next ?? row;
193
+ }
194
+
195
+ // Reads one capture that is still `reading`. A capture that has moved on
196
+ // (typed, decided) is left as it is. The model's answer is stored on the
197
+ // row and is not written to the log.
198
+ export async function readStoredCapture(db, storage, port, settings, { organisationId, captureId, log = console }) {
199
+ const [row] = await db.select().from(captures).where(and(
200
+ eq(captures.organisationId, organisationId),
201
+ eq(captures.id, captureId),
202
+ ));
203
+ if (!row || row.state !== 'reading') return row ?? null;
204
+
205
+ let stored;
206
+ try {
207
+ stored = await storage.get(row.fileKey);
208
+ } catch {
209
+ const error = new FlowError('reader_unavailable', 'the reader did not answer');
210
+ error.retry = true;
211
+ throw error;
212
+ }
213
+ if (!stored) {
214
+ log.error?.(`[reader] ${row.id} reader_missing`);
215
+ return mark(db, row, { state: 'problem', problem: 'the photo is missing' });
216
+ }
217
+ const sha = createHash('sha256').update(stored.body).digest('hex');
218
+ if (sha !== row.fileSha256) {
219
+ log.error?.(`[reader] ${row.id} hash_mismatch`);
220
+ return mark(db, row, { state: 'problem', problem: 'could not read the form' });
221
+ }
222
+ if (!READABLE.has(row.fileType)) {
223
+ log.error?.(`[reader] ${row.id} reader_type`);
224
+ return mark(db, row, { state: 'problem', problem: 'photo type cannot be read' });
225
+ }
226
+
227
+ const fieldList = await captureFields(db, organisationId);
228
+ const instruction = instructionFor(fieldList);
229
+ const photo = { fieldList, mediaType: row.fileType, bytes: stored.body, instruction };
230
+ const calls = [];
231
+ let fields = null;
232
+ try {
233
+ const first = await ask(port, { ...photo, model: settings.model, price: settings.prices.small });
234
+ calls.push(...first.spent);
235
+ fields = first.fields;
236
+ if (belowLine(fields, settings.sureLine)) {
237
+ const second = await ask(port, { ...photo, model: settings.large, price: settings.prices.large });
238
+ calls.push(...second.spent);
239
+ fields = second.fields;
240
+ }
241
+ return mark(db, row, { state: 'ready', problem: null, read: recorded(fields, calls) });
242
+ } catch (error) {
243
+ log.error?.(`[reader] ${row.id} ${error?.code ?? 'reader_unavailable'}`);
244
+ calls.push(...(error?.spent ?? []));
245
+ return mark(db, row, {
246
+ state: 'problem',
247
+ problem: problemText(error),
248
+ read: calls.length ? recorded(fields ?? [], calls, fields ? settings.model : calls.at(-1).model) : null,
249
+ });
250
+ }
251
+ }
252
+
253
+ // Queues the read, or runs it in the request when background jobs are off
254
+ // (a Replit workspace). The worker only claims `capture.read` jobs. Two
255
+ // processes can run: the lease keeps a capture to one of them.
256
+ export function createCaptureReader({
257
+ db, storage, port, settings, log = console, jobsOn = true, timers = globalThis,
258
+ }) {
259
+ async function handle(job) {
260
+ const captureId = job.payload?.captureId ?? null;
261
+ try {
262
+ await readStoredCapture(db, storage, port, settings, { organisationId: job.organisationId, captureId, log });
263
+ return { captureId };
264
+ } catch (error) {
265
+ log.error?.(`[reader] ${captureId ?? '-'} ${error?.code ?? 'reader_unavailable'}`);
266
+ const safe = new FlowError('reader_unavailable', 'the reader did not answer');
267
+ safe.retry = error?.retry !== false;
268
+ throw safe;
269
+ }
270
+ }
271
+
272
+ async function schedule(row) {
273
+ if (!settings?.on) return row;
274
+ if (!jobsOn) {
275
+ return readStoredCapture(db, storage, port, settings, { organisationId: row.organisationId, captureId: row.id, log });
276
+ }
277
+ await enqueueJob(db, row.organisationId, {
278
+ kind: CAPTURE_READ_KIND,
279
+ dedupeKey: row.id,
280
+ payload: { captureId: row.id },
281
+ maxAttempts: 2,
282
+ });
283
+ return row;
284
+ }
285
+
286
+ const workerId = `reader-${randomUUID()}`;
287
+ let timer = null;
288
+ let stopped = true;
289
+ let running = null;
290
+
291
+ function arm(delay) {
292
+ if (stopped) return;
293
+ timer = timers.setTimeout(async () => {
294
+ timer = null;
295
+ running = tick();
296
+ const again = await running;
297
+ running = null;
298
+ arm(again ? 0 : 1000);
299
+ }, delay);
300
+ timer?.unref?.();
301
+ }
302
+
303
+ async function tick() {
304
+ try {
305
+ const ran = await runNextJob(db, {
306
+ workerId,
307
+ leaseMs: 15 * 60 * 1000,
308
+ handlers: { [CAPTURE_READ_KIND]: handle },
309
+ });
310
+ return Boolean(ran);
311
+ } catch {
312
+ log.error?.('[reader] - reader_unavailable');
313
+ return false;
314
+ }
315
+ }
316
+
317
+ return {
318
+ schedule,
319
+ handle,
320
+ worker: {
321
+ start() {
322
+ stopped = false;
323
+ arm(1000);
324
+ },
325
+ async stop() {
326
+ stopped = true;
327
+ if (timer) timers.clearTimeout(timer);
328
+ timer = null;
329
+ await running;
330
+ },
331
+ },
332
+ };
333
+ }