chore(infra): add AI flyer parsing configuration and retry logic
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 2m9s
Test Suite / flutter-quality (push) Failing after 1m19s

- Add FLYER_AI_TIMEOUT_MS and FLYER_AI_RETRIES environment variables
- Configure timeout and retry settings in compose.yml
- Update AiFlyerParserService with configurable timeout and retry logic
- Add text window reduction strategy for retry attempts
- Update documentation in TEKNISK_BESKRIVNING.md
- Fix ESLint configuration in app.security.spec.ts
This commit is contained in:
Nils-Johan Gynther
2026-05-19 20:13:59 +02:00
parent 187d0283a5
commit 4d2942a8e5
6 changed files with 99 additions and 37 deletions
@@ -21,7 +21,8 @@ export interface AiFlyerParseResult {
@Injectable()
export class AiFlyerParserService {
private readonly logger = new Logger(AiFlyerParserService.name);
private readonly timeoutMs = 15_000;
private readonly timeoutMs: number;
private readonly maxRetries: number;
private mistral: any;
private apiKey: string;
@@ -30,6 +31,9 @@ export class AiFlyerParserService {
if (!this.apiKey) {
throw new Error('MISTRAL_API_KEY environment variable not set');
}
this.timeoutMs = this.readPositiveIntEnv('FLYER_AI_TIMEOUT_MS', 30_000);
this.maxRetries = this.readPositiveIntEnv('FLYER_AI_RETRIES', 2);
}
private async getClient(): Promise<any> {
@@ -50,38 +54,58 @@ export class AiFlyerParserService {
throw new BadRequestException('Flyer-texten är tom. Kan inte fortsätta.');
}
const prompt = this.buildPrompt(text);
try {
this.logger.debug('Sending request to Mistral Tiny');
const client = await this.getClient();
const response = await this.withTimeout<any>(
client.chat({
model: 'mistral-tiny',
messages: [{ role: 'user', content: prompt }],
temperature: 0.1,
}),
this.timeoutMs,
'Mistral-anrop timeout',
);
const textWindows = [5000, 3000, 2000];
const attempts = Math.max(1, Math.min(this.maxRetries + 1, textWindows.length));
let lastError: unknown = null;
const content = response.choices?.[0]?.message?.content;
if (!content) {
throw new BadRequestException('Tomt svar från AI-modellen.');
for (let i = 0; i < attempts; i++) {
const window = textWindows[i];
const prompt = this.buildPrompt(text, window);
try {
this.logger.debug(
`Sending request to Mistral Tiny (attempt ${i + 1}/${attempts}, timeout=${this.timeoutMs}ms, textWindow=${window})`,
);
const response = await this.withTimeout<any>(
client.chat({
model: 'mistral-tiny',
messages: [{ role: 'user', content: prompt }],
temperature: 0.1,
}),
this.timeoutMs,
'Mistral-anrop timeout',
);
const content = response.choices?.[0]?.message?.content;
if (!content) {
throw new BadRequestException('Tomt svar från AI-modellen.');
}
this.logger.debug(`Mistral response length: ${content.length} chars`);
const jsonString = this.sanitizeJsonResponse(content);
const items = JSON.parse(jsonString) as Array<Record<string, unknown>>;
if (!Array.isArray(items)) {
throw new BadRequestException('AI returnerade inte en JSON-array.');
}
return items.map((item, idx) => this.normalizeAiItem(item, idx));
} catch (attemptErr) {
lastError = attemptErr;
if (!this.isRetryableError(attemptErr) || i === attempts - 1) {
throw attemptErr;
}
this.logger.warn(
`Mistral attempt ${i + 1} failed (${this.toErrorMessage(attemptErr)}). Retrying with shorter text window.`,
);
}
}
this.logger.debug(`Mistral response length: ${content.length} chars`);
// Rensa och parse JSON
const jsonString = this.sanitizeJsonResponse(content);
const items = JSON.parse(jsonString) as Array<Record<string, unknown>>;
if (!Array.isArray(items)) {
throw new BadRequestException('AI returnerade inte en JSON-array.');
}
return items.map((item, idx) => this.normalizeAiItem(item, idx));
throw lastError instanceof Error ? lastError : new ServiceUnavailableException('AI-anrop misslyckades');
} catch (err) {
if (err instanceof SyntaxError) {
this.logger.error(`JSON parse error: ${String(err)}`);
@@ -121,9 +145,8 @@ export class AiFlyerParserService {
/**
* Bygger systemprompten för Mistral.
*/
private buildPrompt(text: string): string {
// Trunkera långt text för att spara tokens
const truncatedText = text.length > 5000 ? text.substring(0, 5000) : text;
private buildPrompt(text: string, maxTextLength: number): string {
const truncatedText = text.length > maxTextLength ? text.substring(0, maxTextLength) : text;
return `Du är en expert på att tolka svenska matvaruflyers (t.ex. från Willys, Coop, ICA).
@@ -231,4 +254,32 @@ Exempel på utdata:
.replace(/\s+/g, ' ')
.trim();
}
private readPositiveIntEnv(key: string, fallback: number): number {
const raw = process.env[key];
if (!raw) return fallback;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
this.logger.warn(`Invalid ${key} value: "${raw}". Falling back to ${fallback}.`);
return fallback;
}
return parsed;
}
private isRetryableError(err: unknown): boolean {
if (err instanceof ServiceUnavailableException) return true;
const message = this.toErrorMessage(err).toLowerCase();
return (
message.includes('timeout') ||
message.includes('timed out') ||
message.includes('rate limit') ||
message.includes('econnreset') ||
message.includes('socket hang up')
);
}
private toErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
}