react-native-ai-core 0.1.0 → 0.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/LICENSE +1 -1
- package/README.md +153 -3
- package/android/src/main/AndroidManifest.xml +12 -0
- package/android/src/main/java/com/aicore/AiCoreModule.kt +560 -74
- package/android/src/main/java/com/aicore/InferenceService.kt +85 -0
- package/lib/module/NativeAiCore.js +2 -2
- package/lib/module/NativeAiCore.js.map +1 -1
- package/lib/module/index.js +61 -52
- package/lib/module/index.js.map +1 -1
- package/lib/module/structured.js +752 -0
- package/lib/module/structured.js.map +1 -0
- package/lib/typescript/src/NativeAiCore.d.ts +26 -15
- package/lib/typescript/src/NativeAiCore.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +55 -41
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/structured.d.ts +39 -0
- package/lib/typescript/src/structured.d.ts.map +1 -0
- package/package.json +8 -5
- package/src/NativeAiCore.ts +29 -16
- package/src/index.tsx +74 -55
- package/src/structured.ts +1170 -0
|
@@ -0,0 +1,1170 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
import { z, type ZodTypeAny } from 'zod';
|
|
3
|
+
import NativeAiCore from './NativeAiCore';
|
|
4
|
+
|
|
5
|
+
export type StructuredSchema<T> = z.ZodType<T>;
|
|
6
|
+
|
|
7
|
+
export interface StructuredGenerateOptions<
|
|
8
|
+
TOutputSchema extends ZodTypeAny,
|
|
9
|
+
TInput = unknown,
|
|
10
|
+
> {
|
|
11
|
+
prompt: string;
|
|
12
|
+
output: TOutputSchema;
|
|
13
|
+
input?: TInput;
|
|
14
|
+
inputSchema?: z.ZodType<TInput>;
|
|
15
|
+
maxRetries?: number;
|
|
16
|
+
maxContinuations?: number;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
/**
|
|
19
|
+
* 'single' — generate the entire JSON in one call (default).
|
|
20
|
+
* 'chunked' — generate each top-level field separately and assemble the object.
|
|
21
|
+
* Recommended for schemas with many fields or long responses.
|
|
22
|
+
*/
|
|
23
|
+
strategy?: 'single' | 'chunked';
|
|
24
|
+
/**
|
|
25
|
+
* Called at each step of chunked generation.
|
|
26
|
+
* @param field JSON path of the field being generated, e.g. "days.0.exercises"
|
|
27
|
+
* @param done true when the field has just been completed
|
|
28
|
+
*/
|
|
29
|
+
onProgress?: (field: string, done: boolean) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Optional AbortSignal to cancel the generation mid-way.
|
|
32
|
+
* When aborted, the promise rejects with an Error whose `name` is `'AbortError'`.
|
|
33
|
+
*/
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface StructuredValidationIssue {
|
|
38
|
+
path: string;
|
|
39
|
+
message: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class StructuredOutputError extends Error {
|
|
43
|
+
issues: StructuredValidationIssue[];
|
|
44
|
+
rawResponse: string;
|
|
45
|
+
|
|
46
|
+
constructor(
|
|
47
|
+
message: string,
|
|
48
|
+
rawResponse: string,
|
|
49
|
+
issues: StructuredValidationIssue[] = []
|
|
50
|
+
) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = 'StructuredOutputError';
|
|
53
|
+
this.rawResponse = rawResponse;
|
|
54
|
+
this.issues = issues;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const STRUCTURED_PROMPT_BUDGET = 2600;
|
|
59
|
+
const STRUCTURED_REPAIR_RESPONSE_BUDGET = 1200;
|
|
60
|
+
const STRUCTURED_ISSUES_BUDGET = 600;
|
|
61
|
+
const STRUCTURED_CONTINUATION_BUDGET = 1400;
|
|
62
|
+
const DEFAULT_MAX_STRUCTURED_CONTINUATIONS = 8;
|
|
63
|
+
const CONTINUATION_OVERLAP_WINDOW = 160;
|
|
64
|
+
const DEFAULT_STRUCTURED_TIMEOUT_MS = 300000; // 5 min default
|
|
65
|
+
const QUOTA_ERROR_CODE = 9;
|
|
66
|
+
const QUOTA_RETRY_DELAY_MS = 1800;
|
|
67
|
+
const MAX_QUOTA_RETRIES = 2;
|
|
68
|
+
|
|
69
|
+
function makeAbortError(): Error {
|
|
70
|
+
return Object.assign(new Error('Generation cancelled.'), {
|
|
71
|
+
name: 'AbortError',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
if (signal?.aborted) {
|
|
78
|
+
reject(makeAbortError());
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const timer = setTimeout(resolve, ms);
|
|
82
|
+
signal?.addEventListener(
|
|
83
|
+
'abort',
|
|
84
|
+
() => {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
reject(makeAbortError());
|
|
87
|
+
},
|
|
88
|
+
{ once: true }
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function toErrorMessage(error: unknown): string {
|
|
94
|
+
if (error instanceof Error) return error.message;
|
|
95
|
+
if (typeof error === 'string') return error;
|
|
96
|
+
try {
|
|
97
|
+
return JSON.stringify(error);
|
|
98
|
+
} catch {
|
|
99
|
+
return String(error);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readNumericErrorCode(error: unknown): number | null {
|
|
104
|
+
if (typeof error === 'object' && error !== null) {
|
|
105
|
+
const directCode = (error as { errorCode?: unknown }).errorCode;
|
|
106
|
+
if (typeof directCode === 'number') return directCode;
|
|
107
|
+
|
|
108
|
+
const code = (error as { code?: unknown }).code;
|
|
109
|
+
if (typeof code === 'number') return code;
|
|
110
|
+
if (typeof code === 'string') {
|
|
111
|
+
const parsed = Number.parseInt(code, 10);
|
|
112
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const message = toErrorMessage(error);
|
|
117
|
+
const match = message.match(/error\s*code\s*[:=]?\s*(\d+)/i);
|
|
118
|
+
if (!match) return null;
|
|
119
|
+
const capturedCode = match[1];
|
|
120
|
+
if (!capturedCode) return null;
|
|
121
|
+
const parsed = Number.parseInt(capturedCode, 10);
|
|
122
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isQuotaError(error: unknown): boolean {
|
|
126
|
+
const code = readNumericErrorCode(error);
|
|
127
|
+
if (code === QUOTA_ERROR_CODE) return true;
|
|
128
|
+
const message = toErrorMessage(error).toLowerCase();
|
|
129
|
+
return (
|
|
130
|
+
message.includes('out of quota') ||
|
|
131
|
+
message.includes('quota exceeded') ||
|
|
132
|
+
message.includes('error code: 9')
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function truncateStart(text: string, maxChars: number): string {
|
|
137
|
+
if (text.length <= maxChars) return text;
|
|
138
|
+
return text.slice(text.length - maxChars);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function truncateEnd(text: string, maxChars: number): string {
|
|
142
|
+
if (text.length <= maxChars) return text;
|
|
143
|
+
return text.slice(0, maxChars);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function compactWhitespace(text: string): string {
|
|
147
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function stringifyInput(value: unknown, maxChars: number): string {
|
|
151
|
+
return truncateStart(JSON.stringify(value, null, 2), maxChars);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function fitStructuredPrompt(parts: string[]): string {
|
|
155
|
+
return truncateStart(
|
|
156
|
+
parts.filter(Boolean).join('\n'),
|
|
157
|
+
STRUCTURED_PROMPT_BUDGET
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function fitContinuationPrompt(parts: string[]): string {
|
|
162
|
+
return truncateStart(
|
|
163
|
+
parts.filter(Boolean).join('\n'),
|
|
164
|
+
STRUCTURED_CONTINUATION_BUDGET
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function zodTypeToDescription(schema: ZodTypeAny): string {
|
|
169
|
+
if (schema instanceof z.ZodString) return 'string';
|
|
170
|
+
if (schema instanceof z.ZodNumber) return 'number';
|
|
171
|
+
if (schema instanceof z.ZodBoolean) return 'boolean';
|
|
172
|
+
if (schema instanceof z.ZodNull) return 'null';
|
|
173
|
+
if (schema instanceof z.ZodEnum) {
|
|
174
|
+
return `enum(${schema.options
|
|
175
|
+
.map((value: string) => JSON.stringify(value))
|
|
176
|
+
.join(', ')})`;
|
|
177
|
+
}
|
|
178
|
+
if (schema instanceof z.ZodLiteral) {
|
|
179
|
+
return `literal(${JSON.stringify(schema.value)})`;
|
|
180
|
+
}
|
|
181
|
+
if (schema instanceof z.ZodArray) {
|
|
182
|
+
return `Array<${zodTypeToDescription(schema.element as unknown as ZodTypeAny)}>`;
|
|
183
|
+
}
|
|
184
|
+
if (schema instanceof z.ZodOptional) {
|
|
185
|
+
return `${zodTypeToDescription(schema.unwrap() as unknown as ZodTypeAny)} | undefined`;
|
|
186
|
+
}
|
|
187
|
+
if (schema instanceof z.ZodNullable) {
|
|
188
|
+
return `${zodTypeToDescription(schema.unwrap() as unknown as ZodTypeAny)} | null`;
|
|
189
|
+
}
|
|
190
|
+
if (schema instanceof z.ZodUnion) {
|
|
191
|
+
return schema.options
|
|
192
|
+
.map((option: unknown) => zodTypeToDescription(option as ZodTypeAny))
|
|
193
|
+
.join(' | ');
|
|
194
|
+
}
|
|
195
|
+
if (schema instanceof z.ZodObject) {
|
|
196
|
+
const shape = schema.shape;
|
|
197
|
+
const entries = Object.entries(shape).map(
|
|
198
|
+
([key, value]) => ` ${key}: ${zodTypeToDescription(value as ZodTypeAny)}`
|
|
199
|
+
);
|
|
200
|
+
return `{
|
|
201
|
+
${entries.join(',\n')}
|
|
202
|
+
}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return 'unknown';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function formatIssues(error: z.ZodError): StructuredValidationIssue[] {
|
|
209
|
+
return error.issues.map((issue) => ({
|
|
210
|
+
path: issue.path.join('.') || '$',
|
|
211
|
+
message: issue.message,
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function extractFencedJson(text: string): string | null {
|
|
216
|
+
const match = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
217
|
+
return match?.[1]?.trim() ?? null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function extractBalancedJson(text: string): string | null {
|
|
221
|
+
let start = -1;
|
|
222
|
+
let depth = 0;
|
|
223
|
+
let inString = false;
|
|
224
|
+
let escaped = false;
|
|
225
|
+
|
|
226
|
+
for (let index = 0; index < text.length; index++) {
|
|
227
|
+
const char = text[index];
|
|
228
|
+
|
|
229
|
+
if (inString) {
|
|
230
|
+
if (escaped) {
|
|
231
|
+
escaped = false;
|
|
232
|
+
} else if (char === '\\') {
|
|
233
|
+
escaped = true;
|
|
234
|
+
} else if (char === '"') {
|
|
235
|
+
inString = false;
|
|
236
|
+
}
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (char === '"') {
|
|
241
|
+
inString = true;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (char === '{' || char === '[') {
|
|
246
|
+
if (depth === 0) start = index;
|
|
247
|
+
depth++;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (char === '}' || char === ']') {
|
|
252
|
+
depth--;
|
|
253
|
+
if (depth === 0 && start >= 0) {
|
|
254
|
+
return text.slice(start, index + 1);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function extractJsonPayload(raw: string): string {
|
|
263
|
+
const trimmed = raw.trim();
|
|
264
|
+
const fenced = extractFencedJson(trimmed);
|
|
265
|
+
if (fenced) return fenced;
|
|
266
|
+
const balanced = extractBalancedJson(trimmed);
|
|
267
|
+
if (balanced) return balanced;
|
|
268
|
+
return trimmed;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function stripCodeFences(text: string): string {
|
|
272
|
+
return text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function mergeStructuredFragments(existing: string, incoming: string): string {
|
|
276
|
+
const normalizedIncoming = stripCodeFences(incoming).trimStart();
|
|
277
|
+
if (!normalizedIncoming) return existing;
|
|
278
|
+
if (!existing) return normalizedIncoming;
|
|
279
|
+
|
|
280
|
+
const maxOverlap = Math.min(
|
|
281
|
+
CONTINUATION_OVERLAP_WINDOW,
|
|
282
|
+
existing.length,
|
|
283
|
+
normalizedIncoming.length
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
for (let size = maxOverlap; size > 0; size--) {
|
|
287
|
+
if (existing.endsWith(normalizedIncoming.slice(0, size))) {
|
|
288
|
+
return existing + normalizedIncoming.slice(size);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return existing + normalizedIncoming;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function looksLikeIncompleteJson(text: string): boolean {
|
|
296
|
+
const candidate = extractJsonPayload(text).trim();
|
|
297
|
+
if (!candidate) return false;
|
|
298
|
+
|
|
299
|
+
let depth = 0;
|
|
300
|
+
let inString = false;
|
|
301
|
+
let escaped = false;
|
|
302
|
+
|
|
303
|
+
for (let index = 0; index < candidate.length; index++) {
|
|
304
|
+
const char = candidate[index];
|
|
305
|
+
|
|
306
|
+
if (inString) {
|
|
307
|
+
if (escaped) {
|
|
308
|
+
escaped = false;
|
|
309
|
+
} else if (char === '\\') {
|
|
310
|
+
escaped = true;
|
|
311
|
+
} else if (char === '"') {
|
|
312
|
+
inString = false;
|
|
313
|
+
}
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (char === '"') {
|
|
318
|
+
inString = true;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (char === '{' || char === '[') {
|
|
323
|
+
depth++;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (char === '}' || char === ']') {
|
|
328
|
+
depth = Math.max(0, depth - 1);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const lastChar = candidate[candidate.length - 1];
|
|
333
|
+
return inString || depth > 0 || lastChar === ',' || lastChar === ':';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function buildStructuredContinuationPrompt<TInput>(
|
|
337
|
+
prompt: string,
|
|
338
|
+
outputSchema: ZodTypeAny,
|
|
339
|
+
partialResponse: string,
|
|
340
|
+
input?: TInput
|
|
341
|
+
): string {
|
|
342
|
+
const schemaDescription = truncateEnd(
|
|
343
|
+
zodTypeToDescription(outputSchema),
|
|
344
|
+
700
|
|
345
|
+
);
|
|
346
|
+
const normalizedPrompt = truncateEnd(compactWhitespace(prompt), 500);
|
|
347
|
+
const serializedInput =
|
|
348
|
+
input === undefined ? '' : `Input JSON:\n${stringifyInput(input, 500)}`;
|
|
349
|
+
const partialTail = truncateStart(stripCodeFences(partialResponse), 900);
|
|
350
|
+
|
|
351
|
+
return fitContinuationPrompt([
|
|
352
|
+
'Continue the same JSON value from the next character.',
|
|
353
|
+
'Do not restart, repeat, explain, summarize, or wrap in markdown.',
|
|
354
|
+
'Output only the missing suffix needed to complete the same valid JSON.',
|
|
355
|
+
'Schema:',
|
|
356
|
+
schemaDescription,
|
|
357
|
+
`Task:\n${normalizedPrompt}`,
|
|
358
|
+
serializedInput,
|
|
359
|
+
'Current partial JSON tail:',
|
|
360
|
+
partialTail,
|
|
361
|
+
]);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function buildJsonParseIssues(error: unknown): StructuredValidationIssue[] {
|
|
365
|
+
if (error instanceof z.ZodError) {
|
|
366
|
+
return formatIssues(error);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (error instanceof Error) {
|
|
370
|
+
return [{ path: '$', message: error.message }];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return [{ path: '$', message: 'Unknown structured output error' }];
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function generateStructuredRawResponse<
|
|
377
|
+
TOutputSchema extends ZodTypeAny,
|
|
378
|
+
TInput = unknown,
|
|
379
|
+
>(
|
|
380
|
+
prompt: string,
|
|
381
|
+
output: TOutputSchema,
|
|
382
|
+
input: TInput | undefined,
|
|
383
|
+
maxContinuations: number,
|
|
384
|
+
timeoutMs: number
|
|
385
|
+
): Promise<string> {
|
|
386
|
+
let combined = await generateStatelessWithQuotaRetry(
|
|
387
|
+
buildStructuredPrompt(prompt, output, input),
|
|
388
|
+
timeoutMs
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
for (let attempt = 0; attempt < maxContinuations; attempt++) {
|
|
392
|
+
try {
|
|
393
|
+
JSON.parse(extractJsonPayload(combined));
|
|
394
|
+
return combined;
|
|
395
|
+
} catch {
|
|
396
|
+
if (!looksLikeIncompleteJson(combined)) {
|
|
397
|
+
return combined;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const continuationPrompt = buildStructuredContinuationPrompt(
|
|
402
|
+
prompt,
|
|
403
|
+
output,
|
|
404
|
+
combined,
|
|
405
|
+
input
|
|
406
|
+
);
|
|
407
|
+
const continuation = await tryGenerateWithQuotaTolerance(
|
|
408
|
+
continuationPrompt,
|
|
409
|
+
timeoutMs
|
|
410
|
+
);
|
|
411
|
+
if (continuation === null) {
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
const merged = mergeStructuredFragments(combined, continuation);
|
|
415
|
+
if (merged === combined) {
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
combined = merged;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return combined;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function buildStructuredPrompt<TInput>(
|
|
425
|
+
prompt: string,
|
|
426
|
+
outputSchema: ZodTypeAny,
|
|
427
|
+
input?: TInput
|
|
428
|
+
): string {
|
|
429
|
+
const schemaDescription = truncateEnd(
|
|
430
|
+
zodTypeToDescription(outputSchema),
|
|
431
|
+
900
|
|
432
|
+
);
|
|
433
|
+
const normalizedPrompt = truncateEnd(compactWhitespace(prompt), 900);
|
|
434
|
+
const serializedInput =
|
|
435
|
+
input === undefined ? '' : `Input JSON:\n${stringifyInput(input, 900)}`;
|
|
436
|
+
|
|
437
|
+
return fitStructuredPrompt([
|
|
438
|
+
'Return only valid JSON.',
|
|
439
|
+
'Do not include markdown, code fences, comments, prose, or explanations.',
|
|
440
|
+
'The JSON must match this exact TypeScript-like schema:',
|
|
441
|
+
schemaDescription,
|
|
442
|
+
`Task:\n${normalizedPrompt}`,
|
|
443
|
+
serializedInput,
|
|
444
|
+
]);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function buildRepairPrompt(
|
|
448
|
+
prompt: string,
|
|
449
|
+
outputSchema: ZodTypeAny,
|
|
450
|
+
invalidResponse: string,
|
|
451
|
+
issues: StructuredValidationIssue[]
|
|
452
|
+
): string {
|
|
453
|
+
const issueText = issues
|
|
454
|
+
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
|
455
|
+
.join('\n');
|
|
456
|
+
const normalizedIssues = truncateEnd(issueText, STRUCTURED_ISSUES_BUDGET);
|
|
457
|
+
const normalizedInvalidResponse = truncateStart(
|
|
458
|
+
compactWhitespace(extractJsonPayload(invalidResponse)),
|
|
459
|
+
STRUCTURED_REPAIR_RESPONSE_BUDGET
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
return fitStructuredPrompt([
|
|
463
|
+
buildStructuredPrompt(prompt, outputSchema),
|
|
464
|
+
'Your previous response was invalid.',
|
|
465
|
+
'Fix it and return only corrected JSON.',
|
|
466
|
+
'Validation errors:',
|
|
467
|
+
normalizedIssues,
|
|
468
|
+
'Previous response:',
|
|
469
|
+
normalizedInvalidResponse,
|
|
470
|
+
]);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function generateStateless(
|
|
474
|
+
prompt: string,
|
|
475
|
+
signal?: AbortSignal
|
|
476
|
+
): Promise<string> {
|
|
477
|
+
if (!NativeAiCore) {
|
|
478
|
+
throw new Error(
|
|
479
|
+
`react-native-ai-core: native module unavailable on ${Platform.OS}. This feature requires Android.`
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (signal?.aborted) throw makeAbortError();
|
|
484
|
+
|
|
485
|
+
if (NativeAiCore?.generateResponseStateless) {
|
|
486
|
+
return NativeAiCore.generateResponseStateless(prompt);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return NativeAiCore.generateResponse(prompt);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function generateStatelessWithTimeout(
|
|
493
|
+
prompt: string,
|
|
494
|
+
timeoutMs: number,
|
|
495
|
+
signal?: AbortSignal
|
|
496
|
+
): Promise<string> {
|
|
497
|
+
if (timeoutMs <= 0) {
|
|
498
|
+
return generateStateless(prompt, signal);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
502
|
+
const timeout = new Promise<string>((_, reject) => {
|
|
503
|
+
timer = setTimeout(() => {
|
|
504
|
+
reject(new Error(`Structured generation timed out after ${timeoutMs}ms`));
|
|
505
|
+
}, timeoutMs);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
try {
|
|
509
|
+
return await Promise.race([generateStateless(prompt, signal), timeout]);
|
|
510
|
+
} finally {
|
|
511
|
+
if (timer) clearTimeout(timer);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function generateStatelessWithQuotaRetry(
|
|
516
|
+
prompt: string,
|
|
517
|
+
timeoutMs: number,
|
|
518
|
+
signal?: AbortSignal
|
|
519
|
+
): Promise<string> {
|
|
520
|
+
let quotaRetries = 0;
|
|
521
|
+
while (true) {
|
|
522
|
+
try {
|
|
523
|
+
return await generateStatelessWithTimeout(prompt, timeoutMs, signal);
|
|
524
|
+
} catch (error) {
|
|
525
|
+
if ((error as { name?: string }).name === 'AbortError') throw error;
|
|
526
|
+
if (!isQuotaError(error) || quotaRetries >= MAX_QUOTA_RETRIES) {
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
quotaRetries += 1;
|
|
530
|
+
await sleep(QUOTA_RETRY_DELAY_MS, signal);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function tryGenerateWithQuotaTolerance(
|
|
536
|
+
prompt: string,
|
|
537
|
+
timeoutMs: number,
|
|
538
|
+
signal?: AbortSignal
|
|
539
|
+
): Promise<string | null> {
|
|
540
|
+
try {
|
|
541
|
+
return await generateStatelessWithQuotaRetry(prompt, timeoutMs, signal);
|
|
542
|
+
} catch (error) {
|
|
543
|
+
if ((error as { name?: string }).name === 'AbortError') throw error;
|
|
544
|
+
if (isQuotaError(error)) {
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
throw error;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ── Chunked strategy — tree-walker ───────────────────────────────────────────
|
|
552
|
+
//
|
|
553
|
+
// Walks the schema recursively choosing the optimal call granularity:
|
|
554
|
+
//
|
|
555
|
+
// • Leaf (string/number/bool/enum) → 1 call with coercion
|
|
556
|
+
// • Object/array whose schema fits in MAX_COMPACT_SCHEMA_CHARS → 1 walkCompact
|
|
557
|
+
// call (with continuation if truncated)
|
|
558
|
+
// • Array of compact elements → askCount + 1 walkCompact per element
|
|
559
|
+
// • Complex object → per-field walkLeaf/walkCompact
|
|
560
|
+
//
|
|
561
|
+
// This minimises the total number of calls without exceeding the token limit.
|
|
562
|
+
|
|
563
|
+
const INTER_CALL_DELAY_MS = 100;
|
|
564
|
+
const DEFAULT_ARRAY_COUNT = 5;
|
|
565
|
+
const MAX_ARRAY_COUNT = 7;
|
|
566
|
+
const MAX_WHOLE_ARRAY_COMPACT_ITEMS = 2;
|
|
567
|
+
const LEAF_TIMEOUT_CAP_MS = 15000;
|
|
568
|
+
const COMPACT_TIMEOUT_CAP_MS = 30000;
|
|
569
|
+
const ARRAY_COUNT_TIMEOUT_CAP_MS = 8000;
|
|
570
|
+
const LEAF_MAX_RETRIES_CAP = 1;
|
|
571
|
+
// Schema description length threshold below which a single compact call is used.
|
|
572
|
+
const MAX_COMPACT_SCHEMA_CHARS = 500;
|
|
573
|
+
|
|
574
|
+
interface WalkContext {
|
|
575
|
+
taskPrompt: string;
|
|
576
|
+
input: unknown;
|
|
577
|
+
timeoutMs: number;
|
|
578
|
+
maxRetries: number;
|
|
579
|
+
/** Current JSON path for model context, e.g. "days.0.exercises" */
|
|
580
|
+
path: string[];
|
|
581
|
+
/** Extra context hint when iterating an array element */
|
|
582
|
+
itemHint?: string;
|
|
583
|
+
/** Label for onProgress — overrides the computed path when set */
|
|
584
|
+
progressLabel?: string;
|
|
585
|
+
onProgress?: (field: string, done: boolean) => void;
|
|
586
|
+
signal?: AbortSignal;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
interface ArrayCountBounds {
|
|
590
|
+
min: number;
|
|
591
|
+
max: number;
|
|
592
|
+
preferred: number;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function readZodLengthValue(value: unknown): number | null {
|
|
596
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
597
|
+
if (typeof value === 'object' && value !== null) {
|
|
598
|
+
const maybe = (value as { value?: unknown }).value;
|
|
599
|
+
if (typeof maybe === 'number' && Number.isFinite(maybe)) return maybe;
|
|
600
|
+
}
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function getArrayBounds(
|
|
605
|
+
arraySchema: z.ZodArray<ZodTypeAny>,
|
|
606
|
+
fieldName: string
|
|
607
|
+
): ArrayCountBounds {
|
|
608
|
+
const def = (arraySchema as unknown as { _def?: Record<string, unknown> })
|
|
609
|
+
._def;
|
|
610
|
+
const exact = readZodLengthValue(def?.exactLength);
|
|
611
|
+
const minLen = readZodLengthValue(def?.minLength);
|
|
612
|
+
const maxLen = readZodLengthValue(def?.maxLength);
|
|
613
|
+
|
|
614
|
+
if (exact !== null) {
|
|
615
|
+
const fixed = Math.max(0, Math.round(exact));
|
|
616
|
+
return { min: fixed, max: fixed, preferred: fixed };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
let min = minLen !== null ? Math.max(0, Math.round(minLen)) : 1;
|
|
620
|
+
let max =
|
|
621
|
+
maxLen !== null ? Math.max(min, Math.round(maxLen)) : MAX_ARRAY_COUNT;
|
|
622
|
+
|
|
623
|
+
// Domain heuristics when schema does not constrain lengths explicitly.
|
|
624
|
+
const name = fieldName.toLowerCase();
|
|
625
|
+
if (name === 'days') {
|
|
626
|
+
min = Math.max(min, 7);
|
|
627
|
+
max = Math.min(max, 7);
|
|
628
|
+
}
|
|
629
|
+
if (name === 'exercises') {
|
|
630
|
+
min = Math.max(min, 3);
|
|
631
|
+
max = Math.min(max, 4);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const preferred = Math.max(min, Math.min(max, DEFAULT_ARRAY_COUNT));
|
|
635
|
+
return { min, max, preferred };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function unwrapModifiers(schema: ZodTypeAny): ZodTypeAny {
|
|
639
|
+
if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) {
|
|
640
|
+
return unwrapModifiers(schema.unwrap() as ZodTypeAny);
|
|
641
|
+
}
|
|
642
|
+
return schema;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function isLeafSchema(schema: ZodTypeAny): boolean {
|
|
646
|
+
const inner = unwrapModifiers(schema);
|
|
647
|
+
return (
|
|
648
|
+
inner instanceof z.ZodString ||
|
|
649
|
+
inner instanceof z.ZodNumber ||
|
|
650
|
+
inner instanceof z.ZodBoolean ||
|
|
651
|
+
inner instanceof z.ZodEnum ||
|
|
652
|
+
inner instanceof z.ZodLiteral ||
|
|
653
|
+
inner instanceof z.ZodNull
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// A schema is "compact" if its description fits within MAX_COMPACT_SCHEMA_CHARS.
|
|
658
|
+
// Arrays are always excluded because their output can multiply by N.
|
|
659
|
+
function isCompactSchema(schema: ZodTypeAny): boolean {
|
|
660
|
+
const inner = unwrapModifiers(schema);
|
|
661
|
+
if (isLeafSchema(inner)) return true;
|
|
662
|
+
if (inner instanceof z.ZodArray) return false;
|
|
663
|
+
if (hasNestedArray(inner)) return false;
|
|
664
|
+
return zodTypeToDescription(inner).length <= MAX_COMPACT_SCHEMA_CHARS;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Returns true if each array element can be generated in a single compact call.
|
|
668
|
+
function isCompactElement(schema: ZodTypeAny): boolean {
|
|
669
|
+
const inner = unwrapModifiers(schema);
|
|
670
|
+
if (isLeafSchema(inner)) return true;
|
|
671
|
+
if (hasNestedArray(inner)) return false;
|
|
672
|
+
return zodTypeToDescription(inner).length <= MAX_COMPACT_SCHEMA_CHARS;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Returns true if the schema contains arrays at any depth.
|
|
676
|
+
// Such schemas are never compacted to avoid exceeding the 256-token output limit.
|
|
677
|
+
function hasNestedArray(schema: ZodTypeAny): boolean {
|
|
678
|
+
const inner = unwrapModifiers(schema);
|
|
679
|
+
|
|
680
|
+
if (inner instanceof z.ZodArray) return true;
|
|
681
|
+
|
|
682
|
+
if (inner instanceof z.ZodObject) {
|
|
683
|
+
const shape = (inner as z.ZodObject<z.ZodRawShape>).shape;
|
|
684
|
+
return Object.values(shape).some((value) =>
|
|
685
|
+
hasNestedArray(value as ZodTypeAny)
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (inner instanceof z.ZodUnion) {
|
|
690
|
+
return inner.options.some((option: unknown) =>
|
|
691
|
+
hasNestedArray(option as unknown as ZodTypeAny)
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function isStringSchema(schema: ZodTypeAny): boolean {
|
|
699
|
+
return unwrapModifiers(schema) instanceof z.ZodString;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function isNumberSchema(schema: ZodTypeAny): boolean {
|
|
703
|
+
return unwrapModifiers(schema) instanceof z.ZodNumber;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function isBooleanSchema(schema: ZodTypeAny): boolean {
|
|
707
|
+
return unwrapModifiers(schema) instanceof z.ZodBoolean;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function getEnumOptions(schema: ZodTypeAny): string[] | null {
|
|
711
|
+
const inner = unwrapModifiers(schema);
|
|
712
|
+
if (inner instanceof z.ZodEnum) {
|
|
713
|
+
return [...inner.options];
|
|
714
|
+
}
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function getLiteralValue(schema: ZodTypeAny): unknown | null {
|
|
719
|
+
const inner = unwrapModifiers(schema);
|
|
720
|
+
if (inner instanceof z.ZodLiteral) {
|
|
721
|
+
return inner.value;
|
|
722
|
+
}
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function synthesizeLeafFallback(schema: ZodTypeAny, path: string[]): unknown {
|
|
727
|
+
const inner = unwrapModifiers(schema);
|
|
728
|
+
const field = (path[path.length - 1] ?? '').toLowerCase();
|
|
729
|
+
|
|
730
|
+
if (inner instanceof z.ZodLiteral) {
|
|
731
|
+
return inner.value;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (inner instanceof z.ZodEnum) {
|
|
735
|
+
// For paths like days.0.day, map index -> weekday to improve coherence.
|
|
736
|
+
if (field === 'day' && path.length >= 2) {
|
|
737
|
+
const maybeIndex = Number.parseInt(path[path.length - 2] ?? '', 10);
|
|
738
|
+
if (Number.isFinite(maybeIndex)) {
|
|
739
|
+
const idx = Math.max(0, Math.min(inner.options.length - 1, maybeIndex));
|
|
740
|
+
return inner.options[idx];
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return inner.options[0] ?? null;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (inner instanceof z.ZodString) {
|
|
747
|
+
if (field === 'goal') return 'Strength + fat loss';
|
|
748
|
+
if (field === 'focus') return 'Strength + conditioning';
|
|
749
|
+
if (field === 'notes') return 'Progressively increase load week to week.';
|
|
750
|
+
if (field === 'reps') return '8-12';
|
|
751
|
+
if (field === 'name') return 'Accessory Lift';
|
|
752
|
+
return 'TBD';
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (inner instanceof z.ZodNumber) {
|
|
756
|
+
if (field === 'durationweeks') return 1;
|
|
757
|
+
if (field === 'sets') return 3;
|
|
758
|
+
if (field === 'restseconds') return 90;
|
|
759
|
+
return 0;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (inner instanceof z.ZodBoolean) {
|
|
763
|
+
return false;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (inner instanceof z.ZodNull) {
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function coercePrimitiveField(raw: string, schema: ZodTypeAny): unknown {
|
|
774
|
+
const trimmed = raw
|
|
775
|
+
.trim()
|
|
776
|
+
.replace(/^```[\w]*\n?|```$/g, '')
|
|
777
|
+
.trim();
|
|
778
|
+
|
|
779
|
+
try {
|
|
780
|
+
return JSON.parse(trimmed);
|
|
781
|
+
} catch {
|
|
782
|
+
/* sigue */
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (isStringSchema(schema)) return trimmed;
|
|
786
|
+
|
|
787
|
+
if (isNumberSchema(schema)) {
|
|
788
|
+
const n = Number(trimmed.replace(/[^\d.-]/g, ''));
|
|
789
|
+
if (!Number.isNaN(n)) return n;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (isBooleanSchema(schema)) {
|
|
793
|
+
if (/^true$/i.test(trimmed)) return true;
|
|
794
|
+
if (/^false$/i.test(trimmed)) return false;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const enumOptions = getEnumOptions(schema);
|
|
798
|
+
if (enumOptions) {
|
|
799
|
+
const normalized = trimmed.replace(/^"|"$/g, '').trim();
|
|
800
|
+
const direct = enumOptions.find((opt) => opt === normalized);
|
|
801
|
+
if (direct) return direct;
|
|
802
|
+
const ci = enumOptions.find(
|
|
803
|
+
(opt) => opt.toLowerCase() === normalized.toLowerCase()
|
|
804
|
+
);
|
|
805
|
+
if (ci) return ci;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const literalValue = getLiteralValue(schema);
|
|
809
|
+
if (literalValue !== null) {
|
|
810
|
+
const normalized = trimmed.replace(/^"|"$/g, '').trim();
|
|
811
|
+
if (String(literalValue) === normalized) return literalValue;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async function walkLeaf(
|
|
818
|
+
schema: ZodTypeAny,
|
|
819
|
+
ctx: WalkContext
|
|
820
|
+
): Promise<unknown> {
|
|
821
|
+
const fieldPath = ctx.progressLabel ?? (ctx.path.join('.') || 'root');
|
|
822
|
+
const fieldName = ctx.path[ctx.path.length - 1] ?? 'value';
|
|
823
|
+
ctx.onProgress?.(fieldPath, false);
|
|
824
|
+
const fieldType = truncateEnd(zodTypeToDescription(schema), 200);
|
|
825
|
+
const itemCtx = ctx.itemHint ? `Context: ${ctx.itemHint}.` : '';
|
|
826
|
+
const isStr = isStringSchema(schema);
|
|
827
|
+
const enumOptions = getEnumOptions(schema);
|
|
828
|
+
const literalValue = getLiteralValue(schema);
|
|
829
|
+
|
|
830
|
+
const valueInstruction = enumOptions
|
|
831
|
+
? `one of: ${enumOptions.join(' | ')}. Reply exact value only, no key.`
|
|
832
|
+
: literalValue !== null
|
|
833
|
+
? `exact literal value: ${JSON.stringify(literalValue)}.`
|
|
834
|
+
: isStr
|
|
835
|
+
? 'a plain phrase — no quotes, no JSON'
|
|
836
|
+
: 'raw JSON value only — no key, no markdown';
|
|
837
|
+
|
|
838
|
+
const prompt = fitStructuredPrompt([
|
|
839
|
+
`"${fieldName}" (${fieldType}): output ${valueInstruction}.`,
|
|
840
|
+
`Task: ${truncateEnd(compactWhitespace(ctx.taskPrompt), 250)}`,
|
|
841
|
+
ctx.input !== undefined ? `Input: ${stringifyInput(ctx.input, 150)}` : '',
|
|
842
|
+
itemCtx,
|
|
843
|
+
]);
|
|
844
|
+
|
|
845
|
+
const leafTimeoutMs = Math.min(ctx.timeoutMs, LEAF_TIMEOUT_CAP_MS);
|
|
846
|
+
const leafMaxRetries = Math.min(ctx.maxRetries, LEAF_MAX_RETRIES_CAP);
|
|
847
|
+
let lastRaw = '';
|
|
848
|
+
let lastErrorMessage = '';
|
|
849
|
+
for (let attempt = 0; attempt <= leafMaxRetries; attempt++) {
|
|
850
|
+
if (attempt > 0) await sleep(INTER_CALL_DELAY_MS, ctx.signal);
|
|
851
|
+
try {
|
|
852
|
+
lastRaw = await generateStatelessWithQuotaRetry(
|
|
853
|
+
prompt,
|
|
854
|
+
leafTimeoutMs,
|
|
855
|
+
ctx.signal
|
|
856
|
+
);
|
|
857
|
+
const coerced = coercePrimitiveField(lastRaw, schema);
|
|
858
|
+
const validated = schema.safeParse(coerced);
|
|
859
|
+
if (validated.success) {
|
|
860
|
+
ctx.onProgress?.(fieldPath, true);
|
|
861
|
+
return validated.data;
|
|
862
|
+
}
|
|
863
|
+
} catch (error) {
|
|
864
|
+
lastErrorMessage = toErrorMessage(error);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const fallbackFromRaw = isStr ? lastRaw.trim() : null;
|
|
869
|
+
const fallbackSynth = synthesizeLeafFallback(schema, ctx.path);
|
|
870
|
+
const fallback = fallbackFromRaw ?? fallbackSynth;
|
|
871
|
+
const fallbackValidated = schema.safeParse(fallback);
|
|
872
|
+
if (fallbackValidated.success) {
|
|
873
|
+
ctx.onProgress?.(fieldPath, true);
|
|
874
|
+
return fallbackValidated.data;
|
|
875
|
+
}
|
|
876
|
+
throw new StructuredOutputError(
|
|
877
|
+
`Could not generate leaf "${ctx.path.join('.')}"`,
|
|
878
|
+
lastRaw || lastErrorMessage,
|
|
879
|
+
[
|
|
880
|
+
{
|
|
881
|
+
path: ctx.path.join('.'),
|
|
882
|
+
message: (
|
|
883
|
+
lastRaw ||
|
|
884
|
+
lastErrorMessage ||
|
|
885
|
+
'leaf generation failed'
|
|
886
|
+
).slice(0, 120),
|
|
887
|
+
},
|
|
888
|
+
]
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function walkCompact(
|
|
893
|
+
schema: ZodTypeAny,
|
|
894
|
+
ctx: WalkContext
|
|
895
|
+
): Promise<unknown> {
|
|
896
|
+
const fieldPath = ctx.progressLabel ?? (ctx.path.join('.') || 'root');
|
|
897
|
+
const fieldName = ctx.path[ctx.path.length - 1] ?? 'value';
|
|
898
|
+
ctx.onProgress?.(fieldPath, false);
|
|
899
|
+
const typeDesc = truncateEnd(zodTypeToDescription(schema), 400);
|
|
900
|
+
const itemCtx = ctx.itemHint ? `Context: ${ctx.itemHint}.` : '';
|
|
901
|
+
|
|
902
|
+
const prompt = fitStructuredPrompt([
|
|
903
|
+
`"${fieldName}" (${typeDesc}): return JSON value only — no key, no markdown.`,
|
|
904
|
+
`Task: ${truncateEnd(compactWhitespace(ctx.taskPrompt), 250)}`,
|
|
905
|
+
ctx.input !== undefined ? `Input: ${stringifyInput(ctx.input, 150)}` : '',
|
|
906
|
+
itemCtx,
|
|
907
|
+
]);
|
|
908
|
+
|
|
909
|
+
const compactTimeoutMs = Math.min(ctx.timeoutMs, COMPACT_TIMEOUT_CAP_MS);
|
|
910
|
+
let combined = await generateStatelessWithQuotaRetry(
|
|
911
|
+
prompt,
|
|
912
|
+
compactTimeoutMs,
|
|
913
|
+
ctx.signal
|
|
914
|
+
);
|
|
915
|
+
|
|
916
|
+
for (let cont = 0; cont < 6; cont++) {
|
|
917
|
+
try {
|
|
918
|
+
const result = JSON.parse(extractJsonPayload(combined));
|
|
919
|
+
ctx.onProgress?.(fieldPath, true);
|
|
920
|
+
return result;
|
|
921
|
+
} catch {
|
|
922
|
+
if (!looksLikeIncompleteJson(combined)) break;
|
|
923
|
+
}
|
|
924
|
+
await sleep(INTER_CALL_DELAY_MS, ctx.signal);
|
|
925
|
+
const extra = await tryGenerateWithQuotaTolerance(
|
|
926
|
+
buildStructuredContinuationPrompt(
|
|
927
|
+
ctx.taskPrompt,
|
|
928
|
+
schema,
|
|
929
|
+
combined,
|
|
930
|
+
ctx.input
|
|
931
|
+
),
|
|
932
|
+
compactTimeoutMs,
|
|
933
|
+
ctx.signal
|
|
934
|
+
);
|
|
935
|
+
if (!extra) break;
|
|
936
|
+
const merged = mergeStructuredFragments(combined, extra);
|
|
937
|
+
if (merged === combined) break;
|
|
938
|
+
combined = merged;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const finalResult = JSON.parse(extractJsonPayload(combined));
|
|
942
|
+
ctx.onProgress?.(fieldPath, true);
|
|
943
|
+
return finalResult;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async function askArrayCount(
|
|
947
|
+
arraySchema: z.ZodArray<ZodTypeAny>,
|
|
948
|
+
fieldName: string,
|
|
949
|
+
ctx: WalkContext
|
|
950
|
+
): Promise<number> {
|
|
951
|
+
const bounds = getArrayBounds(arraySchema, fieldName);
|
|
952
|
+
const prompt = fitContinuationPrompt([
|
|
953
|
+
`"${fieldName}" array count (${bounds.min}-${bounds.max}). Reply: single integer only.`,
|
|
954
|
+
`Task: ${truncateEnd(compactWhitespace(ctx.taskPrompt), 150)}`,
|
|
955
|
+
]);
|
|
956
|
+
|
|
957
|
+
try {
|
|
958
|
+
await sleep(INTER_CALL_DELAY_MS, ctx.signal);
|
|
959
|
+
const countTimeoutMs = Math.min(ctx.timeoutMs, ARRAY_COUNT_TIMEOUT_CAP_MS);
|
|
960
|
+
const raw = await generateStatelessWithQuotaRetry(
|
|
961
|
+
prompt,
|
|
962
|
+
countTimeoutMs,
|
|
963
|
+
ctx.signal
|
|
964
|
+
);
|
|
965
|
+
const n = parseInt(raw.trim().replace(/\D/g, ''), 10);
|
|
966
|
+
if (Number.isFinite(n) && n >= bounds.min && n <= bounds.max) return n;
|
|
967
|
+
} catch {
|
|
968
|
+
/* usa defecto */
|
|
969
|
+
}
|
|
970
|
+
return bounds.preferred;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async function walkSchema(
|
|
974
|
+
schema: ZodTypeAny,
|
|
975
|
+
ctx: WalkContext
|
|
976
|
+
): Promise<unknown> {
|
|
977
|
+
const inner = unwrapModifiers(schema);
|
|
978
|
+
|
|
979
|
+
if (isLeafSchema(inner)) {
|
|
980
|
+
await sleep(INTER_CALL_DELAY_MS, ctx.signal);
|
|
981
|
+
return walkLeaf(inner, ctx);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
if (inner instanceof z.ZodArray) {
|
|
985
|
+
const elementSchema = unwrapModifiers(inner.element as ZodTypeAny);
|
|
986
|
+
const fieldName = ctx.path[ctx.path.length - 1] ?? 'items';
|
|
987
|
+
const arrayLabel = ctx.path.join('.') || fieldName;
|
|
988
|
+
const count = await askArrayCount(
|
|
989
|
+
inner as z.ZodArray<ZodTypeAny>,
|
|
990
|
+
fieldName,
|
|
991
|
+
ctx
|
|
992
|
+
);
|
|
993
|
+
|
|
994
|
+
// Fast path: if each element fits in a compact call and the array is small,
|
|
995
|
+
// generate the entire array in one walkCompact call instead of per-element.
|
|
996
|
+
if (
|
|
997
|
+
isCompactElement(elementSchema) &&
|
|
998
|
+
count <= MAX_WHOLE_ARRAY_COMPACT_ITEMS
|
|
999
|
+
) {
|
|
1000
|
+
ctx.onProgress?.(arrayLabel, false);
|
|
1001
|
+
await sleep(INTER_CALL_DELAY_MS, ctx.signal);
|
|
1002
|
+
const result = await walkCompact(inner, {
|
|
1003
|
+
...ctx,
|
|
1004
|
+
progressLabel: arrayLabel,
|
|
1005
|
+
});
|
|
1006
|
+
ctx.onProgress?.(arrayLabel, true);
|
|
1007
|
+
return result;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Slow path: non-compact elements → generate one by one
|
|
1011
|
+
ctx.onProgress?.(arrayLabel, false);
|
|
1012
|
+
const items: unknown[] = [];
|
|
1013
|
+
|
|
1014
|
+
for (let i = 0; i < count; i++) {
|
|
1015
|
+
await sleep(INTER_CALL_DELAY_MS, ctx.signal);
|
|
1016
|
+
const elemLabel = `${arrayLabel}[${i + 1}/${count}]`;
|
|
1017
|
+
const elemCtx: WalkContext = {
|
|
1018
|
+
...ctx,
|
|
1019
|
+
path: [...ctx.path, String(i)],
|
|
1020
|
+
itemHint: `item ${i + 1} of ${count}`,
|
|
1021
|
+
progressLabel: elemLabel,
|
|
1022
|
+
};
|
|
1023
|
+
items.push(await walkSchema(elementSchema, elemCtx));
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
ctx.onProgress?.(arrayLabel, true);
|
|
1027
|
+
return items;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Compact object: single call (except at root where per-field gives better progress).
|
|
1031
|
+
if (isCompactSchema(inner) && ctx.path.length > 0) {
|
|
1032
|
+
await sleep(INTER_CALL_DELAY_MS, ctx.signal);
|
|
1033
|
+
return walkCompact(inner, ctx);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (inner instanceof z.ZodObject) {
|
|
1037
|
+
const shape = (inner as z.ZodObject<z.ZodRawShape>).shape;
|
|
1038
|
+
const entries = Object.entries(shape);
|
|
1039
|
+
const result: Record<string, unknown> = {};
|
|
1040
|
+
|
|
1041
|
+
const compactEntries = entries.filter(([, s]) =>
|
|
1042
|
+
isCompactSchema(s as ZodTypeAny)
|
|
1043
|
+
);
|
|
1044
|
+
const complexEntries = entries.filter(
|
|
1045
|
+
([, s]) => !isCompactSchema(s as ZodTypeAny)
|
|
1046
|
+
);
|
|
1047
|
+
|
|
1048
|
+
if (compactEntries.length > 0) {
|
|
1049
|
+
for (const [key, fieldSchema] of compactEntries) {
|
|
1050
|
+
const fieldCtx: WalkContext = {
|
|
1051
|
+
...ctx,
|
|
1052
|
+
path: [...ctx.path, key],
|
|
1053
|
+
progressLabel: [...ctx.path, key].join('.'),
|
|
1054
|
+
};
|
|
1055
|
+
if (isLeafSchema(unwrapModifiers(fieldSchema as ZodTypeAny))) {
|
|
1056
|
+
await sleep(INTER_CALL_DELAY_MS, ctx.signal);
|
|
1057
|
+
result[key] = await walkLeaf(fieldSchema as ZodTypeAny, fieldCtx);
|
|
1058
|
+
} else {
|
|
1059
|
+
await sleep(INTER_CALL_DELAY_MS, ctx.signal);
|
|
1060
|
+
result[key] = await walkCompact(fieldSchema as ZodTypeAny, fieldCtx);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
for (const [key, fieldSchema] of complexEntries) {
|
|
1066
|
+
result[key] = await walkSchema(fieldSchema as ZodTypeAny, {
|
|
1067
|
+
...ctx,
|
|
1068
|
+
path: [...ctx.path, key],
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
return result;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
await sleep(INTER_CALL_DELAY_MS, ctx.signal);
|
|
1076
|
+
return walkLeaf(inner, ctx);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
async function generateChunked<TOutputSchema extends ZodTypeAny, TInput>(
|
|
1080
|
+
prompt: string,
|
|
1081
|
+
schema: TOutputSchema,
|
|
1082
|
+
input: TInput | undefined,
|
|
1083
|
+
timeoutMs: number,
|
|
1084
|
+
maxRetries: number,
|
|
1085
|
+
onProgress?: (field: string, done: boolean) => void,
|
|
1086
|
+
signal?: AbortSignal
|
|
1087
|
+
): Promise<unknown> {
|
|
1088
|
+
return walkSchema(schema, {
|
|
1089
|
+
taskPrompt: prompt,
|
|
1090
|
+
input,
|
|
1091
|
+
timeoutMs,
|
|
1092
|
+
maxRetries,
|
|
1093
|
+
path: [],
|
|
1094
|
+
onProgress,
|
|
1095
|
+
signal,
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
export async function generateStructuredResponse<
|
|
1100
|
+
TOutputSchema extends ZodTypeAny,
|
|
1101
|
+
TInput = unknown,
|
|
1102
|
+
>(
|
|
1103
|
+
options: StructuredGenerateOptions<TOutputSchema, TInput>
|
|
1104
|
+
): Promise<z.infer<TOutputSchema>> {
|
|
1105
|
+
const {
|
|
1106
|
+
prompt,
|
|
1107
|
+
output,
|
|
1108
|
+
input,
|
|
1109
|
+
inputSchema,
|
|
1110
|
+
maxRetries = 2,
|
|
1111
|
+
maxContinuations = DEFAULT_MAX_STRUCTURED_CONTINUATIONS,
|
|
1112
|
+
timeoutMs = DEFAULT_STRUCTURED_TIMEOUT_MS,
|
|
1113
|
+
strategy = 'single',
|
|
1114
|
+
onProgress,
|
|
1115
|
+
signal,
|
|
1116
|
+
} = options;
|
|
1117
|
+
|
|
1118
|
+
if (inputSchema && input !== undefined) {
|
|
1119
|
+
inputSchema.parse(input);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if (strategy === 'chunked') {
|
|
1123
|
+
const assembled = await generateChunked(
|
|
1124
|
+
prompt,
|
|
1125
|
+
output,
|
|
1126
|
+
input,
|
|
1127
|
+
timeoutMs,
|
|
1128
|
+
maxRetries,
|
|
1129
|
+
onProgress,
|
|
1130
|
+
signal
|
|
1131
|
+
);
|
|
1132
|
+
return output.parse(assembled);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
let lastRawResponse = '';
|
|
1136
|
+
let lastIssues: StructuredValidationIssue[] = [];
|
|
1137
|
+
|
|
1138
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1139
|
+
if (signal?.aborted) throw makeAbortError();
|
|
1140
|
+
lastRawResponse =
|
|
1141
|
+
attempt === 0
|
|
1142
|
+
? await generateStructuredRawResponse(
|
|
1143
|
+
prompt,
|
|
1144
|
+
output,
|
|
1145
|
+
input,
|
|
1146
|
+
maxContinuations,
|
|
1147
|
+
timeoutMs
|
|
1148
|
+
)
|
|
1149
|
+
: await generateStatelessWithQuotaRetry(
|
|
1150
|
+
buildRepairPrompt(prompt, output, lastRawResponse, lastIssues),
|
|
1151
|
+
timeoutMs,
|
|
1152
|
+
signal
|
|
1153
|
+
);
|
|
1154
|
+
|
|
1155
|
+
try {
|
|
1156
|
+
const parsed = JSON.parse(extractJsonPayload(lastRawResponse));
|
|
1157
|
+
return output.parse(parsed);
|
|
1158
|
+
} catch (error) {
|
|
1159
|
+
lastIssues = buildJsonParseIssues(error);
|
|
1160
|
+
|
|
1161
|
+
if (attempt === maxRetries) break;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
throw new StructuredOutputError(
|
|
1166
|
+
'Unable to produce valid structured output after retries.',
|
|
1167
|
+
lastRawResponse,
|
|
1168
|
+
lastIssues
|
|
1169
|
+
);
|
|
1170
|
+
}
|