MAJOR UPPDATE: "First Ai"

feat: add AI categorization for products and enhance user management

- Integrated AI service for category suggestions in receipt import and product management.
- Added premium subscription feature for users with corresponding API endpoints.
- Implemented admin interface for managing pending product suggestions.
- Enhanced user management to include premium status and corresponding UI updates.
- Updated database schema to support new fields for premium status and product status.
This commit is contained in:
Nils-Johan Gynther
2026-04-19 10:34:21 +02:00
parent 0286ab0991
commit 054a19ed7c
30 changed files with 917 additions and 77 deletions
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { AiService } from './ai.service';
@Module({
providers: [AiService],
exports: [AiService],
})
export class AiModule {}
+128
View File
@@ -0,0 +1,128 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { FlatCategory } from '../categories/categories.service';
const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions';
const MODEL = 'mistral-small-2603';
export type CategorySuggestion = {
categoryId: number;
categoryName: string;
path: string;
confidence: 'high' | 'medium' | 'low';
usedFallback: boolean;
};
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
async suggestCategory(
productName: string,
categories: FlatCategory[],
): Promise<CategorySuggestion> {
const apiKey = process.env.MISTRAL_API_KEY;
if (!apiKey) {
throw new 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: string;
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.ok) {
const err = await response.text();
this.logger.error(`Mistral API-fel: ${response.status} ${err}`);
throw new ServiceUnavailableException('AI-tjänsten svarade inte korrekt');
}
const data = await response.json() as { choices: { message: { content: string } }[] };
raw = data.choices?.[0]?.message?.content ?? '';
} catch (err) {
if (err instanceof ServiceUnavailableException) throw err;
this.logger.error(`Mistral fetch-fel: ${String(err)}`);
throw new ServiceUnavailableException('Kunde inte nå AI-tjänsten');
}
// Parsa och validera AI-svaret
let parsed: { categoryId: number; confidence: string };
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 as 'high' | 'medium' | 'low')
: 'medium';
return {
categoryId: matchedCategory.id,
categoryName: matchedCategory.name,
path: matchedCategory.path,
confidence,
usedFallback: confidence === 'low',
};
}
private fallbackToOvrigt(categories: FlatCategory[]): CategorySuggestion {
const ovrigt = categories.find((c) => c.path === 'Övrigt');
if (!ovrigt) {
// Sista utväg — returnera första kategorin
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,
};
}
}