@arcforgelabs/flow 2026.10.49 → 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
+ }
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
3
3
  import { billingObligations, contacts, invoiceLines, invoices } from '../db/schema.js';
4
4
  import { FlowError } from './errors.js';
5
- import { listRules, rulePending, ruleSkip } from './invoice-rules.js';
5
+ import { listRules, ruleNonChargeable, rulePending, ruleSkip } from './invoice-rules.js';
6
6
  import { customerBillingByContact, effectiveSettings, readInvoicingSettings } from './invoicing-rules.js';
7
7
  import { issueInvoice } from './invoices.js';
8
8
  import { CHANGED_REASON, diffJobLines, STATUS_BACK_REASON } from './ledger.js';
@@ -20,7 +20,9 @@ import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
20
20
  // rules (invoice-rules.js) add their notes. An enabled skip rule does too:
21
21
  // a draft whose job it matches stays Pending, even when the draft was made
22
22
  // by hand or before the rule existed. A draft with a problem is
23
- // Pending (the "problem" view). A draft without one is Ready.
23
+ // Pending (the "problem" view). A draft without one is Ready. A draft whose
24
+ // job a non-chargeable rule matches is neither: it is Non-chargeable, and
25
+ // nothing sends it.
24
26
  //
25
27
  // Send all ready issues every ready draft (each gets its number) and, when an
26
28
  // accounting system is connected, queues it to go there. The send worker
@@ -97,14 +99,14 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
97
99
  // The lab's rules (invoice-rules.js). A pending rule puts its note on the
98
100
  // draft. A skip rule holds a draft that already exists: void it, or change
99
101
  // the rule. Matching stays in invoice-rules.js.
100
- const rules = (await listRules(db, organisationId)).filter((r) => r.enabled && (r.action.kind === 'pending' || r.action.kind === 'skip'));
102
+ const rules = (await listRules(db, organisationId)).filter((r) => r.enabled && ['pending', 'skip', 'non_chargeable'].includes(r.action.kind));
101
103
  if (rules.length && jobs.length) {
102
104
  const settings = await readInvoicingSettings(db, organisationId);
103
105
  const customers = await customerBillingByContact(db, organisationId, jobs.map((j) => j.obligation.contactId));
104
106
  for (const { obligation, reference, customerName } of jobs) {
105
107
  const customer = { ...effectiveSettings(settings, customers.get(obligation.contactId)), name: customerName ?? null };
106
108
  const job = { ...obligation, reference };
107
- const found = [...rulePending(rules, job, { customer }), ...ruleSkip(rules, job, { customer })];
109
+ const found = [...ruleNonChargeable(rules, job, { customer }), ...rulePending(rules, job, { customer }), ...ruleSkip(rules, job, { customer })];
108
110
  if (found.length) problems.set(obligation.invoiceId, [...(problems.get(obligation.invoiceId) ?? []), ...found]);
109
111
  }
110
112
  }
@@ -125,17 +127,28 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
125
127
  return problems;
126
128
  }
127
129
 
130
+ // A draft a non-chargeable rule matches has nothing to fix: it is never sent.
131
+ const nonChargeableOf = (found) => found.filter((p) => p.code === 'non_chargeable');
132
+
128
133
  // The views of the Invoices page. `accounting` is the accounting adapter, or
129
- // null. Every draft is in one view: Problem (a check failed; the Pending tab)
130
- // or Ready (its checks pass). An issued invoice is Sending while
131
- // queued, and a Problem when the accounting system refused it. Answers
132
- // { toCheck: [ids], ready: [ids], problems: Map(id -> problems), sending: Set(ids) }.
134
+ // null. Every draft is in one view: Non-chargeable (a non-chargeable rule
135
+ // matches its job), Problem (a check failed; the Pending tab) or Ready (its
136
+ // checks pass). An issued invoice is Sending while queued, and a Problem when
137
+ // the accounting system refused it. Answers { toCheck: [ids], ready: [ids],
138
+ // problems: Map(id -> problems), nonChargeable: Map(id -> rules), sending: Set(ids) }.
133
139
  export async function invoiceViews(db, organisationId, { accounting = null } = {}) {
134
140
  const drafts = await db.select({ id: invoices.id, totalCents: invoices.totalCents, readyAt: invoices.readyAt }).from(invoices)
135
141
  .where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'draft')))
136
142
  .orderBy(asc(invoices.createdAt), asc(invoices.id))
137
143
  .limit(CHECK_LIMIT);
138
144
  const problems = await checkDrafts(db, organisationId, drafts, accounting);
145
+ const nonChargeable = new Map();
146
+ for (const [id, found] of problems) {
147
+ const rules = nonChargeableOf(found);
148
+ if (!rules.length) continue;
149
+ nonChargeable.set(id, rules);
150
+ problems.delete(id);
151
+ }
139
152
  const sending = new Set();
140
153
  const issued = await db.select({ id: invoices.id, sendState: invoices.sendState, sendError: invoices.sendError }).from(invoices)
141
154
  .where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'issued'), inArray(invoices.sendState, ['queued', 'failed'])))
@@ -146,11 +159,12 @@ export async function invoiceViews(db, organisationId, { accounting = null } = {
146
159
  }
147
160
  // A draft whose checks pass is ready (Samuel, 2026-10-20): no person
148
161
  // needs to mark it. toCheck stays for callers that still read it.
149
- const clean = drafts.filter((d) => !problems.has(d.id));
162
+ const clean = drafts.filter((d) => !problems.has(d.id) && !nonChargeable.has(d.id));
150
163
  return {
151
164
  toCheck: [],
152
165
  ready: clean.map((d) => d.id),
153
166
  problems,
167
+ nonChargeable,
154
168
  sending,
155
169
  };
156
170
  }
@@ -164,6 +178,8 @@ export async function invoiceView(db, organisationId, invoice, { accounting = nu
164
178
  return { view: null, problems: [] };
165
179
  }
166
180
  const found = (await checkDrafts(db, organisationId, [invoice], accounting)).get(invoice.id) ?? [];
181
+ const rules = nonChargeableOf(found);
182
+ if (rules.length) return { view: 'non_chargeable', problems: [], nonChargeable: rules };
167
183
  return { view: found.length ? 'problem' : 'ready', problems: found };
168
184
  }
169
185
 
@@ -279,6 +295,8 @@ export async function sendAllReady(db, organisationId, { accounting = null, work
279
295
  ready = ready.filter((id) => wanted.has(id));
280
296
  held = wanted.size - ready.length;
281
297
  if (!ready.length) {
298
+ const kept = invoiceIds.map((id) => views.nonChargeable.get(id)?.[0]?.message).find(Boolean);
299
+ if (kept) throw new FlowError('not_ready', `Not sent: ${kept}`);
282
300
  const reason = invoiceIds.map((id) => views.problems.get(id)?.[0]?.message).find(Boolean);
283
301
  throw new FlowError('not_ready', reason
284
302
  ? `Not sent: ${reason.replace(/\.?$/, '.')} The invoice is on Pending.`