mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
Use local heuristic engine first for extraction and offer user-initiated AI Polish action
This commit is contained in:
parent
e283f238cc
commit
a098ea3bca
4 changed files with 246 additions and 56 deletions
|
|
@ -1,6 +1,7 @@
|
|||
// ForkFolio client application state
|
||||
let allRecipes = [];
|
||||
let activeRecipe = null;
|
||||
let currentDraftRawCaption = '';
|
||||
let currentMultiplier = 1;
|
||||
let selectedCoverImage = '';
|
||||
let stopwatchInterval = null;
|
||||
|
|
@ -144,6 +145,10 @@ const elements = {
|
|||
modalActionTitle: document.getElementById('modal-action-title'),
|
||||
btnSaveConfirm: document.getElementById('btn-save-confirm'),
|
||||
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
|
||||
detailModal: document.getElementById('detail-modal'),
|
||||
|
|
@ -265,6 +270,7 @@ function registerEvents() {
|
|||
|
||||
// Recipe Form Save
|
||||
elements.recipeEditForm.addEventListener('submit', handleRecipeSave);
|
||||
elements.btnAiPolish?.addEventListener('click', handleAiPolish);
|
||||
|
||||
// Action Buttons Detail Modal
|
||||
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
|
||||
async function handleRecipeSave(e) {
|
||||
e.preventDefault();
|
||||
|
|
@ -856,6 +922,9 @@ async function revokeActiveRecipeShare() {
|
|||
// -------------------------------------------------------------
|
||||
|
||||
function openManualAdd() {
|
||||
currentDraftRawCaption = '';
|
||||
if (elements.aiPolishBanner) elements.aiPolishBanner.style.display = 'none';
|
||||
|
||||
elements.modalActionTitle.textContent = 'Create New Recipe';
|
||||
elements.editRecipeId.value = '';
|
||||
elements.recipeEditForm.reset();
|
||||
|
|
@ -887,12 +956,33 @@ function openDraftPreview(data) {
|
|||
// Render Image Selector
|
||||
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');
|
||||
}
|
||||
|
||||
function editActiveRecipe() {
|
||||
if (!activeRecipe) return;
|
||||
|
||||
currentDraftRawCaption = '';
|
||||
if (elements.aiPolishBanner) elements.aiPolishBanner.style.display = 'none';
|
||||
|
||||
elements.modalActionTitle.textContent = 'Edit Recipe Details';
|
||||
elements.editRecipeId.value = activeRecipe.id;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</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">
|
||||
<!-- Ingredients Editing -->
|
||||
<div class="form-group">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
|
|
|||
87
server.js
87
server.js
|
|
@ -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.');
|
||||
}
|
||||
|
||||
// 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 geminiKey = settingsRow?.geminiApiKey?.trim();
|
||||
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 {
|
||||
console.log('[TikTok] Attempting Gemini AI parsing...');
|
||||
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.
|
||||
const promptText = `You are a culinary expert assistant for a recipe app. Clean up, format, and organize the following recipe.
|
||||
- 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'}
|
||||
Caption:
|
||||
"""
|
||||
${caption}
|
||||
"""
|
||||
Source Text / Current Draft:
|
||||
Title: ${title || ''}
|
||||
${rawCaption ? `Original Caption:\n"""\n${rawCaption}\n"""` : ''}
|
||||
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",
|
||||
"servings": "e.g., 4",
|
||||
"prepTime": "e.g., 10 mins",
|
||||
"cookTime": "e.g., 20 mins",
|
||||
"totalTime": "e.g., 30 mins",
|
||||
"prepTime": "e.g., 15 mins",
|
||||
"cookTime": "e.g., 45 mins",
|
||||
"totalTime": "e.g., 1 hour",
|
||||
"ingredients": ["ingredient line 1", "ingredient line 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 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) {
|
||||
const rawText = aiRes.data.candidates[0].content.parts[0].text;
|
||||
const parsed = JSON.parse(rawText);
|
||||
if (Array.isArray(parsed.ingredients) && parsed.ingredients.length > 0) {
|
||||
parsed.image = cover || '';
|
||||
parsed.images = cover ? [cover] : [];
|
||||
parsed.sourceUrl = canonicalUrl;
|
||||
parsed._scrapingMethod = 'tiktok-gemini-ai';
|
||||
return parsed;
|
||||
return res.json(parsed);
|
||||
} else {
|
||||
throw new Error('Gemini returned an empty response.');
|
||||
}
|
||||
} 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
|
||||
app.get('/api/settings', requireAuth, (req, res) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue