Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions __tests__/unit/lib/ai-assist-any-form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,65 @@ describe('generateFormPrefill — service floor follows the intent, like the rou
});
});

describe('generateFormPrefill — retries a transient provider failure', () => {
const target = resolveAiAssistTarget('service');
const originalFetch = global.fetch;
const originalGroqKey = process.env.GROQ_API_KEY;

beforeEach(() => {
process.env.GROQ_API_KEY = 'test-key';
});

afterEach(() => {
global.fetch = originalFetch;
if (originalGroqKey === undefined) {
delete process.env.GROQ_API_KEY;
} else {
process.env.GROQ_API_KEY = originalGroqKey;
}
});

it('recovers from a single 503 without surfacing an error to the caller', async () => {
const okBody = {
choices: [
{
message: { content: JSON.stringify({ data: { title: 'Retried fine' }, confidence: {} }) },
},
],
};
const fetchMock = jest
.fn()
.mockResolvedValueOnce({ ok: false, status: 503, text: async () => 'upstream hiccup' })
.mockResolvedValueOnce({ ok: true, json: async () => okBody });
global.fetch = fetchMock as unknown as typeof fetch;

const result = await generateFormPrefill({
target: target!,
description: 'A cleaning service for offices in Zurich',
});

expect(fetchMock).toHaveBeenCalledTimes(2);
expect(result.success).toBe(true);
expect(result.data.title).toBe('Retried fine');
});

it('gives up after repeated failures with the same user-facing message', async () => {
const fetchMock = jest
.fn()
.mockResolvedValue({ ok: false, status: 503, text: async () => 'still down' });
global.fetch = fetchMock as unknown as typeof fetch;

const result = await generateFormPrefill({
target: target!,
description: 'A cleaning service for offices in Zurich',
});

expect(fetchMock).toHaveBeenCalledTimes(3);
expect(result.success).toBe(false);
expect(result.error).toBe('AI service temporarily unavailable. Please try again.');
});
});

describe('getExampleDescriptions — standalone forms get starters too', () => {
it('returns the declared examples for task and proposal', () => {
expect(getExampleDescriptions('task')).toEqual(AI_ASSIST_FORMS.task.examples);
Expand Down
55 changes: 37 additions & 18 deletions src/lib/ai/form-prefill-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type AiAssistIntent,
} from '@/config/ai-form-assist';
import { logger } from '@/utils/logger';
import { withApiRetry } from '@/utils/retry';
import { extractFieldDescriptions, formatFieldsForPrompt } from './schema-to-prompt';
import { getSystemPrompt, getUserPrompt, parseAIResponse } from './prompts/form-prefill';
import { sanitizeAiFields } from './sanitize-ai-fields';
Expand Down Expand Up @@ -184,26 +185,44 @@ export async function generateFormPrefill({
headers['X-Title'] = 'OrangeCat Form Prefill';
}

const response = await fetch(baseUrl, {
method: 'POST',
headers,
body: JSON.stringify({
model: useGroq ? model : model.replace('openrouter/', ''),
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: config?.temperature ?? 0.3,
// Headroom for genuinely written descriptions (and bilingual ones) —
// 1000 truncated the JSON mid-string on multi-field forms.
max_tokens: config?.maxTokens ?? 2000,
...(useGroq ? {} : { response_format: { type: 'json_object' } }),
}),
const requestBody = JSON.stringify({
model: useGroq ? model : model.replace('openrouter/', ''),
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: config?.temperature ?? 0.3,
// Headroom for genuinely written descriptions (and bilingual ones) —
// 1000 truncated the JSON mid-string on multi-field forms.
max_tokens: config?.maxTokens ?? 2000,
...(useGroq ? {} : { response_format: { type: 'json_object' } }),
});

if (!response.ok) {
const errorText = await response.text();
logger.error('AI API error', { errorText }, 'AI');
// The upstream provider (Groq/OpenRouter) occasionally answers with a
// transient 429/5xx that clears on its own — visitors were seeing that
// as a hard failure and had to manually re-click "Fill with AI" to get
// the same request to succeed. Retry it here instead, same as every
// other outbound call in the codebase (see withApiRetry usages).
let response: Response;
try {
response = await withApiRetry(
async () => {
const res = await fetch(baseUrl, { method: 'POST', headers, body: requestBody });
if (!res.ok) {
const errorText = await res.text();
const error = new Error(`AI provider responded ${res.status}`) as Error & {
status: number;
};
error.status = res.status;
logger.warn('AI API error, may retry', { status: res.status, errorText }, 'AI');
throw error;
}
return res;
},
{ maxAttempts: 3, baseDelay: 400, maxDelay: 2000 }
);
} catch (fetchError) {
logger.error('AI API error after retries', fetchError, 'AI');
return {
success: false,
data: {},
Expand Down
Loading