@simpleplatform/sdk 2.3.0 → 2.4.1

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/README.md CHANGED
@@ -308,6 +308,63 @@ yet; attach its handle through the workflow that owns the record.
308
308
 
309
309
  ## API Documentation
310
310
 
311
+ ### Describing an Action
312
+
313
+ What an action is, and when to reach for it, is written where the code is — in
314
+ the JSDoc comment above the handler, the same comment that carries its
315
+ description:
316
+
317
+ ```typescript
318
+ /**
319
+ * Close a duplicate lead and point it at the record that survives.
320
+ *
321
+ * The surviving lead keeps its activity; the duplicate is marked closed and
322
+ * linked to it, so a later report still reaches both records.
323
+ *
324
+ * @tool
325
+ * @shortdesc Close a duplicate lead, pointing it at the surviving record.
326
+ * @usewhen A lead is a duplicate of one already in the system.
327
+ * @usewhen Two leads share a contact and one should be retired.
328
+ */
329
+ simple.Handle(async (request) => {
330
+ // ...
331
+ })
332
+ ```
333
+
334
+ | Tag | Shape | What it says |
335
+ | --------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
336
+ | `@tool` | bare, no value | This action can be reached as a tool |
337
+ | `@shortdesc` | one line, up to 300 characters, written once | What this is, read in a listing of tools |
338
+ | `@usewhen` | one line, up to 100 characters, up to ten times | One occasion for reaching for this rather than something else |
339
+ | `@parallelsafe` | bare, no value, only alongside `@tool` | This tool changes no stored data and sends nothing, so it may run at the same time as the other parallel-safe calls next to it in a batch |
340
+
341
+ `@parallelsafe` is a claim you make about your own action; the platform does
342
+ not verify it. It changes only which calls of a batch may overlap — consecutive
343
+ parallel-safe calls may run at the same time, and every other call still runs
344
+ alone, in the order asked. It never changes whether a failed call is retried,
345
+ nor the platform's assumption that a failed call may have changed stored data.
346
+ The build refuses it without `@tool`, with a value, or written twice. Write it
347
+ only when the action is read-only:
348
+
349
+ ```typescript
350
+ /**
351
+ * Look up a lead's open activity.
352
+ *
353
+ * @tool
354
+ * @shortdesc Look up a lead's open activity.
355
+ * @usewhen A caller wants a lead's current open activity.
356
+ * @parallelsafe
357
+ */
358
+ simple.Handle(async (request) => {
359
+ // reads only — no writes, no outbound calls
360
+ })
361
+ ```
362
+
363
+ The prose above the tags is the full description, and stays exactly as
364
+ written. Each tag is declared in `tsdoc.json` (`{"tagName": "@parallelsafe",
365
+ "syntaxKind": "modifier"}` alongside `@tool`, `@shortdesc` and `@usewhen`), so
366
+ an editor with TSDoc support recognizes it instead of flagging it as unknown.
367
+
311
368
  ### AI Module
312
369
 
313
370
  The AI module provides powerful capabilities for working with unstructured data.
@@ -382,6 +439,60 @@ console.log(result.data.summary) // "Customer called regarding..."
382
439
  console.log(result.data.participants) // ["Customer", "Support Agent"]
383
440
  ```
384
441
 
442
+ #### Transcribe PDF Pages
443
+
444
+ Read the pages of a PDF that have no usable text of their own — scans,
445
+ image-only exhibits — from their images. Each such page is transcribed once,
446
+ and it is the same transcription an `extract` or `summarize` that asks for text
447
+ is given in the page's place, so a quote on an image page can be checked
448
+ against the text the answer was built on. Pages with a readable text layer are
449
+ not returned.
450
+
451
+ ```typescript
452
+ import { transcribePages } from '@simpleplatform/sdk/ai'
453
+
454
+ const { data } = await transcribePages(
455
+ { ...contract, first_page: 40, last_page: 52 },
456
+ {},
457
+ request.context
458
+ )
459
+
460
+ for (const page of data.pages) {
461
+ if ('error' in page) {
462
+ console.log(`page ${page.page} could not be read: ${page.error}`)
463
+ continue
464
+ }
465
+
466
+ console.log(page.page, page.text.includes(quote))
467
+ }
468
+ ```
469
+
470
+ Pages are numbered in the original document. Transcriptions are kept per
471
+ version of the file and page, so a page already read for an `extract` or
472
+ `summarize` that asked for text (`deliver_as: 'text'`) is not read again.
473
+
474
+ A page is not transcribed twice to check itself: that would be the same model
475
+ reading the same image again, doubling the cost of every scanned page without
476
+ adding independence. To check an answer independently, read the pages a second
477
+ way — as the document itself (`deliver_as: 'document'`) — and compare.
478
+
479
+ #### How Files Travelled
480
+
481
+ Every AI result says how each file it carried reached the model, in
482
+ `metadata.delivery`: `deliveredAs` (`'document'`, `'text'` or `'image'`), the
483
+ range it was cut to, the pages transcribed from their images, and — when text
484
+ was asked for and the document was sent instead — a `fallback` naming the pages
485
+ that could not be read and why.
486
+
487
+ ```typescript
488
+ const result = await extract({ ...contract, deliver_as: 'text' }, { prompt, schema }, request.context)
489
+
490
+ for (const file of result.metadata.delivery ?? []) {
491
+ if (file.fallback)
492
+ console.log(`${file.filename} was read as a PDF: ${file.fallback.message}`)
493
+ }
494
+ ```
495
+
385
496
  ### GraphQL Module
386
497
 
387
498
  Execute type-safe database operations with GraphQL:
package/dist/ai.d.ts CHANGED
@@ -88,8 +88,12 @@ export type JSONSchema = JSONSchemaArray | JSONSchemaBoolean | JSONSchemaNumber
88
88
  * file,
89
89
  * which only ever travels as text.
90
90
  *
91
- * A page the platform cannot read as text sends the pages asked for as a PDF
92
- * instead, so an answer is never built on text with a hole in it.
91
+ * A page with no usable text of its own — a scan, or text that reads as noise
92
+ * — is read from its image instead, and that text stands in its place, marked
93
+ * `[Transcribed from the page image.]` under its page label. Only if that
94
+ * reading fails are the pages asked for sent as a PDF, so an answer is never
95
+ * built on text with a hole in it. The answer's `metadata.delivery` says which
96
+ * happened.
93
97
  *
94
98
  * Pages asked for as text arrive numbered from 1, because by then they are a
95
99
  * document of their own. A line above them says which pages of which document
@@ -203,6 +207,62 @@ export interface AITranscribeOptions extends Omit<AICommonOptions, 'prompt'> {
203
207
  */
204
208
  summarize?: boolean;
205
209
  }
210
+ /**
211
+ * A PDF whose pages are to be transcribed, and optionally which of them.
212
+ *
213
+ * `first_page` and `last_page` work as they do for any AI operation: counted
214
+ * from 1, both ends included, named together or not at all, and cut out first.
215
+ * `deliver_as` has no meaning here, because the pages are answered as text.
216
+ */
217
+ export type AITranscribePagesInput = DocumentHandle & Omit<AIDocumentInput, 'deliver_as' | keyof DocumentHandle>;
218
+ /**
219
+ * Configuration options for `transcribePages`. There is no prompt and no
220
+ * reasoning switch: the instructions are the platform's, and every page is
221
+ * read at the platform's own accuracy-first effort.
222
+ */
223
+ export interface AITranscribePagesOptions {
224
+ /**
225
+ * If true, runs the operation again rather than serving its kept answer.
226
+ * A page already read is still served from its kept read, which is tied to
227
+ * this version of the file and to the platform's instructions.
228
+ */
229
+ regenerate?: boolean;
230
+ /** (Optional) The maximum time in milliseconds to wait for the operation. */
231
+ timeout?: number;
232
+ }
233
+ /**
234
+ * One page's transcription, or why it has none. Pages are numbered in the
235
+ * original document.
236
+ */
237
+ export type AIPageTranscription = {
238
+ /** Why the page could not be read. No partial text is given. */
239
+ error: string;
240
+ /** The page's number in the original document. */
241
+ page: number;
242
+ } | {
243
+ /** The page's number in the original document. */
244
+ page: number;
245
+ /**
246
+ * The page's text as read from its image: verbatim markdown, in reading
247
+ * order, tables as markdown tables, `[illegible]` where a word could not
248
+ * be read. An empty string is a read that found the page blank.
249
+ */
250
+ text: string;
251
+ };
252
+ /**
253
+ * The response of `transcribePages`.
254
+ */
255
+ export interface AITranscriptionResult {
256
+ /** Every page of the document, or of the range, that has no usable text layer. */
257
+ data: {
258
+ pages: AIPageTranscription[];
259
+ };
260
+ /**
261
+ * The operation's metadata. Its token counts are zero: each page read is
262
+ * charged where it was made, once, whichever operation asked for it.
263
+ */
264
+ metadata: AIExecutionResult['metadata'];
265
+ }
206
266
  /**
207
267
  * Options for searching faces within the Face Engine.
208
268
  */
@@ -218,6 +278,34 @@ export interface AIFaceSearchOptions {
218
278
  */
219
279
  similarityThreshold?: number;
220
280
  }
281
+ /**
282
+ * How one file an AI operation carried reached the model. Page numbers are
283
+ * the original document's, whatever range was cut from it.
284
+ */
285
+ export interface AIFileDelivery {
286
+ /** How the file travelled: as the document itself, as its text, or as an image. */
287
+ deliveredAs: 'document' | 'image' | 'text';
288
+ /**
289
+ * Present only when text was asked for and the document was sent instead:
290
+ * the pages whose images could not be read, and why.
291
+ */
292
+ fallback?: {
293
+ /** What went wrong, page by page, in the platform's words. */
294
+ message: string;
295
+ /** The pages that could not be transcribed. */
296
+ pages: number[];
297
+ /** Why the document travelled instead of its text. */
298
+ reason: 'transcription_failed';
299
+ };
300
+ /** The file's name. */
301
+ filename: string;
302
+ /** The first page of the range the file was cut to, when one was named. */
303
+ firstPage?: number;
304
+ /** The last page of the range the file was cut to, when one was named. */
305
+ lastPage?: number;
306
+ /** The pages whose text was read from their images rather than their text layer. */
307
+ transcribedPages: number[];
308
+ }
221
309
  /**
222
310
  * The response structure from a successful AI `extract` or `summarize` operation.
223
311
  */
@@ -232,6 +320,12 @@ export interface AIExecutionResult {
232
320
  * providing context to the user.
233
321
  */
234
322
  metadata: {
323
+ /**
324
+ * How each file the operation carried reached the model, in input order.
325
+ * Absent when the operation carried no file, and on an answer kept from
326
+ * before the platform reported it.
327
+ */
328
+ delivery?: AIFileDelivery[];
235
329
  /** The number of tokens in the input prompt. */
236
330
  inputTokens: number;
237
331
  /** The number of tokens in the generated output. */
@@ -301,6 +395,48 @@ export declare function summarize(input: AIDocumentInput | DocumentHandle | obje
301
395
  * // Returns: { language: "en", summary: "...", participants: ["Participant 1", "Participant 2"] }
302
396
  */
303
397
  export declare function transcribe(input: DocumentHandle, options: AITranscribeOptions, context: Context): Promise<AIExecutionResult>;
398
+ /**
399
+ * Transcribes the pages of a PDF that have no usable text of their own — scans,
400
+ * image-only exhibits, text that reads as noise — from their images.
401
+ *
402
+ * Every such page is read once, and it is the same transcription an `extract`
403
+ * or `summarize` that asked for text is given in the page's place, so a caller
404
+ * can check that a quote on an image page is really there by looking for it in
405
+ * the text the answer was built on. Pages the platform reads as text are
406
+ * neither read nor returned.
407
+ *
408
+ * A page is not transcribed a second time to check the first: that would be
409
+ * the same model reading the same image again, doubling the cost of every
410
+ * scanned page without being independent of the first read. An independent
411
+ * check reads the pages a second way — as the document itself
412
+ * (`deliver_as: 'document'`) — and compares the answers.
413
+ *
414
+ * Transcriptions are kept per version of the file and page, and shared with
415
+ * every other AI operation: a page already read for an `extract` or
416
+ * `summarize` that asked for text is not read again.
417
+ *
418
+ * @param document The PDF, as a DocumentHandle. Add `first_page` and
419
+ * `last_page` to transcribe only those pages; they are counted from 1 and
420
+ * the answer numbers pages in the original document.
421
+ * @param options Optional `regenerate` and `timeout`.
422
+ * @param context The execution context provided by the host.
423
+ * @returns A promise that resolves to each page's transcription, or why it has none.
424
+ * @throws Will throw an error if the operation fails or the input is not a PDF.
425
+ *
426
+ * @example
427
+ * const { data } = await transcribePages(
428
+ * { ...contract, first_page: 40, last_page: 52 },
429
+ * {},
430
+ * context,
431
+ * )
432
+ *
433
+ * for (const page of data.pages) {
434
+ * if ('error' in page)
435
+ * continue
436
+ * const quoted = page.text.includes(quote)
437
+ * }
438
+ */
439
+ export declare function transcribePages(document: AITranscribePagesInput, options: AITranscribePagesOptions, context: Context): Promise<AITranscriptionResult>;
304
440
  /**
305
441
  * Enrolls a face for a subject. The face is associated with the given `subjectId`
306
442
  * within the tenant's secure collection.
package/dist/ai.js CHANGED
@@ -85,6 +85,7 @@ async function _executeAIOperation(operation, input, options, context) {
85
85
  return {
86
86
  data,
87
87
  metadata: {
88
+ ...(Array.isArray(metadata.delivery) && { delivery: metadata.delivery.map(_fileDelivery) }),
88
89
  inputTokens: metadata.input_tokens,
89
90
  outputTokens: metadata.output_tokens,
90
91
  reasoning: metadata.reasoning,
@@ -92,6 +93,38 @@ async function _executeAIOperation(operation, input, options, context) {
92
93
  },
93
94
  };
94
95
  }
96
+ /**
97
+ * One file's delivery as the platform reports it, in the SDK's own casing.
98
+ * A key the platform sends as `null` is left out rather than carried as one.
99
+ *
100
+ * @internal
101
+ */
102
+ function _fileDelivery(file) {
103
+ return {
104
+ deliveredAs: file.delivered_as,
105
+ ...(file.fallback && { fallback: file.fallback }),
106
+ filename: file.filename,
107
+ ...(typeof file.first_page === 'number' && { firstPage: file.first_page }),
108
+ ...(typeof file.last_page === 'number' && { lastPage: file.last_page }),
109
+ transcribedPages: Array.isArray(file.transcribed_pages) ? file.transcribed_pages : [],
110
+ };
111
+ }
112
+ /**
113
+ * One page's transcription as the platform reports it: its text or its error,
114
+ * never both. An error wins, so no partial text is handed on, and a page that
115
+ * carries no text is reported as unread rather than as text that is not there.
116
+ *
117
+ * @internal
118
+ */
119
+ function _pageTranscription(page) {
120
+ if (typeof page.text === 'string' && typeof page.error !== 'string') {
121
+ return { page: page.page, text: page.text };
122
+ }
123
+ return {
124
+ error: typeof page.error === 'string' ? page.error : 'no transcription was returned for this page',
125
+ page: page.page,
126
+ };
127
+ }
95
128
  // ============================================================================
96
129
  // Public SDK Functions
97
130
  // ============================================================================
@@ -284,6 +317,61 @@ export async function transcribe(input, options, context) {
284
317
  schema,
285
318
  }, context);
286
319
  }
320
+ /**
321
+ * Transcribes the pages of a PDF that have no usable text of their own — scans,
322
+ * image-only exhibits, text that reads as noise — from their images.
323
+ *
324
+ * Every such page is read once, and it is the same transcription an `extract`
325
+ * or `summarize` that asked for text is given in the page's place, so a caller
326
+ * can check that a quote on an image page is really there by looking for it in
327
+ * the text the answer was built on. Pages the platform reads as text are
328
+ * neither read nor returned.
329
+ *
330
+ * A page is not transcribed a second time to check the first: that would be
331
+ * the same model reading the same image again, doubling the cost of every
332
+ * scanned page without being independent of the first read. An independent
333
+ * check reads the pages a second way — as the document itself
334
+ * (`deliver_as: 'document'`) — and compares the answers.
335
+ *
336
+ * Transcriptions are kept per version of the file and page, and shared with
337
+ * every other AI operation: a page already read for an `extract` or
338
+ * `summarize` that asked for text is not read again.
339
+ *
340
+ * @param document The PDF, as a DocumentHandle. Add `first_page` and
341
+ * `last_page` to transcribe only those pages; they are counted from 1 and
342
+ * the answer numbers pages in the original document.
343
+ * @param options Optional `regenerate` and `timeout`.
344
+ * @param context The execution context provided by the host.
345
+ * @returns A promise that resolves to each page's transcription, or why it has none.
346
+ * @throws Will throw an error if the operation fails or the input is not a PDF.
347
+ *
348
+ * @example
349
+ * const { data } = await transcribePages(
350
+ * { ...contract, first_page: 40, last_page: 52 },
351
+ * {},
352
+ * context,
353
+ * )
354
+ *
355
+ * for (const page of data.pages) {
356
+ * if ('error' in page)
357
+ * continue
358
+ * const quoted = page.text.includes(quote)
359
+ * }
360
+ */
361
+ export async function transcribePages(document, options, context) {
362
+ if (!document || typeof document !== 'object' || !document.file_hash) {
363
+ throw new Error('The `document` parameter must be a valid DocumentHandle for `transcribePages`.');
364
+ }
365
+ const mimeType = (document.mime_type || '').split(';')[0].trim().toLowerCase();
366
+ if (mimeType !== 'application/pdf') {
367
+ throw new Error('`transcribePages` reads the pages of a PDF.');
368
+ }
369
+ const result = await _executeAIOperation('transcribe', document, options, context);
370
+ return {
371
+ data: { pages: Array.isArray(result.data?.pages) ? result.data.pages.map(_pageTranscription) : [] },
372
+ metadata: result.metadata,
373
+ };
374
+ }
287
375
  // ============================================================================
288
376
  // Face Recognition API
289
377
  // ============================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simpleplatform/sdk",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
4
4
  "description": "Simple Platform Typescript SDK",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://docs.simple.dev",