feat: refactor frontend and fix LLM extraction

- Fix critical await bug in extract-stream endpoint
- Add comprehensive logging to LLM and parser modules
- Implement fallback to standard completion for incompatible models
- Create enhanced v2.0 prompts with social media handling and few-shot examples
- Add LLM health check endpoint
- Decompose share page into 6 focused Svelte 5 snippets

Resolves LM Studio integration issues and improves code maintainability
This commit is contained in:
Giancarmine Salucci
2025-12-21 03:49:33 +01:00
parent 377bdbf6d7
commit da58263aba
9 changed files with 2104 additions and 56 deletions

View File

@@ -1,6 +1,7 @@
import { createLLM } from './llm';
import { zodResponseFormat } from 'openai/helpers/zod';
import { z } from 'zod';
import { RECIPE_DETECTION_PROMPT, RECIPE_EXTRACTION_PROMPT } from './prompts/recipe-extraction';
const RecipeSchema = z.object({
name: z.string(),
@@ -28,27 +29,34 @@ export async function detectRecipe(text: string): Promise<boolean> {
try {
const { client, model } = createLLM();
console.log('[LLM] Starting recipe detection...');
console.log('[LLM] Model:', model);
console.log('[LLM] Text length:', text.length);
const detectionResponse = await client.chat.completions.create({
model,
messages: [
{
role: 'system',
content:
"You are a recipe detector. Answer with ONLY 'yes' or 'no' - nothing else. A recipe MUST have: (1) name/title, (2) ingredients with quantities, (3) numbered cooking steps. If ANY are missing, answer 'no'."
content: RECIPE_DETECTION_PROMPT
},
{
role: 'user',
content: `Does this text contain a recipe?\n\n${text}`
}
],
max_tokens: 10
max_tokens: 10,
temperature: 0
});
const detectionResult = detectionResponse.choices[0].message.content?.toLowerCase() ?? '';
console.log('[LLM] Detection response:', detectionResult);
return detectionResult.includes('yes');
} catch (e) {
console.error('Recipe detection error:', e);
throw new Error('Failed to detect recipe');
console.error('[LLM] Recipe detection error:', e);
console.error('[LLM] Stack trace:', (e as Error).stack);
throw new Error(`Failed to detect recipe: ${(e as Error).message}`);
}
}
@@ -61,47 +69,27 @@ export async function parseRecipe(text: string): Promise<Recipe> {
try {
const { client, model } = createLLM();
console.log('[LLM] Starting recipe parsing...');
console.log('[LLM] Model:', model);
const completion = await client.beta.chat.completions.parse({
model,
messages: [
{
role: 'system',
content: `You are a RECIPE EXTRACTOR. Extract the recipe from the provided text.
✅ REQUIREMENTS:
1. Extract the exact recipe name from the text
2. List all ingredients with their quantities and units
3. List all cooking steps in order
4. Translate everything to Italian
5. Convert measurements to SI units (g, mL, °C)
📋 CONVERSION TABLE:
- 1 cup = 240 mL, 1 tbsp = 15 mL, 1 tsp = 5 mL
- 1 oz = 28.35 g, 1 lb = 453.59 g
- 1 stick butter = 113 g
- °F→°C: (°F32)×5/9
🔄 OUTPUT FORMAT:
{
"name": "recipe name in Italian",
"servings": number or null,
"description": "description in Italian or null",
"ingredients": [{"item": "ingredient name", "amount": "quantity", "unit": "SI unit"}],
"steps": ["1. First step", "2. Second step", ...]
}
Extract ONLY what's explicitly in the text. Be accurate and literal.
`
content: RECIPE_EXTRACTION_PROMPT
},
{
role: 'user',
content: `Extract the recipe from this text:\n\n${text}`
}
],
response_format: zodResponseFormat(RecipeSchema, 'recipe')
response_format: zodResponseFormat(RecipeSchema, 'recipe'),
temperature: 0.3
});
const recipe = completion.choices[0].message.parsed;
console.log('[LLM] Parse response:', recipe?.name);
if (!recipe || !recipe.name) {
throw new Error('Failed to extract recipe - missing name');
@@ -109,8 +97,17 @@ Extract ONLY what's explicitly in the text. Be accurate and literal.
return recipe;
} catch (e) {
console.error('Recipe parsing error:', e);
throw new Error('Failed to parse recipe');
console.error('[LLM] Recipe parsing error:', e);
console.error('[LLM] Stack trace:', (e as Error).stack);
// If structured output fails, try standard completion
if ((e as any).message?.includes('response_format') ||
(e as any).message?.includes('structured output')) {
console.warn('[LLM] Falling back to standard completion');
return await parseRecipeWithStandardCompletion(text);
}
throw new Error(`Failed to parse recipe: ${(e as Error).message}`);
}
}
@@ -128,3 +125,56 @@ export async function extractRecipe(text: string): Promise<Recipe | null> {
return parseRecipe(text);
}
/**
* Fallback parser using standard completion (no structured output)
* Used when the model doesn't support beta.chat.completions.parse()
*/
async function parseRecipeWithStandardCompletion(text: string): Promise<Recipe> {
const { client, model } = createLLM();
console.log('[LLM] Using standard completion fallback');
const completion = await client.chat.completions.create({
model,
messages: [
{
role: 'system',
content: `You are a recipe extractor. Return ONLY valid JSON matching this schema:
{
"name": "recipe name in Italian",
"servings": number or null,
"description": "description in Italian or null",
"ingredients": [{"item": "ingredient name", "amount": "quantity", "unit": "SI unit"}],
"steps": ["1. First step", "2. Second step", ...]
}
Convert all measurements to SI units (g, mL, °C).
Translate everything to Italian.
Extract ONLY what's in the text.`
},
{
role: 'user',
content: `Extract the recipe from this text:\n\n${text}`
}
],
max_tokens: 2000,
temperature: 0.3
});
const jsonResponse = completion.choices[0].message.content;
if (!jsonResponse) {
throw new Error('Empty response from LLM');
}
console.log('[LLM] Standard completion raw response:', jsonResponse.substring(0, 200));
// Parse and validate JSON (remove code fences if present)
const cleanedJson = jsonResponse.replace(/```json\n?|```\n?/g, '').trim();
const parsedData = JSON.parse(cleanedJson);
const recipe = RecipeSchema.parse(parsedData);
console.log('[LLM] Standard completion parsed recipe:', recipe.name);
return recipe;
}