Files
recipe-app/backend/dist/ai/ai.service.js
T
Nils-Johan Gynther 04b1fc3024
Test Suite / test (24.15.0) (push) Has been cancelled
feat: add rematch functionality for recipe ingredients and enhance inventory management
- Added a new API path for rematching recipe ingredients in `api_paths.dart`.
- Implemented a manual product creation dialog in `inventory_screen.dart` to allow users to create new products directly.
- Integrated the rematch functionality in `recipe_repository.dart` to handle rematching of recipe ingredients.
- Updated the recipe detail screen to include a button for triggering the rematch process.
- Introduced a new `RecipeMatchingService` in the backend to handle ingredient matching logic.
- Added database migration to include `aiEngineEnabled` column in the User table.

Co-authored-by: Copilot <copilot@github.com>
2026-05-06 09:20:31 +02:00

240 lines
11 KiB
JavaScript

"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var AiService_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.AiService = exports.AI_CATEGORIZATION_MODEL = void 0;
const common_1 = require("@nestjs/common");
const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions';
exports.AI_CATEGORIZATION_MODEL = 'mistral-tiny';
const MODEL = exports.AI_CATEGORIZATION_MODEL;
let AiService = AiService_1 = class AiService {
constructor() {
this.logger = new common_1.Logger(AiService_1.name);
}
async suggestIngredientMatches(rawIngredient, candidates) {
const apiKey = process.env.MISTRAL_API_KEY;
if (!apiKey || candidates.length === 0)
return [];
const candidateList = candidates
.map((c) => `[${c.id}] ${c.canonicalName || c.name}`)
.join('\n');
const systemPrompt = `Du matchar en ingrediensrad mot produktkandidater.
Svara ENDAST med JSON: {"matches":[{"productId":123,"reason":"...","confidence":"high|medium|low"}]}
Regler:
1. Välj max 3 kandidater.
2. Om inget passar, returnera tom lista.`;
const userPrompt = `Ingrediens: "${rawIngredient}"\nKandidater:\n${candidateList}`;
try {
const response = await fetch(MISTRAL_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
max_tokens: 300,
temperature: 0.1,
response_format: { type: 'json_object' },
}),
});
if (!response.ok) {
this.logger.warn(`suggestIngredientMatches API-fel: ${response.status}`);
return [];
}
const data = (await response.json());
const raw = data.choices?.[0]?.message?.content ?? '{}';
const parsed = JSON.parse(raw);
return Array.isArray(parsed.matches) ? parsed.matches.slice(0, 3) : [];
}
catch (err) {
this.logger.warn(`suggestIngredientMatches misslyckades: ${String(err)}`);
return [];
}
}
async suggestSubstitutions(rawIngredient, availableProducts) {
const apiKey = process.env.MISTRAL_API_KEY;
if (!apiKey || availableProducts.length === 0)
return [];
const productList = availableProducts
.map((p) => `[${p.id}] ${p.canonicalName || p.name}`)
.join('\n');
const systemPrompt = `Du föreslår ersättningsvaror för en ingrediens.
Svara ENDAST med JSON: {"substitutions":[{"productId":123,"reason":"...","confidence":"high|medium|low"}]}
Regler:
1. Välj max 3 ersättningar.
2. Om inget passar, returnera tom lista.`;
const userPrompt = `Ingrediens: "${rawIngredient}"\nTillgängliga produkter:\n${productList}`;
try {
const response = await fetch(MISTRAL_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
max_tokens: 300,
temperature: 0.2,
response_format: { type: 'json_object' },
}),
});
if (!response.ok) {
this.logger.warn(`suggestSubstitutions API-fel: ${response.status}`);
return [];
}
const data = (await response.json());
const raw = data.choices?.[0]?.message?.content ?? '{}';
const parsed = JSON.parse(raw);
return Array.isArray(parsed.substitutions) ? parsed.substitutions.slice(0, 3) : [];
}
catch (err) {
this.logger.warn(`suggestSubstitutions misslyckades: ${String(err)}`);
return [];
}
}
async suggestCategory(productName, categories) {
const apiKey = process.env.MISTRAL_API_KEY;
if (!apiKey) {
throw new common_1.ServiceUnavailableException('MISTRAL_API_KEY är inte konfigurerad i miljövariabler');
}
const categoryList = categories
.map((c) => `[${c.id}] ${c.path}`)
.join('\n');
const systemPrompt = `Du är ett kategoriseringssystem för en livsmedelsapp. Din uppgift är att hitta den mest lämpliga kategorin för en produkt.
Tillgängliga kategorier (format: [id] Sökväg):
${categoryList}
Regler:
1. Välj den mest specifika underkategorin som passar produkten.
2. Om ingen specifik kategori passar, välj en underkategori under "Övrigt" om möjligt.
3. Om ingen underkategori under "Övrigt" passar, välj "Övrigt" (den kategori vars sökväg är exakt "Övrigt").
4. Du MÅSTE alltid returnera ett svar — aldrig null eller tomt.
5. Svara ENDAST med giltig JSON i detta format: { "categoryId": <nummer>, "confidence": "high" | "medium" | "low" }
- "high": uppenbart matchande kategori
- "medium": trolig matchning
- "low": osäker, används fallback (Övrigt eller underkategori till Övrigt)`;
const userPrompt = `Produkt: "${productName}"`;
let raw = '';
const MAX_RETRIES = 3;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await fetch(MISTRAL_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
max_tokens: 100,
temperature: 0.1,
response_format: { type: 'json_object' },
}),
});
if (response.status === 503 || response.status === 429) {
const err = await response.text();
this.logger.warn(`Mistral API ${response.status} (försök ${attempt}/${MAX_RETRIES}): ${err}`);
if (attempt < MAX_RETRIES) {
await new Promise((r) => setTimeout(r, attempt * 1500));
continue;
}
throw new common_1.ServiceUnavailableException('AI-tjänsten är tillfälligt otillgänglig, försök igen');
}
if (!response.ok) {
const err = await response.text();
this.logger.error(`Mistral API-fel: ${response.status} ${err}`);
throw new common_1.ServiceUnavailableException('AI-tjänsten svarade inte korrekt');
}
const data = await response.json();
raw = data.choices?.[0]?.message?.content ?? '';
break;
}
catch (err) {
if (err instanceof common_1.ServiceUnavailableException)
throw err;
this.logger.error(`Mistral fetch-fel (försök ${attempt}/${MAX_RETRIES}): ${String(err)}`);
if (attempt < MAX_RETRIES) {
await new Promise((r) => setTimeout(r, attempt * 1500));
continue;
}
throw new common_1.ServiceUnavailableException('Kunde inte nå AI-tjänsten');
}
}
let parsed;
try {
parsed = JSON.parse(raw);
}
catch {
this.logger.warn(`AI returnerade ogiltig JSON: ${raw}`);
return this.fallbackToOvrigt(categories);
}
const validId = typeof parsed.categoryId === 'number';
const matchedCategory = validId ? categories.find((c) => c.id === parsed.categoryId) : null;
if (!matchedCategory) {
this.logger.warn(`AI returnerade okänt categoryId ${parsed.categoryId}, använder fallback`);
return this.fallbackToOvrigt(categories);
}
const confidence = ['high', 'medium', 'low'].includes(parsed.confidence)
? parsed.confidence
: 'medium';
if (confidence === 'low') {
const l1Name = matchedCategory.path.split(' > ')[0];
const l1 = categories.find((c) => c.path === l1Name);
if (l1 && l1.id !== matchedCategory.id) {
this.logger.log(`AI-guardrail: ${confidence} konfidenspoäng → remappar "${matchedCategory.path}" → L1 "${l1.path}"`);
return {
categoryId: l1.id,
categoryName: l1.name,
path: l1.path,
confidence,
usedFallback: true,
};
}
}
return {
categoryId: matchedCategory.id,
categoryName: matchedCategory.name,
path: matchedCategory.path,
confidence,
usedFallback: confidence === 'low',
};
}
fallbackToOvrigt(categories) {
const ovrigt = categories.find((c) => c.path === 'Övrigt');
if (!ovrigt) {
const first = categories[0];
return { categoryId: first.id, categoryName: first.name, path: first.path, confidence: 'low', usedFallback: true };
}
return {
categoryId: ovrigt.id,
categoryName: ovrigt.name,
path: ovrigt.path,
confidence: 'low',
usedFallback: true,
};
}
};
exports.AiService = AiService;
exports.AiService = AiService = AiService_1 = __decorate([
(0, common_1.Injectable)()
], AiService);
//# sourceMappingURL=ai.service.js.map