diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 719c5df1..ddb22515 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -98,3 +98,11 @@ Zabbix monitoring: 1. Build — Containerfile builds successfully 2. Application health test — HTTP 200 from Node.js backend 3. Push — Image pushed to Quay.io + +## Code Review Regression Prevention + +Gemini Code Assist reviews every PR. To prevent later PRs from undoing reviewed fixes: + +1. **Check before modifying**: When substantially modifying a file, check recent PRs for unresolved Gemini feedback on that file (`gh api repos/crunchtools/rotv/pulls/{N}/comments`). Address or preserve those fixes. +2. **Mark reviewed fixes**: When fixing a bug caught by code review, add an inline comment: `// Fix: (PR #NNN review)`. This makes the fix visible to anyone refactoring the area later. +3. **Don't silently revert**: If a reviewed fix must be changed, explain why in the PR description. diff --git a/CLAUDE.md b/CLAUDE.md index c0198960..210a4de6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ Before making any changes, read these documents in order: | SemVer | MAJOR.MINOR.PATCH versioning strictly followed | | Documentation | Architecture docs for major features | | AI Quality | Gourmand checks for AI slop detection | +| Review Fixes | Mark reviewed fixes with `// Fix: (PR #N review)` | --- diff --git a/backend/migrations/012_add_research_context.sql b/backend/migrations/012_add_research_context.sql new file mode 100644 index 00000000..89eff318 --- /dev/null +++ b/backend/migrations/012_add_research_context.sql @@ -0,0 +1 @@ +ALTER TABLE pois ADD COLUMN IF NOT EXISTS research_context TEXT; diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 4454f947..a882fe86 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -169,7 +169,7 @@ export function createAdminRouter(pool) { 'name', 'poi_type', 'latitude', 'longitude', 'geometry', 'geometry_drive_file_id', 'property_owner', 'owner_id', 'brief_description', 'era_id', 'historical_description', 'primary_activities', 'surface', 'pets', 'cell_signal', 'more_info_link', - 'events_url', 'news_url', + 'events_url', 'news_url', 'research_context', 'length_miles', 'difficulty', 'boundary_type', 'boundary_color' ]; const updates = {}; @@ -226,7 +226,7 @@ export function createAdminRouter(pool) { const allowedFields = [ 'name', 'latitude', 'longitude', 'property_owner', 'owner_id', 'brief_description', 'era', 'era_id', 'historical_description', 'primary_activities', 'surface', - 'pets', 'cell_signal', 'more_info_link', 'events_url', 'news_url', 'status_url' + 'pets', 'cell_signal', 'more_info_link', 'events_url', 'news_url', 'research_context', 'status_url' ]; const updates = {}; const values = []; @@ -611,6 +611,111 @@ export function createAdminRouter(pool) { } }); + // Multi-pass research (Issue #102) — returns draft for approval + router.post('/ai/research-v2', isAdmin, async (req, res) => { + const { destination, adminContext } = req.body; + + if (!destination || !destination.name) { + return res.status(400).json({ error: 'Destination with name is required' }); + } + + try { + // Inject admin context into the destination object + const destWithContext = { ...destination, research_context: adminContext || destination.research_context || '' }; + + // Fetch constrained lists + const activitiesResult = await pool.query('SELECT name FROM activities ORDER BY sort_order, name'); + const availableActivities = activitiesResult.rows.map(row => row.name); + + const erasResult = await pool.query('SELECT id, name FROM eras ORDER BY sort_order, name'); + const availableEras = erasResult.rows.map(row => row.name); + + const surfacesResult = await pool.query('SELECT name FROM surfaces ORDER BY sort_order, name'); + const availableSurfaces = surfacesResult.rows.map(row => row.name); + + const { researchLocationMultiPass } = await import('../services/geminiService.js'); + const data = await researchLocationMultiPass(pool, destWithContext, availableActivities, availableEras, availableSurfaces); + + console.log(`Admin ${req.user.email} researched (v2) location: ${destination.name}`); + res.json({ draft: true, data, destination_id: destination.id }); + } catch (error) { + console.error('Error in multi-pass research:', error); + if (error.message?.includes('API key')) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: error.message || 'Failed to research location. Please try again.' }); + } + }); + + // Generate hero image for a POI (Issue #99) + router.post('/ai/generate-hero-image', isAdmin, async (req, res) => { + const { poiId, poiName, briefDescription, historicalDescription, era } = req.body; + + if (!poiName) { + return res.status(400).json({ error: 'POI name is required' }); + } + + try { + const { generateHeroImage } = await import('../services/geminiService.js'); + const result = await generateHeroImage(pool, { + name: poiName, + briefDescription, + historicalDescription, + era + }); + + console.log(`Admin ${req.user.email} generated hero image for: ${poiName}`); + res.json({ + imageData: result.base64, + mimeType: result.mimeType, + promptUsed: result.promptUsed + }); + } catch (error) { + console.error('Error generating hero image:', error); + res.status(500).json({ error: error.message || 'Failed to generate hero image.' }); + } + }); + + // Accept and save a generated hero image + router.post('/ai/accept-hero-image', isAdmin, async (req, res) => { + const { poiId, imageData, mimeType } = req.body; + + // Fix: sanitize poiId to numeric to prevent path traversal (PR #173 review) + const sanitizedPoiId = parseInt(poiId, 10); + if (!sanitizedPoiId || !imageData) { + return res.status(400).json({ error: 'Valid numeric POI ID and image data are required' }); + } + + try { + const imageBuffer = Buffer.from(imageData, 'base64'); + const mimeMap = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' }; + const extension = mimeMap[mimeType] || 'png'; + const filename = `hero-${sanitizedPoiId}-${Date.now()}.${extension}`; + + // Fix: upload first, then delete old — avoids leaving POI imageless if upload fails (PR #173 review) + const uploadResult = await imageServerClient.uploadImage( + imageBuffer, sanitizedPoiId, 'primary', filename, mimeType + ); + + if (!uploadResult.success) { + throw new Error(uploadResult.error || 'Upload failed'); + } + + // Delete old primary asset after successful upload + const existingPrimary = await imageServerClient.getPrimaryAsset(sanitizedPoiId); + if (existingPrimary && existingPrimary.id !== uploadResult.assetId) { + await imageServerClient.deleteAsset(existingPrimary.id); + console.log(`[Hero Image] Deleted old primary asset ${existingPrimary.id} for POI ${sanitizedPoiId}`); + } + + console.log(`Admin ${req.user.email} accepted hero image for POI ${sanitizedPoiId}: asset ${uploadResult.assetId}`); + res.json({ success: true, assetId: uploadResult.assetId }); + } catch (error) { + console.error('Error accepting hero image:', error); + res.status(500).json({ error: error.message || 'Failed to save hero image.' }); + } + }); + // ============================================ // Activities Management Routes // ============================================ diff --git a/backend/server.js b/backend/server.js index 41054439..aa3b2c4b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -28,7 +28,8 @@ import { scheduleImageBackup, registerDatabaseBackupHandler, scheduleDatabaseBackup, - stopJobScheduler + stopJobScheduler, + withJitter } from './services/jobScheduler.js'; import { processItem, processPendingItems } from './services/moderationService.js'; import { @@ -590,6 +591,11 @@ async function initDatabase() { ALTER TABLE pois ADD COLUMN IF NOT EXISTS news_url TEXT `); + // Add research_context column for multi-pass AI research + await client.query(` + ALTER TABLE pois ADD COLUMN IF NOT EXISTS research_context TEXT + `); + // Add status_url column for MTB Trail Status feature await client.query(` ALTER TABLE pois ADD COLUMN IF NOT EXISTS status_url VARCHAR(500) @@ -1069,7 +1075,7 @@ app.get('/api/destinations', async (req, res) => { p.owner_id, o.name as owner_name, p.property_owner, p.brief_description, p.era_id, e.name as era_name, p.historical_description, p.primary_activities, p.surface, p.pets, p.cell_signal, p.more_info_link, - p.image_drive_file_id, p.news_url, p.events_url, p.status_url, + p.image_drive_file_id, p.news_url, p.events_url, p.research_context, p.status_url, p.deleted, p.created_at, p.updated_at FROM pois p LEFT JOIN pois o ON p.owner_id = o.id AND o.poi_type = 'virtual' @@ -1093,7 +1099,7 @@ app.get('/api/destinations/:id', async (req, res) => { p.owner_id, o.name as owner_name, p.property_owner, p.brief_description, p.era_id, e.name as era_name, p.historical_description, p.primary_activities, p.surface, p.pets, p.cell_signal, p.more_info_link, - p.image_drive_file_id, p.news_url, p.events_url, p.status_url, + p.image_drive_file_id, p.news_url, p.events_url, p.research_context, p.status_url, p.deleted, p.created_at, p.updated_at FROM pois p LEFT JOIN pois o ON p.owner_id = o.id AND o.poi_type = 'virtual' @@ -1881,7 +1887,7 @@ async function start() { await initJobScheduler(connectionString); // Register scheduled news collection handler (daily job for all POIs) - await registerNewsCollectionHandler(async () => { + await registerNewsCollectionHandler(withJitter(async () => { console.log('Running scheduled news collection for all POIs...'); const newsCollectionResult = await runNewsCollection(pool, null); if (newsCollectionResult.totalPois > 0) { @@ -1889,7 +1895,7 @@ async function start() { } else { console.log('No POIs to collect'); } - }); + }, 'news-collection')); // Register batch news collection handler (for admin-triggered jobs via pg-boss) await registerBatchNewsHandler(async (pgBossJobId, jobData) => { @@ -1901,7 +1907,7 @@ async function start() { await scheduleNewsCollection('0 6 * * *'); // Register scheduled trail status collection handler - await registerTrailStatusHandler(async () => { + await registerTrailStatusHandler(withJitter(async () => { console.log('Running scheduled trail status collection for all MTB trails...'); const { runTrailStatusCollection } = await import('./services/trailStatusService.js'); const boss = app.get('boss'); @@ -1913,7 +1919,7 @@ async function start() { } else { console.log('No MTB trails to collect'); } - }); + }, 'trail-status')); // Register batch trail status collection handler await registerBatchTrailStatusHandler(async (jobId, poiIds) => { @@ -1931,7 +1937,7 @@ async function start() { }); // Register moderation sweep handler (catches unprocessed items) - await registerModerationSweepHandler(async () => { + await registerModerationSweepHandler(withJitter(async () => { await processPendingItems(pool); // Retention: purge job logs older than 30 days try { @@ -1942,7 +1948,7 @@ async function start() { } catch (err) { console.error('[JobLogger] Retention cleanup failed:', err.message); } - }); + }, 'moderation-sweep')); // Schedule moderation sweep every 15 minutes await scheduleModerationSweep('*/15 * * * *'); @@ -1972,25 +1978,25 @@ async function start() { return createDriveServiceWithRefresh(credentials, pool, adminResult.rows[0].id); }; - await registerImageBackupHandler(async () => { + await registerImageBackupHandler(withJitter(async () => { console.log('Running scheduled image backup...'); const { triggerImageBackup } = await import('./services/backupService.js'); const drive = await getAdminDriveService(); const result = await triggerImageBackup(pool, drive); console.log(`Image backup completed: ${result.uploaded} uploaded, ${result.skipped} skipped, ${result.failed} failed`); - }); + }, 'image-backup')); // Schedule nightly image backup at 2 AM Eastern await scheduleImageBackup('0 2 * * *'); // Register database backup handler (nightly pg_dump to Drive) - await registerDatabaseBackupHandler(async () => { + await registerDatabaseBackupHandler(withJitter(async () => { console.log('Running scheduled database backup...'); const { triggerBackup } = await import('./services/backupService.js'); const drive = await getAdminDriveService(); const result = await triggerBackup(pool, drive); console.log(`Database backup completed: ${result.filename} (${result.driveFileId})`); - }); + }, 'database-backup')); // Schedule nightly database backup at 3 AM Eastern await scheduleDatabaseBackup('0 3 * * *'); diff --git a/backend/services/collection/registry.js b/backend/services/collection/registry.js index 792c8f0e..b91fbbff 100644 --- a/backend/services/collection/registry.js +++ b/backend/services/collection/registry.js @@ -118,16 +118,24 @@ export const COLLECTION_TYPES = [ { id: 'research', label: 'POI Research', - description: 'AI research for POI metadata and icon generation', + description: 'Multi-pass AI research for POI metadata, descriptions, and hero images', icon: '\u{1F50D}', - promptKeys: [], + promptKeys: [{ + key: 'gemini_prompt_brief', + label: 'Brief Description Prompt', + placeholders: ['{{name}}', '{{era}}', '{{property_owner}}'] + }, { + key: 'gemini_prompt_historical', + label: 'Historical Description Prompt', + placeholders: ['{{name}}', '{{era}}', '{{property_owner}}'] + }], scheduleJobName: null, schedule: null, statusTable: null, historyTypes: ['research'], - triggerEndpoint: null, - manualTriggerMethod: null, - hasPrompt: false + triggerEndpoint: '/api/admin/ai/research-v2', + manualTriggerMethod: 'POST', + hasPrompt: true }, { id: 'cleanup', diff --git a/backend/services/geminiService.js b/backend/services/geminiService.js index 85d906dc..1ec690a1 100644 --- a/backend/services/geminiService.js +++ b/backend/services/geminiService.js @@ -13,6 +13,9 @@ import { logInfo, logError, flush as flushJobLogs } from './jobLogger.js'; // Gemini model — configurable via environment variable, defaults to gemini-2.5-flash export const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.5-flash'; +// Image generation model — separate because it requires image output modality support +export const GEMINI_IMAGE_MODEL = process.env.GEMINI_IMAGE_MODEL || 'gemini-2.5-flash-image'; + // Default prompt templates - designed to avoid generic AI slop const DEFAULT_PROMPTS = { gemini_prompt_brief: `You are a local historian writing for the Cuyahoga Valley National Park visitor guide. @@ -43,6 +46,71 @@ REQUIREMENTS: Location context: Era: {{era}}, Owner: {{property_owner}}` }; +/** + * Parse JSON from Gemini response text, handling markdown code blocks, + * duplicated JSON, and truncated responses (e.g. token limit hit mid-array). + */ +export function parseJsonResponse(text) { + let jsonText = text; + + // Remove markdown code blocks if present + const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (jsonMatch) { + jsonText = jsonMatch[1].trim(); + } else { + // No closing ``` found — response may be truncated. Extract from first { + const openBrace = text.indexOf('{'); + if (openBrace >= 0) { + jsonText = text.substring(openBrace); + } + } + + // If there are multiple JSON objects, take the first complete one + const firstBrace = jsonText.indexOf('{'); + if (firstBrace > 0) { + jsonText = jsonText.substring(firstBrace); + } + + // Find the matching closing brace for the first opening brace. + // Skip braces/brackets inside quoted strings to avoid miscounting. + let braceCount = 0; + let bracketCount = 0; + let endIndex = -1; + let inString = false; + for (let i = 0; i < jsonText.length; i++) { + const ch = jsonText[i]; + if (ch === '"' && (i === 0 || jsonText[i - 1] !== '\\')) { + inString = !inString; + continue; + } + if (inString) continue; + if (ch === '{') braceCount++; + if (ch === '}') braceCount--; + if (ch === '[') bracketCount++; + if (ch === ']') bracketCount--; + if (braceCount === 0 && ch === '}') { + endIndex = i + 1; + break; + } + } + + if (endIndex > 0) { + jsonText = jsonText.substring(0, endIndex); + } else if (braceCount > 0) { + // Truncated JSON — try to salvage by closing open brackets/braces + // Trim trailing incomplete values (partial strings, trailing commas) + jsonText = jsonText.replace(/,\s*"[^"]*"?\s*$/, ''); // trailing partial key-value + jsonText = jsonText.replace(/,\s*"[^"]*$/, ''); // trailing partial string in array + jsonText = jsonText.replace(/,\s*$/, ''); // trailing comma + // Close open brackets and braces + for (let i = 0; i < bracketCount; i++) jsonText += ']'; + for (let i = 0; i < braceCount; i++) jsonText += '}'; + console.warn('[parseJsonResponse] Salvaged truncated JSON by closing', bracketCount, 'brackets and', braceCount, 'braces'); + } + + return JSON.parse(jsonText); +} + // Research prompt for filling all fields - {{activities_list}}, {{eras_list}}, and {{surfaces_list}} placeholders will be filled dynamically const RESEARCH_PROMPT_TEMPLATE = `You are a researcher for Cuyahoga Valley National Park. Search the web and find accurate information about this location. @@ -61,7 +129,7 @@ Return a JSON object with these fields (use null if you cannot find reliable inf "pets": "Pet policy: 'Yes', 'No', or 'Leashed'", "brief_description": "2-3 sentences with specific facts about what makes this place notable. Include dates and names.", "historical_description": "2-3 paragraphs of historical narrative with specific dates, people, and events. Written in warm local history style.", - "sources": ["Array of source URLs or references used"] + "sources": ["url1", "url2"] } ALLOWED ERAS (era MUST be one of these exact names): @@ -81,7 +149,8 @@ IMPORTANT: - Only include facts you can verify from search results - Use null for fields where you have no reliable information - Avoid generic filler text - specific facts or nothing -- The brief_description and historical_description should contain real, searchable facts`; +- The brief_description and historical_description should contain real, searchable facts +- For sources, include MAXIMUM 5 unique URLs. No duplicate URLs.`; /** * Get default prompt for a given key @@ -247,39 +316,7 @@ export async function researchLocation(pool, destination, availableActivities = const text = response.text(); try { - // Try to extract JSON from the response - // Sometimes Gemini returns markdown code blocks or duplicated JSON - let jsonText = text; - - // Remove markdown code blocks if present - const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/); - if (jsonMatch) { - jsonText = jsonMatch[1].trim(); - } - - // If there are multiple JSON objects, take the first complete one - const firstBrace = jsonText.indexOf('{'); - if (firstBrace > 0) { - jsonText = jsonText.substring(firstBrace); - } - - // Find the matching closing brace for the first opening brace - let braceCount = 0; - let endIndex = -1; - for (let i = 0; i < jsonText.length; i++) { - if (jsonText[i] === '{') braceCount++; - if (jsonText[i] === '}') braceCount--; - if (braceCount === 0 && jsonText[i] === '}') { - endIndex = i + 1; - break; - } - } - - if (endIndex > 0) { - jsonText = jsonText.substring(0, endIndex); - } - - const researchData = JSON.parse(jsonText); + const researchData = parseJsonResponse(text); logInfo(runId, 'research', null, destination.name, `Research complete: ${destination.name}`, { completed: true, fields: Object.keys(researchData) }); await flushJobLogs(); return researchData; @@ -406,6 +443,276 @@ Generate ONLY the SVG code now, starting with :`; return text; } +// ============================================================ +// Multi-Pass Research (Issue #102) +// ============================================================ + +const RESEARCH_PASS1_TEMPLATE = `You are a researcher for Cuyahoga Valley National Park. Search the web and find accurate information about this location. + +Location to research: {{name}} +%%OPTIONAL_SECTIONS%% + +Search for information from NPS.gov, Ohio History Connection, local historical societies, and reliable sources. + +Return a JSON object with these fields (use null if you cannot find reliable information): + +{ + "era": "The primary historical era - MUST be one from the ALLOWED ERAS list below", + "property_owner": "Current owner/manager (e.g., 'Federal (NPS)', 'Cleveland Metroparks', 'Private')", + "primary_activities": "Comma-separated activities from the ALLOWED ACTIVITIES list ONLY", + "surface": "Trail/path surface type - MUST be one from the ALLOWED SURFACES list below", + "pets": "Pet policy: 'Yes', 'No', or 'Leashed'", + "brief_description": "2-3 sentences with specific facts about what makes this place notable. Include dates and names. NO generic phrases like 'rich history', 'beloved destination'.", + "sources": ["url1", "url2"] +} + +ALLOWED ERAS (era MUST be one of these exact names): +{{eras_list}} + +ALLOWED ACTIVITIES (only use activities from this list): +{{activities_list}} + +ALLOWED SURFACES (surface MUST be one of these exact names): +{{surfaces_list}} + +IMPORTANT: +- For era, you MUST select exactly one era from the ALLOWED ERAS list above +- For primary_activities, ONLY use activities from the ALLOWED ACTIVITIES list above +- For surface, you MUST select exactly one surface from the ALLOWED SURFACES list above +- Only include facts you can verify from search results +- Use null for fields where you have no reliable information +- Avoid generic filler text - specific facts or nothing +- For sources, include MAXIMUM 5 unique URLs. No duplicate URLs.`; + +const RESEARCH_PASS2_TEMPLATE = `You are writing for Arcadia Publishing's "Images of America" series about Cuyahoga Valley. + +Research and write 2-3 paragraphs about: {{name}} +%%OPTIONAL_SECTIONS%% + +CONTEXT FROM INITIAL RESEARCH: +- Era: {{pass1_era}} +- Brief description: {{pass1_brief}} + +Return a JSON object: + +{ + "historical_description": "2-3 paragraphs of historical narrative with specific dates, people, and events. Written in warm local history style. Include specific dates, names of people, and historical events. Reference primary sources when possible. Describe what the place looked like historically vs today. Connect to broader Ohio & Erie Canal corridor history if relevant. NO filler phrases: avoid 'rich tapestry', 'testament to', 'bygone era'. If information is uncertain, say 'According to local accounts...' or 'Records suggest...'. If you cannot verify facts, acknowledge the gaps.", + "additional_sources": ["Array of additional source URLs or references used"] +}`; + +/** + * Multi-pass research for a location (Issue #102) + * Pass 1: metadata + brief description + * Pass 2: historical description with Pass 1 context + */ +export async function researchLocationMultiPass(pool, destination, availableActivities = [], availableEras = [], availableSurfaces = []) { + const genAI = await createGeminiClient(pool); + + const model = genAI.getGenerativeModel({ + model: GEMINI_MODEL, + tools: [{ googleSearch: {} }], + generationConfig: { temperature: 0 } + }); + + // Build optional sections for prompts + const optionalSections = []; + if (destination.latitude && destination.longitude) { + optionalSections.push(`Coordinates: ${destination.latitude}, ${destination.longitude}`); + } + if (destination.more_info_link) { + optionalSections.push(`PRIORITY SOURCE: Consult this URL first: ${destination.more_info_link}`); + } + if (destination.research_context) { + optionalSections.push(`ADMIN CONTEXT (use this to guide your research): ${destination.research_context}`); + } + + // Build Pass 1 prompt + let pass1Template = RESEARCH_PASS1_TEMPLATE; + pass1Template = pass1Template.replace('%%OPTIONAL_SECTIONS%%', optionalSections.join('\n')); + + const activitiesList = availableActivities.length > 0 + ? availableActivities.join(', ') + : 'Hiking, Biking, Photography, Bird Watching, Fishing, Picnicking, Camping, Wildlife Viewing, Historical Tours'; + pass1Template = pass1Template.replace('{{activities_list}}', activitiesList); + + const erasList = availableEras.length > 0 + ? availableEras.join(', ') + : 'Pre-Colonial, Early Settlement, Canal Era, Railroad Era, Industrial Era, Conservation Era, Modern Era'; + pass1Template = pass1Template.replace('{{eras_list}}', erasList); + + const surfacesList = availableSurfaces.length > 0 + ? availableSurfaces.join(', ') + : 'Paved, Gravel, Boardwalk, Dirt, Grass, Sand, Rocky, Water, Rail, Mixed'; + pass1Template = pass1Template.replace('{{surfaces_list}}', surfacesList); + + const pass1Prompt = interpolatePrompt(pass1Template, destination); + + const runId = Math.floor(Date.now() / 1000); + console.log(`[Research v2] Pass 1 for: ${destination.name}`); + logInfo(runId, 'research', null, destination.name, `Research v2 Pass 1: ${destination.name}`); + + // Pass 1 + const pass1Generation = await model.generateContent(pass1Prompt); + const pass1Text = pass1Generation.response.text(); + let pass1Data; + + try { + pass1Data = parseJsonResponse(pass1Text); + } catch (e) { + console.error('Failed to parse Pass 1 response:', pass1Text); + logError(runId, 'research', null, destination.name, `Research v2 Pass 1 failed: ${destination.name}`, { error_stack: pass1Text.slice(0, 500) }); + await flushJobLogs(); + throw new Error('AI returned invalid format in Pass 1. Please try again.'); + } + + logInfo(runId, 'research', null, destination.name, `Research v2 Pass 1 complete: ${destination.name}`, { fields: Object.keys(pass1Data) }); + + // Pass 2 — historical description with Pass 1 context + let pass2Template = RESEARCH_PASS2_TEMPLATE; + pass2Template = pass2Template.replace('%%OPTIONAL_SECTIONS%%', optionalSections.join('\n')); + pass2Template = pass2Template.replace('{{pass1_era}}', pass1Data.era || 'unknown'); + pass2Template = pass2Template.replace('{{pass1_brief}}', pass1Data.brief_description || 'no description available'); + + const pass2Prompt = interpolatePrompt(pass2Template, destination); + + console.log(`[Research v2] Pass 2 for: ${destination.name}`); + logInfo(runId, 'research', null, destination.name, `Research v2 Pass 2: ${destination.name}`); + + const pass2Generation = await model.generateContent(pass2Prompt); + const pass2Text = pass2Generation.response.text(); + let pass2Data; + + try { + pass2Data = parseJsonResponse(pass2Text); + } catch (e) { + console.error('Failed to parse Pass 2 response:', pass2Text); + logError(runId, 'research', null, destination.name, `Research v2 Pass 2 failed: ${destination.name}`, { error_stack: pass2Text.slice(0, 500) }); + await flushJobLogs(); + throw new Error('AI returned invalid format in Pass 2. Please try again.'); + } + + // Resolve era name → era_id + let eraId = null; + if (pass1Data.era) { + const eraResult = await pool.query( + 'SELECT id FROM eras WHERE LOWER(name) = LOWER($1)', + [pass1Data.era] + ); + if (eraResult.rows.length > 0) { + eraId = eraResult.rows[0].id; + } + } + + // Merge results + const mergedSources = [ + ...(pass1Data.sources || []), + ...(pass2Data.additional_sources || []) + ]; + + const mergedResearch = { + era: pass1Data.era, + era_id: eraId, + property_owner: pass1Data.property_owner, + primary_activities: pass1Data.primary_activities, + surface: pass1Data.surface, + pets: pass1Data.pets, + brief_description: pass1Data.brief_description, + historical_description: pass2Data.historical_description, + sources: mergedSources + }; + + logInfo(runId, 'research', null, destination.name, `Research v2 complete: ${destination.name}`, { completed: true, fields: Object.keys(mergedResearch) }); + await flushJobLogs(); + + return mergedResearch; +} + +// ============================================================ +// Hero Image Generation (Issue #99) +// ============================================================ + +/** + * Generate a hero image for a POI using Gemini image generation + * @param {object} pool - Database pool + * @param {object} poiData - { name, briefDescription, historicalDescription, era } + * @returns {{ base64: string, mimeType: string, promptUsed: string }} + */ +export async function generateHeroImage(pool, poiData) { + // Get API key + let apiKey = process.env.GEMINI_API_KEY; + if (!apiKey) { + const apiKeyQuery = await pool.query( + "SELECT value FROM admin_settings WHERE key = 'gemini_api_key'" + ); + if (!apiKeyQuery.rows.length || !apiKeyQuery.rows[0].value) { + throw new Error('Gemini API key not configured.'); + } + apiKey = apiKeyQuery.rows[0].value; + } + + const prompt = `Generate a historical photograph of ${poiData.name} in the style of 1800s photography. + +CONTEXT: +- Era: ${poiData.era || 'historical'} +- Description: ${poiData.briefDescription || ''} +- History: ${(poiData.historicalDescription || '').substring(0, 500)} + +STYLE REQUIREMENTS: +- Sepia-toned or black-and-white photograph from the 1800s +- Arcadia Publishing "Images of America" aesthetic +- Period-appropriate architecture, vegetation, and atmosphere +- Realistic photographic quality, not illustration +- NO text, watermarks, or labels in the image +- NO modern elements (cars, power lines, modern buildings) +- Landscape orientation showing the location in its historical context`; + + // Use REST API directly for image generation (SDK may not support image output modality) + // Fix: use x-goog-api-key header instead of URL query param to avoid key leakage in logs (PR #173 review) + const modelName = GEMINI_IMAGE_MODEL; + const url = `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent`; + + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), 120000); + + let response; + try { + response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey }, + signal: abortController.signal, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { + responseModalities: ['TEXT', 'IMAGE'] + } + }) + }); + } finally { + clearTimeout(timeoutId); + } + + if (!response.ok) { + const errorText = await response.text(); + console.error('[Hero Image] Gemini API error:', errorText); + throw new Error(`Image generation failed: ${response.status}`); + } + + const geminiResponse = await response.json(); + + const parts = geminiResponse.candidates?.[0]?.content?.parts || []; + const imagePart = parts.find(p => p.inlineData); + + if (!imagePart) { + throw new Error('No image generated. The model may have refused the request.'); + } + + return { + base64: imagePart.inlineData.data, + mimeType: imagePart.inlineData.mimeType || 'image/png', + promptUsed: prompt + }; +} + /** * Moderate text content (news/events) using Gemini Flash * @param {Pool} pool - Database connection pool diff --git a/backend/services/jobScheduler.js b/backend/services/jobScheduler.js index 7b97fc7d..20b1f69c 100644 --- a/backend/services/jobScheduler.js +++ b/backend/services/jobScheduler.js @@ -565,4 +565,22 @@ export async function stopJobScheduler() { } } +/** + * Wrap a scheduled job handler with random jitter delay (anti-bot detection) + * Only for cron-scheduled jobs — manual/admin triggers should NOT use this. + * @param {Function} handler - Async handler to wrap + * @param {string} jobName - Job name for logging + * @param {number} minSeconds - Minimum delay in seconds (default: 1) + * @param {number} maxSeconds - Maximum delay in seconds (default: 60) + * @returns {Function} Wrapped handler with jitter delay + */ +export function withJitter(handler, jobName, minSeconds = 1, maxSeconds = 60) { + return async (...args) => { + const delay = Math.floor(Math.random() * (maxSeconds - minSeconds + 1)) + minSeconds; + console.log(`[Jitter] ${jobName} delayed by ${delay}s`); + await new Promise(resolve => setTimeout(resolve, delay * 1000)); + return handler(...args); + }; +} + export { JOB_NAMES }; diff --git a/backend/tests/jobScheduler.unit.test.js b/backend/tests/jobScheduler.unit.test.js new file mode 100644 index 00000000..44c53e1a --- /dev/null +++ b/backend/tests/jobScheduler.unit.test.js @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { withJitter } from '../services/jobScheduler.js'; + +describe('withJitter', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should call the wrapped handler', async () => { + const handler = vi.fn().mockResolvedValue('done'); + const wrapped = withJitter(handler, 'test-job'); + + const promise = wrapped('arg1', 'arg2'); + await vi.runAllTimersAsync(); + const returnValue = await promise; + + expect(handler).toHaveBeenCalledWith('arg1', 'arg2'); + expect(returnValue).toBe('done'); + }); + + it('should delay between minSeconds and maxSeconds (default 1-60)', async () => { + const handler = vi.fn().mockResolvedValue(undefined); + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + + const wrapped = withJitter(handler, 'test-job'); + const promise = wrapped(); + await vi.runAllTimersAsync(); + await promise; + + const jitterCall = setTimeoutSpy.mock.calls.find( + ([, ms]) => ms >= 1000 && ms <= 60000 + ); + expect(jitterCall).toBeDefined(); + const delayMs = jitterCall[1]; + expect(delayMs).toBeGreaterThanOrEqual(1000); + expect(delayMs).toBeLessThanOrEqual(60000); + expect(delayMs % 1000).toBe(0); + + setTimeoutSpy.mockRestore(); + }); + + it('should respect custom min/max seconds', async () => { + const handler = vi.fn().mockResolvedValue(undefined); + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + + const wrapped = withJitter(handler, 'test-job', 5, 10); + const promise = wrapped(); + await vi.runAllTimersAsync(); + await promise; + + const jitterCall = setTimeoutSpy.mock.calls.find( + ([, ms]) => ms >= 5000 && ms <= 10000 + ); + expect(jitterCall).toBeDefined(); + + setTimeoutSpy.mockRestore(); + }); + + it('should propagate errors from the handler', async () => { + vi.useRealTimers(); + const error = new Error('handler failed'); + const handler = vi.fn().mockRejectedValue(error); + const wrapped = withJitter(handler, 'test-job', 0, 0); + + await expect(wrapped()).rejects.toThrow('handler failed'); + vi.useFakeTimers(); + }); + + it('should log the jitter delay', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const handler = vi.fn().mockResolvedValue(undefined); + const wrapped = withJitter(handler, 'my-job'); + + const promise = wrapped(); + await vi.runAllTimersAsync(); + await promise; + + const jitterLog = consoleSpy.mock.calls.find( + ([msg]) => typeof msg === 'string' && msg.includes('[Jitter] my-job delayed by') + ); + expect(jitterLog).toBeDefined(); + + consoleSpy.mockRestore(); + }); +}); diff --git a/frontend/src/App.css b/frontend/src/App.css index a635e546..bb31e2c0 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -8235,6 +8235,235 @@ body { text-decoration: underline; } +/* Draft Approval Modal (multi-pass research) */ +.prompt-editor-overlay:has(.draft-approval-dialog) { + align-items: flex-start; + overflow-y: auto; + padding: 2rem 1rem; +} + +.draft-approval-dialog { + background: white; + border-radius: 12px; + width: 100%; + max-width: 650px; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + margin: 0 auto; +} + +.draft-fields { + padding: 0 1.25rem 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.draft-field { + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 0.75rem; + transition: border-color 0.2s, background 0.2s; +} + +.draft-field.accepted { + border-color: #81c784; + background: #f1f8e9; +} + +.draft-field.rejected { + border-color: #e0e0e0; + background: #fafafa; + opacity: 0.6; +} + +.draft-field-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.draft-field-header label { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + font-size: 0.85rem; + color: #333; + cursor: pointer; +} + +.draft-field-header input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: #4a7c23; +} + +.draft-field-input { + width: 100%; + padding: 0.4rem 0.5rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 0.85rem; + color: #333; + box-sizing: border-box; +} + +.draft-field-input:focus, +.draft-field-textarea:focus { + outline: none; + border-color: #4a7c23; + box-shadow: 0 0 0 2px rgba(74, 124, 35, 0.15); +} + +.draft-field-textarea { + width: 100%; + padding: 0.5rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 0.85rem; + color: #333; + line-height: 1.5; + resize: vertical; + box-sizing: border-box; + font-family: inherit; +} + +.draft-sources { + padding: 0 1.25rem 1rem; + font-size: 0.8rem; +} + +.draft-sources strong { + color: #333; +} + +.draft-sources ul { + margin: 0.5rem 0 0 0; + padding-left: 1.2rem; +} + +.draft-sources li { + margin: 0.25rem 0; + word-break: break-all; +} + +.draft-sources a { + color: #1976d2; + text-decoration: none; +} + +.draft-sources a:hover { + text-decoration: underline; +} + +.draft-hero-section { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1.25rem; + background: #f5f5f5; + border-top: 1px solid #e0e0e0; +} + +.draft-hero-generating { + width: 100%; + padding: 2rem 0; +} + +.hero-generating-indicator { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + color: #558b2f; + font-size: 0.9rem; + font-weight: 600; +} + +.hero-spinner { + width: 20px; + height: 20px; + border: 3px solid #c8e6c9; + border-top-color: #43a047; + border-radius: 50%; + animation: hero-spin 0.8s linear infinite; +} + +@keyframes hero-spin { + to { transform: rotate(360deg); } +} + +/* Hero Image Inline Preview (in draft modal) */ +.draft-hero-preview { + width: 100%; +} + +.draft-hero-preview .hero-image-preview img { + max-width: 100%; + max-height: 300px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + display: block; + margin: 0 auto; +} + +.draft-hero-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; +} + +/* Red button — same shape/look as research-btn but red */ +.reject-btn { + padding: 0.6rem 1.2rem; + background: linear-gradient(135deg, #e53935 0%, #c62828 100%); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + font-size: 0.9rem; + transition: opacity 0.2s, transform 0.1s; +} + +.reject-btn:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +.reject-btn:active { + transform: translateY(0); +} + +/* Pencil icon for prompt editing */ +.prompt-edit-icon { + background: transparent; + border: none; + cursor: pointer; + font-size: 0.85rem; + color: #667eea; + padding: 0 0.25rem; + margin-left: 0.25rem; + opacity: 0.7; + transition: opacity 0.2s; + vertical-align: middle; +} + +.prompt-edit-icon:hover { + opacity: 1; +} + +/* Field hint text next to labels */ +.field-hint { + font-weight: 400; + font-size: 0.75rem; + color: #999; + margin-left: 0.25rem; +} + /* News & Events Collection Button */ .news-section { background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%); diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index bd86903a..303c8dfb 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -401,8 +401,6 @@ function ReadOnlyView({ destination, isLinearFeature, isAdmin, editMode, showIma function EditView({ destination, editedData, setEditedData, onSave, onCancel, onDelete, saving, deleting, onPreviewCoordsChange, isNewPOI, isNewOrganization, _onImageUpdate, isLinearFeature, showImage = true }) { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [aiError, setAiError] = useState(null); - const [researchSources, setResearchSources] = useState(null); - // Prompt editor modal state const [showPromptEditor, setShowPromptEditor] = useState(false); const [promptType, setPromptType] = useState(null); // 'brief' or 'historical' @@ -413,6 +411,16 @@ function EditView({ destination, editedData, setEditedData, onSave, onCancel, on // Research state const [researching, setResearching] = useState(false); + // Draft approval modal state (multi-pass research v2) + const [researchDraft, setResearchDraft] = useState(null); + const [showDraftModal, setShowDraftModal] = useState(false); + const [draftFieldStates, setDraftFieldStates] = useState({}); + + // Hero image modal state + const [heroImageDraft, setHeroImageDraft] = useState(null); + const [generatingHeroImage, setGeneratingHeroImage] = useState(false); + const [acceptingHeroImage, setAcceptingHeroImage] = useState(false); + // Pending image state (staging area for image uploads until save) const [pendingImage, setPendingImage] = useState(null); @@ -563,18 +571,20 @@ function EditView({ destination, editedData, setEditedData, onSave, onCancel, on })); }; - // Research with AI - fills all fields + // Research with AI v2 - multi-pass with draft approval const handleResearch = async () => { setResearching(true); setAiError(null); - setResearchSources(null); try { - const response = await fetch('/api/admin/ai/research', { + const response = await fetch('/api/admin/ai/research-v2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ destination: editedData }) + body: JSON.stringify({ + destination: editedData, + adminContext: editedData.research_context || '' + }) }); if (!response.ok) { @@ -582,22 +592,38 @@ function EditView({ destination, editedData, setEditedData, onSave, onCancel, on throw new Error(error.error || 'Research failed'); } - const data = await response.json(); - - // Update all fields that have data - setEditedData(prev => ({ - ...prev, - property_owner: data.property_owner || prev.property_owner, - primary_activities: data.primary_activities || prev.primary_activities, - surface: data.surface || prev.surface, - pets: data.pets || prev.pets, - brief_description: data.brief_description || prev.brief_description, - historical_description: data.historical_description || prev.historical_description - })); + const result = await response.json(); - // Store sources for display - if (data.sources && data.sources.length > 0) { - setResearchSources(data.sources); + // Show draft modal for approval instead of directly applying + setResearchDraft(result.data); + // Initialize all fields as accepted by default + const fields = ['era_id', 'property_owner', 'primary_activities', 'surface', 'pets', 'brief_description', 'historical_description']; + const initialStates = {}; + for (const field of fields) { + initialStates[field] = result.data[field] != null; + } + setDraftFieldStates(initialStates); + setShowDraftModal(true); + + // Auto-start hero image generation concurrently + if (destination?.id) { + setGeneratingHeroImage(true); + fetch('/api/admin/ai/generate-hero-image', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + poiId: destination.id, + poiName: editedData.name, + briefDescription: result.data.brief_description || editedData.brief_description, + historicalDescription: result.data.historical_description || editedData.historical_description, + era: result.data.era || editedData.era_name || '' + }) + }) + .then(res => res.ok ? res.json() : res.json().then(e => Promise.reject(new Error(e.error || 'Image generation failed')))) + .then(img => setHeroImageDraft(img)) + .catch(err => setAiError(`Hero image: ${err.message}`)) + .finally(() => setGeneratingHeroImage(false)); } } catch (err) { setAiError(err.message); @@ -606,6 +632,81 @@ function EditView({ destination, editedData, setEditedData, onSave, onCancel, on } }; + // Accept research draft — apply selected fields to editedData, optionally save hero image + const handleAcceptDraft = async () => { + const updates = {}; + if (draftFieldStates.era_id && researchDraft.era_id) updates.era_id = researchDraft.era_id; + if (draftFieldStates.property_owner && researchDraft.property_owner) updates.property_owner = researchDraft.property_owner; + if (draftFieldStates.primary_activities && researchDraft.primary_activities) updates.primary_activities = researchDraft.primary_activities; + if (draftFieldStates.surface && researchDraft.surface) updates.surface = researchDraft.surface; + if (draftFieldStates.pets && researchDraft.pets) updates.pets = researchDraft.pets; + if (draftFieldStates.brief_description && researchDraft.brief_description) updates.brief_description = researchDraft.brief_description; + if (draftFieldStates.historical_description && researchDraft.historical_description) updates.historical_description = researchDraft.historical_description; + + setEditedData(prev => ({ ...prev, ...updates })); + + // Save hero image if one was generated + if (heroImageDraft && destination?.id) { + try { + setAcceptingHeroImage(true); + const response = await fetch('/api/admin/ai/accept-hero-image', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + poiId: destination.id, + imageData: heroImageDraft.imageData, + mimeType: heroImageDraft.mimeType + }) + }); + + if (!response.ok) { + const error = await response.json(); + setAiError(`Hero image save failed: ${error.error}`); + } + } catch (err) { + setAiError(`Hero image save failed: ${err.message}`); + } finally { + setAcceptingHeroImage(false); + } + } + + setShowDraftModal(false); + setResearchDraft(null); + setHeroImageDraft(null); + }; + + // Generate hero image + const handleGenerateHeroImage = async () => { + setGeneratingHeroImage(true); + try { + const response = await fetch('/api/admin/ai/generate-hero-image', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + poiId: destination?.id, + poiName: editedData.name, + briefDescription: editedData.brief_description, + historicalDescription: editedData.historical_description, + era: editedData.era_name || researchDraft?.era || '' + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Image generation failed'); + } + + const result = await response.json(); + setHeroImageDraft(result); + } catch (err) { + setAiError(err.message); + } finally { + setGeneratingHeroImage(false); + } + }; + // Generate with the customized prompt const handleGenerate = async () => { setGenerating(true); @@ -707,17 +808,7 @@ function EditView({ destination, editedData, setEditedData, onSave, onCancel, on )} -
- - handleChange('name', e.target.value)} - placeholder="Enter POI name..." - /> -
- - {/* Research with AI button - fills all fields */} + {/* Research with AI — top of Info tab, right under image */}
- Searches the web to fill all fields below + Multi-pass research with draft approval
- {researchSources && ( -
- Sources: -
    - {researchSources.map((source, i) => ( -
  • - {source.startsWith('http') ? ( - {source} - ) : source} -
  • - ))} -
-
- )} +
+ +