feat: add rematch functionality for recipe ingredients and enhance inventory management
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
- 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>
This commit is contained in:
@@ -13,10 +13,128 @@ export type CategorySuggestion = {
|
||||
usedFallback: boolean;
|
||||
};
|
||||
|
||||
export type AiIngredientMatchSuggestion = {
|
||||
productId: number;
|
||||
reason?: string;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
};
|
||||
|
||||
export type AiSubstitutionSuggestion = {
|
||||
productId: number;
|
||||
reason?: string;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
private readonly logger = new Logger(AiService.name);
|
||||
|
||||
async suggestIngredientMatches(
|
||||
rawIngredient: string,
|
||||
candidates: Array<{ id: number; name: string; canonicalName?: string | null }>,
|
||||
): Promise<AiIngredientMatchSuggestion[]> {
|
||||
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()) as { choices: { message: { content: string } }[] };
|
||||
const raw = data.choices?.[0]?.message?.content ?? '{}';
|
||||
const parsed = JSON.parse(raw) as { matches?: AiIngredientMatchSuggestion[] };
|
||||
return Array.isArray(parsed.matches) ? parsed.matches.slice(0, 3) : [];
|
||||
} catch (err) {
|
||||
this.logger.warn(`suggestIngredientMatches misslyckades: ${String(err)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async suggestSubstitutions(
|
||||
rawIngredient: string,
|
||||
availableProducts: Array<{ id: number; name: string; canonicalName?: string | null }>,
|
||||
): Promise<AiSubstitutionSuggestion[]> {
|
||||
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()) as { choices: { message: { content: string } }[] };
|
||||
const raw = data.choices?.[0]?.message?.content ?? '{}';
|
||||
const parsed = JSON.parse(raw) as { substitutions?: AiSubstitutionSuggestion[] };
|
||||
return Array.isArray(parsed.substitutions) ? parsed.substitutions.slice(0, 3) : [];
|
||||
} catch (err) {
|
||||
this.logger.warn(`suggestSubstitutions misslyckades: ${String(err)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async suggestCategory(
|
||||
productName: string,
|
||||
categories: FlatCategory[],
|
||||
|
||||
Reference in New Issue
Block a user