Use local heuristic engine first for extraction and offer user-initiated AI Polish action

This commit is contained in:
Chris 2026-11-01 01:22:40 -04:00
parent e283f238cc
commit a098ea3bca
4 changed files with 246 additions and 56 deletions

View file

@ -1,6 +1,7 @@
// ForkFolio client application state // ForkFolio client application state
let allRecipes = []; let allRecipes = [];
let activeRecipe = null; let activeRecipe = null;
let currentDraftRawCaption = '';
let currentMultiplier = 1; let currentMultiplier = 1;
let selectedCoverImage = ''; let selectedCoverImage = '';
let stopwatchInterval = null; let stopwatchInterval = null;
@ -144,6 +145,10 @@ const elements = {
modalActionTitle: document.getElementById('modal-action-title'), modalActionTitle: document.getElementById('modal-action-title'),
btnSaveConfirm: document.getElementById('btn-save-confirm'), btnSaveConfirm: document.getElementById('btn-save-confirm'),
editIsPublic: document.getElementById('edit-is-public'), editIsPublic: document.getElementById('edit-is-public'),
aiPolishBanner: document.getElementById('ai-polish-banner'),
btnAiPolish: document.getElementById('btn-ai-polish'),
aiPolishTitle: document.getElementById('ai-polish-title'),
aiPolishSubtitle: document.getElementById('ai-polish-subtitle'),
// Detail Modal // Detail Modal
detailModal: document.getElementById('detail-modal'), detailModal: document.getElementById('detail-modal'),
@ -265,6 +270,7 @@ function registerEvents() {
// Recipe Form Save // Recipe Form Save
elements.recipeEditForm.addEventListener('submit', handleRecipeSave); elements.recipeEditForm.addEventListener('submit', handleRecipeSave);
elements.btnAiPolish?.addEventListener('click', handleAiPolish);
// Action Buttons Detail Modal // Action Buttons Detail Modal
elements.btnDetailEdit.addEventListener('click', editActiveRecipe); elements.btnDetailEdit.addEventListener('click', editActiveRecipe);
@ -606,6 +612,66 @@ async function handleScrapeSubmit(e) {
} }
} }
// User-initiated AI Polish action for draft recipe
async function handleAiPolish() {
if (!elements.btnAiPolish) return;
const originalBtnHtml = elements.btnAiPolish.innerHTML;
elements.btnAiPolish.disabled = true;
elements.btnAiPolish.innerHTML = '<i data-lucide="loader-2" class="spin"></i> <span>Polishing...</span>';
lucide.createIcons();
try {
const response = await fetch('/api/recipes/ai-polish', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({
rawCaption: currentDraftRawCaption,
title: elements.editTitle.value.trim(),
ingredients: elements.editIngredients.value.trim().split(/\r?\n/).filter(Boolean),
instructions: elements.editInstructions.value.trim().split(/\r?\n/).filter(Boolean)
})
});
if (response.status === 401) {
logout();
return;
}
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Failed to polish recipe with AI.');
}
// Populate polished fields
if (result.title) elements.editTitle.value = result.title;
if (result.description) elements.editDescription.value = result.description;
if (result.servings) elements.editServings.value = result.servings;
if (result.prepTime) elements.editPrepTime.value = result.prepTime;
if (result.cookTime) elements.editCookTime.value = result.cookTime;
if (Array.isArray(result.ingredients) && result.ingredients.length > 0) {
elements.editIngredients.value = result.ingredients.join('\n');
}
if (Array.isArray(result.instructions) && result.instructions.length > 0) {
elements.editInstructions.value = result.instructions.join('\n');
}
// Update banner to success state
elements.aiPolishBanner.className = 'ai-polish-banner success';
elements.aiPolishTitle.textContent = 'Polished with Gemini AI';
elements.aiPolishSubtitle.textContent = 'Measurements and instruction steps have been organized.';
elements.btnAiPolish.style.display = 'none';
lucide.createIcons();
} catch (err) {
elements.btnAiPolish.disabled = false;
elements.btnAiPolish.innerHTML = originalBtnHtml;
lucide.createIcons();
alert(err.message);
}
}
// Save or Edit submit // Save or Edit submit
async function handleRecipeSave(e) { async function handleRecipeSave(e) {
e.preventDefault(); e.preventDefault();
@ -856,6 +922,9 @@ async function revokeActiveRecipeShare() {
// ------------------------------------------------------------- // -------------------------------------------------------------
function openManualAdd() { function openManualAdd() {
currentDraftRawCaption = '';
if (elements.aiPolishBanner) elements.aiPolishBanner.style.display = 'none';
elements.modalActionTitle.textContent = 'Create New Recipe'; elements.modalActionTitle.textContent = 'Create New Recipe';
elements.editRecipeId.value = ''; elements.editRecipeId.value = '';
elements.recipeEditForm.reset(); elements.recipeEditForm.reset();
@ -887,12 +956,33 @@ function openDraftPreview(data) {
// Render Image Selector // Render Image Selector
renderImageSelector(data.images || []); renderImageSelector(data.images || []);
// Handle AI Polish Suggestion Banner
if (data.suggestAiPolish && data.rawCaption) {
currentDraftRawCaption = data.rawCaption;
if (elements.aiPolishBanner) {
elements.aiPolishBanner.style.display = 'flex';
elements.aiPolishBanner.className = 'ai-polish-banner';
elements.aiPolishTitle.textContent = 'Extracted with local engine';
elements.aiPolishSubtitle.textContent = 'Want AI to polish measurements, separate run-on steps, and format times?';
elements.btnAiPolish.style.display = 'inline-flex';
elements.btnAiPolish.disabled = false;
elements.btnAiPolish.innerHTML = '<i data-lucide="wand-2"></i> <span>Polish with AI</span>';
lucide.createIcons();
}
} else {
currentDraftRawCaption = '';
if (elements.aiPolishBanner) elements.aiPolishBanner.style.display = 'none';
}
openModal('save-modal'); openModal('save-modal');
} }
function editActiveRecipe() { function editActiveRecipe() {
if (!activeRecipe) return; if (!activeRecipe) return;
currentDraftRawCaption = '';
if (elements.aiPolishBanner) elements.aiPolishBanner.style.display = 'none';
elements.modalActionTitle.textContent = 'Edit Recipe Details'; elements.modalActionTitle.textContent = 'Edit Recipe Details';
elements.editRecipeId.value = activeRecipe.id; elements.editRecipeId.value = activeRecipe.id;

View file

@ -691,6 +691,20 @@
<p class="form-help text-sm opacity-80" style="margin-top: 4px; font-size: 0.8rem; color: var(--text-muted);">If enabled, this recipe will be shared to the global Shared Cookbook feed for everyone to view and clone.</p> <p class="form-help text-sm opacity-80" style="margin-top: 4px; font-size: 0.8rem; color: var(--text-muted);">If enabled, this recipe will be shared to the global Shared Cookbook feed for everyone to view and clone.</p>
</div> </div>
<!-- AI Polish Suggestion Banner -->
<div class="ai-polish-banner" id="ai-polish-banner" style="display: none;">
<div class="ai-polish-info">
<div class="ai-polish-icon"><i data-lucide="sparkles"></i></div>
<div class="ai-polish-text">
<span class="ai-polish-title" id="ai-polish-title">Extracted with local engine</span>
<span class="ai-polish-subtitle" id="ai-polish-subtitle">Want AI to polish measurements, separate run-on steps, and format times?</span>
</div>
</div>
<button type="button" class="btn btn-outline btn-sm" id="btn-ai-polish">
<i data-lucide="wand-2"></i> <span>Polish with AI</span>
</button>
</div>
<div class="form-grid-2"> <div class="form-grid-2">
<!-- Ingredients Editing --> <!-- Ingredients Editing -->
<div class="form-group"> <div class="form-group">

View file

@ -719,6 +719,79 @@ h1, h2, h3, h4, h5, h6 {
} }
} }
/* AI Polish Suggestion Banner */
.ai-polish-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
margin-bottom: 20px;
background-color: hsla(var(--hue-secondary), 100%, 51%, 0.08);
border: 1px solid hsla(var(--hue-secondary), 100%, 51%, 0.25);
border-radius: var(--border-radius-md);
animation: fadeIn 0.3s ease-out;
}
.ai-polish-info {
display: flex;
align-items: center;
gap: 12px;
}
.ai-polish-icon {
width: 36px;
height: 36px;
border-radius: var(--border-radius-full);
background-color: hsla(var(--hue-secondary), 100%, 51%, 0.18);
color: var(--secondary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ai-polish-icon i,
.ai-polish-icon svg {
width: 18px;
height: 18px;
}
.ai-polish-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.ai-polish-title {
font-weight: 600;
font-size: 0.92rem;
color: var(--text-main);
}
.ai-polish-subtitle {
font-size: 0.82rem;
color: var(--text-muted);
}
.ai-polish-banner.success {
background-color: hsla(118, 19%, 43%, 0.1);
border-color: hsla(118, 19%, 43%, 0.3);
}
.ai-polish-banner.success .ai-polish-icon {
background-color: hsla(118, 19%, 43%, 0.2);
color: var(--accent);
}
@media (max-width: 640px) {
.ai-polish-banner {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
}
.form-group { .form-group {
margin-bottom: 20px; margin-bottom: 20px;
} }

View file

@ -1557,34 +1557,60 @@ async function scrapeTikTokRecipe(inputUrl) {
throw new Error('No caption or description found on this TikTok video. The creator might not have written recipe details.'); throw new Error('No caption or description found on this TikTok video. The creator might not have written recipe details.');
} }
// 3. Try Gemini AI structuring if API key is configured // Fast & Local Heuristic Engine First (Zero API cost, instant execution)
console.log('[TikTok] Running local heuristic parser on video caption...');
const heuristicResult = parseRecipeCaptionHeuristic(caption, author, cover, canonicalUrl);
if (!heuristicResult.ingredients || heuristicResult.ingredients.length === 0) {
throw new Error('This TikTok caption does not appear to contain an ingredients list. The recipe might be in the audio, video comments, or on-screen text.');
}
heuristicResult._scrapingMethod = 'tiktok-caption-heuristic';
heuristicResult.rawCaption = caption;
heuristicResult.author = author;
heuristicResult.suggestAiPolish = true;
return heuristicResult;
}
// API: Polish and format recipe using Gemini AI (User-initiated)
app.post('/api/recipes/ai-polish', requireAuth, async (req, res) => {
const { rawCaption, title, ingredients, instructions } = req.body;
const settingsRow = db.prepare('SELECT geminiApiKey FROM settings LIMIT 1').get(); const settingsRow = db.prepare('SELECT geminiApiKey FROM settings LIMIT 1').get();
const geminiKey = settingsRow?.geminiApiKey?.trim(); const geminiKey = settingsRow?.geminiApiKey?.trim();
const isPlaceholder = !geminiKey || geminiKey === 'PASTE_YOUR_GEMINI_API_KEY_HERE'; const isPlaceholder = !geminiKey || geminiKey === 'PASTE_YOUR_GEMINI_API_KEY_HERE';
if (!isPlaceholder) { if (isPlaceholder) {
return res.status(400).json({
error: 'Google Gemini API Key is not configured. Please open Settings (top right gear icon) and add your Gemini API key to enable AI text polishing.'
});
}
try { try {
console.log('[TikTok] Attempting Gemini AI parsing...'); const promptText = `You are a culinary expert assistant for a recipe app. Clean up, format, and organize the following recipe.
const promptText = `You are a culinary expert assistant for a recipe app. Extract and structure the recipe from this TikTok video caption. If prep time, cook time, or servings are not explicitly stated, estimate reasonably or leave as empty string. Clean up any hashtag clutter, emojis, or sponsorship notes from the title and instructions. - Standardize ingredient measurements (amounts, units, descriptions) onto separate lines.
- Turn run-on or conversational directions into clean, ordered, numbered steps.
- Infer or estimate prep time, cook time, and servings if mentioned or implied.
- Clean up any hashtags, social media slang, or promotional sponsor notes.
Creator: ${author || 'TikTok Chef'} Source Text / Current Draft:
Caption: Title: ${title || ''}
""" ${rawCaption ? `Original Caption:\n"""\n${rawCaption}\n"""` : ''}
${caption} Ingredients so far:
""" ${Array.isArray(ingredients) ? ingredients.join('\n') : (ingredients || '')}
Instructions so far:
${Array.isArray(instructions) ? instructions.join('\n') : (instructions || '')}
The output MUST be a valid JSON object matching this schema: Return ONLY a valid JSON object matching this schema without markdown code blocks:
{ {
"title": "Recipe Name", "title": "Cleaned Recipe Title",
"description": "Short description of the recipe", "description": "Short description of the recipe",
"servings": "e.g., 4", "servings": "e.g., 4",
"prepTime": "e.g., 10 mins", "prepTime": "e.g., 15 mins",
"cookTime": "e.g., 20 mins", "cookTime": "e.g., 45 mins",
"totalTime": "e.g., 30 mins", "totalTime": "e.g., 1 hour",
"ingredients": ["ingredient line 1", "ingredient line 2"], "ingredients": ["ingredient line 1", "ingredient line 2"],
"instructions": ["Step 1", "Step 2"] "instructions": ["Step 1", "Step 2"]
} }`;
Return ONLY raw JSON without markdown code fences.`;
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${geminiKey}`; const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${geminiKey}`;
const aiRes = await axios.post(geminiUrl, { const aiRes = await axios.post(geminiUrl, {
@ -1598,29 +1624,16 @@ Return ONLY raw JSON without markdown code fences.`;
if (aiRes.data && aiRes.data.candidates?.[0]?.content?.parts?.[0]?.text) { if (aiRes.data && aiRes.data.candidates?.[0]?.content?.parts?.[0]?.text) {
const rawText = aiRes.data.candidates[0].content.parts[0].text; const rawText = aiRes.data.candidates[0].content.parts[0].text;
const parsed = JSON.parse(rawText); const parsed = JSON.parse(rawText);
if (Array.isArray(parsed.ingredients) && parsed.ingredients.length > 0) { return res.json(parsed);
parsed.image = cover || ''; } else {
parsed.images = cover ? [cover] : []; throw new Error('Gemini returned an empty response.');
parsed.sourceUrl = canonicalUrl;
parsed._scrapingMethod = 'tiktok-gemini-ai';
return parsed;
} }
} catch (err) {
console.error('[AI Polish] Error:', err.response?.data?.error?.message || err.message);
const msg = err.response?.data?.error?.message || err.message;
return res.status(500).json({ error: `AI Polish failed: ${msg}` });
} }
} catch (aiErr) { });
console.warn('[TikTok] Gemini parsing note (falling back to heuristic):', aiErr.response?.data?.error?.message || aiErr.message);
}
}
// 4. Heuristic Fallback Parser
console.log('[TikTok] Using heuristic caption parser...');
const heuristicResult = parseRecipeCaptionHeuristic(caption, author, cover, canonicalUrl);
if (!heuristicResult.ingredients || heuristicResult.ingredients.length === 0) {
throw new Error('This TikTok caption does not appear to contain an ingredients list. The recipe might be in the audio, video comments, or on-screen text.');
}
heuristicResult._scrapingMethod = 'tiktok-caption-heuristic';
return heuristicResult;
}
// API: Load System Settings & User Preferences // API: Load System Settings & User Preferences
app.get('/api/settings', requireAuth, (req, res) => { app.get('/api/settings', requireAuth, (req, res) => {