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:
@@ -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: (°F–32)×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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user