@modelprofile.com/flexharness 8.1.0 → 8.3.0
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_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.flexharness.d.ts +1 -0
- package/dist_ts/classes.flexharness.js +34 -10
- package/dist_ts/errors.d.ts +4 -1
- package/dist_ts/errors.js +20 -1
- package/dist_ts/interfaces.d.ts +19 -1
- package/dist_ts/interfaces.js +1 -1
- package/dist_ts/plugins.d.ts +4 -1
- package/dist_ts/plugins.js +3 -1
- package/dist_ts/utils.json.js +11 -2
- package/dist_ts/utils.validation.js +23 -15
- package/dist_ts_document/index.d.ts +50 -3
- package/dist_ts_document/index.js +96 -16
- package/package.json +3 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.flexharness.ts +34 -8
- package/ts/errors.ts +22 -1
- package/ts/interfaces.ts +20 -0
- package/ts/plugins.ts +7 -0
- package/ts/utils.json.ts +11 -1
- package/ts/utils.validation.ts +22 -16
- package/ts_document/index.ts +147 -18
- package/ts_document/readme.md +26 -0
package/ts/utils.json.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
1
2
|
import { FlexHarnessStoreFormatError, FlexHarnessValidationError } from './errors.js';
|
|
2
3
|
import {
|
|
3
4
|
FLEX_REVERSION_MAX_AFFECTED_WORKSPACES,
|
|
@@ -406,6 +407,12 @@ function requireOptionalString(value: unknown, path: string): string | undefined
|
|
|
406
407
|
return value === undefined ? undefined : requireString(value, path);
|
|
407
408
|
}
|
|
408
409
|
|
|
410
|
+
function requireOptionalModelLimit(value: unknown, path: string): void {
|
|
411
|
+
if (value !== undefined && !plugins.isModelLimitInfo(value)) {
|
|
412
|
+
throw new FlexHarnessStoreFormatError(`${path} is invalid.`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
409
416
|
function requireNonNegativeNumber(value: unknown, path: string): void {
|
|
410
417
|
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
|
411
418
|
throw new FlexHarnessStoreFormatError(`${path} must be a non-negative finite number.`);
|
|
@@ -501,7 +508,7 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
|
|
|
501
508
|
throw new FlexHarnessStoreFormatError(`${path}.status is invalid.`);
|
|
502
509
|
}
|
|
503
510
|
const activity = requireRecord(session.activity, `${path}.activity`);
|
|
504
|
-
requireOnlyKeys(activity, ['runId', 'status', 'startedAt', 'completedAt', 'error'], `${path}.activity`);
|
|
511
|
+
requireOnlyKeys(activity, ['runId', 'status', 'startedAt', 'completedAt', 'error', 'limit'], `${path}.activity`);
|
|
505
512
|
if (!activityStatuses.includes(activity.status as (typeof activityStatuses)[number])) {
|
|
506
513
|
throw new FlexHarnessStoreFormatError(`${path}.activity.status is invalid.`);
|
|
507
514
|
}
|
|
@@ -509,6 +516,7 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
|
|
|
509
516
|
requireOptionalString(activity.startedAt, `${path}.activity.startedAt`);
|
|
510
517
|
requireOptionalString(activity.completedAt, `${path}.activity.completedAt`);
|
|
511
518
|
requireOptionalString(activity.error, `${path}.activity.error`);
|
|
519
|
+
requireOptionalModelLimit(activity.limit, `${path}.activity.limit`);
|
|
512
520
|
const relationship = [
|
|
513
521
|
session.parentSessionId,
|
|
514
522
|
session.parentRunId,
|
|
@@ -667,6 +675,7 @@ function validateMessage(value: unknown, path: string): IValidatedMessageIdentit
|
|
|
667
675
|
'model',
|
|
668
676
|
'usage',
|
|
669
677
|
'error',
|
|
678
|
+
'limit',
|
|
670
679
|
],
|
|
671
680
|
path,
|
|
672
681
|
);
|
|
@@ -682,6 +691,7 @@ function validateMessage(value: unknown, path: string): IValidatedMessageIdentit
|
|
|
682
691
|
requireString(message.createdAt, `${path}.createdAt`);
|
|
683
692
|
requireOptionalString(message.completedAt, `${path}.completedAt`);
|
|
684
693
|
requireOptionalString(message.error, `${path}.error`);
|
|
694
|
+
requireOptionalModelLimit(message.limit, `${path}.limit`);
|
|
685
695
|
if (message.model !== undefined) validateModel(message.model, `${path}.model`);
|
|
686
696
|
if (message.usage !== undefined) validateUsage(message.usage, `${path}.usage`);
|
|
687
697
|
if (!Array.isArray(message.parts)) {
|
package/ts/utils.validation.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
1
2
|
import { FlexHarnessValidationError } from './errors.js';
|
|
2
3
|
import {
|
|
3
4
|
FLEX_PROJECT_MANAGEMENT_LIMITS,
|
|
@@ -162,30 +163,30 @@ export function validateDeleteSessionGenerationCohortInput(
|
|
|
162
163
|
return { root, authorizedBySessionId };
|
|
163
164
|
}
|
|
164
165
|
|
|
165
|
-
|
|
166
|
+
/** A plain object whose own properties are all string-keyed data properties, so reading them runs no code. */
|
|
167
|
+
function isPlainDataObject(value: unknown): value is Record<string, unknown> {
|
|
166
168
|
if (
|
|
167
169
|
!value
|
|
168
170
|
|| typeof value !== 'object'
|
|
169
171
|
|| Array.isArray(value)
|
|
170
172
|
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
171
|
-
)
|
|
173
|
+
) return false;
|
|
174
|
+
return Reflect.ownKeys(value).every((key) => {
|
|
175
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
176
|
+
return typeof key === 'string' && descriptor !== undefined && 'value' in descriptor;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function validateProjectedErrorInfo(value: unknown): IFlexErrorInfo {
|
|
181
|
+
if (!isPlainDataObject(value)) {
|
|
172
182
|
throw new FlexHarnessValidationError('External error projection is invalid.');
|
|
173
183
|
}
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
);
|
|
178
|
-
const name = descriptors.name?.value;
|
|
179
|
-
const message = descriptors.message?.value;
|
|
180
|
-
const code = descriptors.code?.value;
|
|
184
|
+
const unsupportedKey = Object.getOwnPropertyNames(value)
|
|
185
|
+
.find((key) => !['name', 'message', 'code', 'limit'].includes(key));
|
|
186
|
+
const { name, message, code, limit } = value;
|
|
181
187
|
if (
|
|
182
188
|
unsupportedKey
|
|
183
|
-
||
|
|
184
|
-
|| descriptors.name?.set !== undefined
|
|
185
|
-
|| descriptors.message?.get !== undefined
|
|
186
|
-
|| descriptors.message?.set !== undefined
|
|
187
|
-
|| descriptors.code?.get !== undefined
|
|
188
|
-
|| descriptors.code?.set !== undefined
|
|
189
|
+
|| (limit !== undefined && !(isPlainDataObject(limit) && plugins.isModelLimitInfo(limit)))
|
|
189
190
|
|| typeof name !== 'string'
|
|
190
191
|
|| !name
|
|
191
192
|
|| Buffer.byteLength(name, 'utf8') > maxProjectedErrorNameBytes
|
|
@@ -199,7 +200,12 @@ export function validateProjectedErrorInfo(value: unknown): IFlexErrorInfo {
|
|
|
199
200
|
) {
|
|
200
201
|
throw new FlexHarnessValidationError('External error projection is invalid.');
|
|
201
202
|
}
|
|
202
|
-
return Object.freeze({
|
|
203
|
+
return Object.freeze({
|
|
204
|
+
name,
|
|
205
|
+
message,
|
|
206
|
+
...(code === undefined ? {} : { code }),
|
|
207
|
+
...(limit === undefined ? {} : { limit: Object.freeze({ ...limit }) }),
|
|
208
|
+
});
|
|
203
209
|
}
|
|
204
210
|
|
|
205
211
|
export function validateUpdateSessionOptions(options: IFlexUpdateSessionOptions): void {
|
package/ts_document/index.ts
CHANGED
|
@@ -1,18 +1,44 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import type { ModelMessage } from 'ai';
|
|
3
|
-
import type { TSmartAiLanguageModel } from '@modelprofile.com/flexharness-models';
|
|
3
|
+
import type { TSmartAiLanguageModel, TSmartAiProviderOptions } from '@modelprofile.com/flexharness-models';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
type TSmartPdf = InstanceType<typeof plugins.smartpdf.SmartPdf>;
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
/** The one start of the shared SmartPDF instance; every request awaits it, and a failed start is forgotten. */
|
|
8
|
+
let smartpdfStart: Promise<TSmartPdf> | null = null;
|
|
9
|
+
|
|
10
|
+
function ensureSmartpdf(): Promise<TSmartPdf> {
|
|
11
|
+
if (!smartpdfStart) {
|
|
12
|
+
const instance = new plugins.smartpdf.SmartPdf();
|
|
13
|
+
const start = instance.start().then(() => instance);
|
|
14
|
+
smartpdfStart = start;
|
|
15
|
+
start.catch(() => {
|
|
16
|
+
if (smartpdfStart === start) {
|
|
17
|
+
smartpdfStart = null;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
11
20
|
}
|
|
12
|
-
return
|
|
21
|
+
return smartpdfStart;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Options every document request accepts in addition to its model and messages. */
|
|
25
|
+
export interface IDocumentRequestOptions {
|
|
26
|
+
/** Passed to the model call unchanged, for example `{ openai: { store: false } }`. */
|
|
27
|
+
providerOptions?: TSmartAiProviderOptions;
|
|
28
|
+
/**
|
|
29
|
+
* Checked before each document is rendered and before the model call, and passed to the model
|
|
30
|
+
* call. SmartPDF cannot stop a document it is rendering, so that render finishes first.
|
|
31
|
+
*/
|
|
32
|
+
abortSignal?: AbortSignal;
|
|
33
|
+
/**
|
|
34
|
+
* The most rendered pages one request may send to the model. A request with more pages rejects
|
|
35
|
+
* with `DocumentPageLimitError` before the model is called; documents after the one that
|
|
36
|
+
* exceeded the limit are not rendered.
|
|
37
|
+
*/
|
|
38
|
+
maxPages?: number;
|
|
13
39
|
}
|
|
14
40
|
|
|
15
|
-
export interface IDocumentOptions {
|
|
41
|
+
export interface IDocumentOptions extends IDocumentRequestOptions {
|
|
16
42
|
model: TSmartAiLanguageModel;
|
|
17
43
|
systemMessage?: string;
|
|
18
44
|
userMessage: string;
|
|
@@ -20,34 +46,111 @@ export interface IDocumentOptions {
|
|
|
20
46
|
messageHistory?: ModelMessage[];
|
|
21
47
|
}
|
|
22
48
|
|
|
23
|
-
export interface IPdfTextExtractionOptions {
|
|
49
|
+
export interface IPdfTextExtractionOptions extends IDocumentRequestOptions {
|
|
24
50
|
model: TSmartAiLanguageModel;
|
|
25
51
|
systemMessage?: string;
|
|
26
52
|
userMessage?: string;
|
|
27
53
|
messageHistory?: ModelMessage[];
|
|
28
54
|
}
|
|
29
55
|
|
|
56
|
+
/** Token usage of a document request, summed over its model steps; usage a provider leaves out counts as zero. */
|
|
57
|
+
export interface IDocumentUsage {
|
|
58
|
+
inputTokens: number;
|
|
59
|
+
outputTokens: number;
|
|
60
|
+
totalTokens: number;
|
|
61
|
+
cacheReadTokens: number;
|
|
62
|
+
cacheWriteTokens: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface IDocumentAnalysisResult {
|
|
66
|
+
text: string;
|
|
67
|
+
usage: IDocumentUsage;
|
|
68
|
+
/** Rendered pages sent to the model. A page that renders to an empty image is not sent. */
|
|
69
|
+
pageCount: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface IDocumentPageLimitErrorDetails {
|
|
73
|
+
renderedPages: number;
|
|
74
|
+
maxPages: number;
|
|
75
|
+
documentCount: number;
|
|
76
|
+
allDocumentsRendered: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A request whose documents render to more pages than its `maxPages`; the model was not called. */
|
|
80
|
+
export class DocumentPageLimitError extends Error {
|
|
81
|
+
/** Pages rendered before the request stopped; with several documents, later documents are not counted. */
|
|
82
|
+
public readonly renderedPages: number;
|
|
83
|
+
public readonly maxPages: number;
|
|
84
|
+
|
|
85
|
+
constructor(details: IDocumentPageLimitErrorDetails) {
|
|
86
|
+
const subject = details.documentCount === 1
|
|
87
|
+
? `The document has ${details.renderedPages} pages`
|
|
88
|
+
: details.allDocumentsRendered
|
|
89
|
+
? `The documents have ${details.renderedPages} pages`
|
|
90
|
+
: `The documents have at least ${details.renderedPages} pages`;
|
|
91
|
+
super(`${subject}, more than the ${details.maxPages} allowed.`);
|
|
92
|
+
this.name = 'DocumentPageLimitError';
|
|
93
|
+
this.renderedPages = details.renderedPages;
|
|
94
|
+
this.maxPages = details.maxPages;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const DEFAULT_OCR_SYSTEM_MESSAGE = 'You are a precise OCR engine. Extract text faithfully and do not summarize.';
|
|
99
|
+
const DEFAULT_OCR_USER_MESSAGE = 'Extract all readable text from this PDF. Preserve page order, line breaks, table structure, and obvious headings. Return only the extracted text.';
|
|
100
|
+
|
|
30
101
|
/** The model-based PDF OCR recipe formerly provided by SmartOCR. */
|
|
31
102
|
export async function extractTextFromPdf(
|
|
32
103
|
pdfDocument: Uint8Array,
|
|
33
104
|
options: IPdfTextExtractionOptions,
|
|
34
105
|
): Promise<string> {
|
|
35
|
-
return
|
|
106
|
+
return (await extractTextFromPdfWithUsage(pdfDocument, options)).text;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `extractTextFromPdf` that also returns the request's token usage and the pages sent. */
|
|
110
|
+
export async function extractTextFromPdfWithUsage(
|
|
111
|
+
pdfDocument: Uint8Array,
|
|
112
|
+
options: IPdfTextExtractionOptions,
|
|
113
|
+
): Promise<IDocumentAnalysisResult> {
|
|
114
|
+
return analyzeDocumentsWithUsage({
|
|
36
115
|
model: options.model,
|
|
37
|
-
systemMessage: options.systemMessage ??
|
|
38
|
-
userMessage: options.userMessage ??
|
|
116
|
+
systemMessage: options.systemMessage ?? DEFAULT_OCR_SYSTEM_MESSAGE,
|
|
117
|
+
userMessage: options.userMessage ?? DEFAULT_OCR_USER_MESSAGE,
|
|
39
118
|
pdfDocuments: [pdfDocument],
|
|
40
119
|
messageHistory: options.messageHistory,
|
|
120
|
+
providerOptions: options.providerOptions,
|
|
121
|
+
abortSignal: options.abortSignal,
|
|
122
|
+
maxPages: options.maxPages,
|
|
41
123
|
});
|
|
42
124
|
}
|
|
43
125
|
|
|
44
126
|
export async function analyzeDocuments(options: IDocumentOptions): Promise<string> {
|
|
45
|
-
|
|
127
|
+
return (await analyzeDocumentsWithUsage(options)).text;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** `analyzeDocuments` that also returns the request's token usage and the pages sent. */
|
|
131
|
+
export async function analyzeDocumentsWithUsage(options: IDocumentOptions): Promise<IDocumentAnalysisResult> {
|
|
132
|
+
const maxPages = options.maxPages;
|
|
133
|
+
if (maxPages !== undefined && (!Number.isSafeInteger(maxPages) || maxPages < 1)) {
|
|
134
|
+
throw new RangeError('maxPages must be a positive whole number.');
|
|
135
|
+
}
|
|
136
|
+
const abortSignal = options.abortSignal;
|
|
137
|
+
abortSignal?.throwIfAborted();
|
|
46
138
|
|
|
47
139
|
const imagePages: Uint8Array[] = [];
|
|
48
|
-
for (const doc of options.pdfDocuments) {
|
|
140
|
+
for (const [index, doc] of options.pdfDocuments.entries()) {
|
|
141
|
+
const pdf = await ensureSmartpdf();
|
|
142
|
+
abortSignal?.throwIfAborted();
|
|
49
143
|
const pages = await pdf.convertPDFToPngBytes(doc);
|
|
144
|
+
abortSignal?.throwIfAborted();
|
|
50
145
|
imagePages.push(...pages);
|
|
146
|
+
if (maxPages !== undefined && imagePages.length > maxPages) {
|
|
147
|
+
throw new DocumentPageLimitError({
|
|
148
|
+
renderedPages: imagePages.length,
|
|
149
|
+
maxPages,
|
|
150
|
+
documentCount: options.pdfDocuments.length,
|
|
151
|
+
allDocumentsRendered: index === options.pdfDocuments.length - 1,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
51
154
|
}
|
|
52
155
|
|
|
53
156
|
// Filter out empty buffers
|
|
@@ -70,13 +173,39 @@ export async function analyzeDocuments(options: IDocumentOptions): Promise<strin
|
|
|
70
173
|
],
|
|
71
174
|
},
|
|
72
175
|
],
|
|
176
|
+
providerOptions: options.providerOptions,
|
|
177
|
+
abortSignal,
|
|
73
178
|
});
|
|
74
|
-
|
|
179
|
+
const usage = result.totalUsage;
|
|
180
|
+
const inputTokens = usage.inputTokens ?? 0;
|
|
181
|
+
const outputTokens = usage.outputTokens ?? 0;
|
|
182
|
+
return {
|
|
183
|
+
text: result.text,
|
|
184
|
+
usage: {
|
|
185
|
+
inputTokens,
|
|
186
|
+
outputTokens,
|
|
187
|
+
totalTokens: inputTokens + outputTokens,
|
|
188
|
+
cacheReadTokens: usage.inputTokenDetails.cacheReadTokens ?? 0,
|
|
189
|
+
cacheWriteTokens: usage.inputTokenDetails.cacheWriteTokens ?? 0,
|
|
190
|
+
},
|
|
191
|
+
pageCount: validPages.length,
|
|
192
|
+
};
|
|
75
193
|
}
|
|
76
194
|
|
|
77
195
|
export async function stopSmartpdf(): Promise<void> {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
196
|
+
const start = smartpdfStart;
|
|
197
|
+
if (!start) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
// forget the instance before waiting on anything, so a request made from now on starts a fresh one
|
|
201
|
+
// instead of receiving the instance that is being stopped
|
|
202
|
+
smartpdfStart = null;
|
|
203
|
+
let instance: TSmartPdf;
|
|
204
|
+
try {
|
|
205
|
+
instance = await start;
|
|
206
|
+
} catch {
|
|
207
|
+
// a failed start left no started instance that could be stopped
|
|
208
|
+
return;
|
|
81
209
|
}
|
|
210
|
+
await instance.stop();
|
|
82
211
|
}
|
package/ts_document/readme.md
CHANGED
|
@@ -23,6 +23,32 @@ Supply a model from your selected FlexHarness provider. `extractTextFromPdf` rep
|
|
|
23
23
|
|
|
24
24
|
`analyzeDocuments({ model, userMessage, pdfDocuments, systemMessage?, messageHistory? })` supports general analysis across multiple PDFs. This component owns a shared SmartPDF instance; call `stopSmartpdf()` when all document work has finished. Image-only OCR is available independently in `flexharness/ocr`.
|
|
25
25
|
|
|
26
|
+
Both functions accept three request options:
|
|
27
|
+
|
|
28
|
+
- `providerOptions` is passed to the model call unchanged, for example `{ openai: { store: false } }` so the provider does not keep the request as application state.
|
|
29
|
+
- `abortSignal` is checked before each document is rendered and before the model call, and is passed to the model call. SmartPDF cannot stop a document it is already rendering, so that render finishes first.
|
|
30
|
+
- `maxPages` is the most rendered pages one request may send to the model. A request with more pages rejects with `DocumentPageLimitError` (`renderedPages`, `maxPages`) before the model is called, and documents after the one that exceeded the limit are not rendered.
|
|
31
|
+
|
|
32
|
+
`extractTextFromPdfWithUsage` and `analyzeDocumentsWithUsage` take the same arguments and return `{ text, usage, pageCount }`. `usage` has the shape of an agent run's usage (`inputTokens`, `outputTokens`, `totalTokens`, `cacheReadTokens`, `cacheWriteTokens`), summed over the request's model steps, with usage the provider leaves out counted as zero; `pageCount` is the number of rendered pages sent to the model.
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import { extractTextFromPdfWithUsage, DocumentPageLimitError } from '@modelprofile.com/flexharness/documents';
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const { text, usage, pageCount } = await extractTextFromPdfWithUsage(pdfBytes, {
|
|
39
|
+
model,
|
|
40
|
+
providerOptions: { openai: { store: false } },
|
|
41
|
+
abortSignal,
|
|
42
|
+
maxPages: 20,
|
|
43
|
+
});
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error instanceof DocumentPageLimitError) {
|
|
46
|
+
console.log(`${error.renderedPages} pages, at most ${error.maxPages}`);
|
|
47
|
+
}
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
26
52
|
## Issue Reporting and Security
|
|
27
53
|
|
|
28
54
|
Report issues and security concerns through [community.foss.global](https://community.foss.global).
|