/** * Hybrid Backfill Workflow - AI + ISBNdb Integration * * ARCHITECTURE: * 1. AI Provider (Gemini or Grok) → Generate book metadata (title, author, publisher, format, year) * 2. ISBNdb API → Resolve authoritative ISBNs via title/author search * 3. Deduplication → Filter already-enriched books * 4. Enrichment → Update database with complete metadata * * BENEFITS: * - 95%+ ISBN accuracy (ISBNdb authoritative source) * - High-quality metadata from AI providers (cultural significance, publisher info) * - Automatic fallback: Gemini → Grok (if quota exhausted or errors) * - Reduced batch size (20 books) for better AI accuracy * - Fuzzy matching handles title variations * * @module services/hybrid-backfill */ import type { Env } from '../env.js'; import type { Logger } from '../../lib/logger.js'; import { batchResolveISBNs, type ISBNResolutionResult } from './isbn-resolution.js'; import type { ResolvedCandidate } from './types/backfill.js'; import { getGlobalRegistry } from '../../lib/external-services/provider-registry.js'; import { BookGenerationOrchestrator } from '../../lib/external-services/orchestrators/book-generation-orchestrator.js'; import { createServiceContext } from '../../lib/external-services/service-context.js'; import { resolvePrompt } from '../../lib/ai/book-generation-prompts.js'; import type { QuotaManager } from './quota-manager.js'; // ================================================================================= // Module-Level Orchestrator (Cold Start Optimization) // ================================================================================= /** * Global book generation orchestrator initialized once and reused across requests. * This reduces per-request overhead by ~5-10ms (no repeated allocations). * * CONCURRENT MODE: Both Gemini and Grok run in parallel for maximum diversity. * - 0% overlap observed in testing (completely different book selections) * - Results are deduplicated by title similarity (80% threshold) * - Succeeds if ANY provider works (resilient to individual failures) * * Follows same pattern as providerRegistry in queue-handlers.ts */ const bookGenOrchestrator = new BookGenerationOrchestrator(getGlobalRegistry(), { enableLogging: true, providerTimeoutMs: 60000, // 60 seconds for AI generation providerPriority: ['gemini', 'xai'], // Provider order (not used in concurrent mode) stopOnFirstSuccess: false, // Use concurrent mode concurrentExecution: true, // Run both providers in parallel deduplicationThreshold: 0.6, // 60% title similarity = duplicate (aligned with database fuzzy matching) }); // ================================================================================= // Types // ================================================================================= export interface GenerationStats { model_used: string; total_books: number; books_with_publisher: number; books_with_significance: number; format_breakdown: { Hardcover: number; Paperback: number; eBook: number; Audiobook: number; Unknown: number; }; duration_ms: number; failed_batches?: number; failed_batch_errors?: Array<{ batch: number; error: string }>; } export interface HybridBackfillStats extends GenerationStats { // AI provider stats (inherited from GenerationStats) ai_provider_used: string; // 'gemini' or 'xai' // ISBNdb resolution stats isbn_resolution: { total_attempted: number; resolved: number; high_confidence: number; medium_confidence: number; low_confidence: number; not_found: number; resolution_rate: number; // Percentage }; // API call tracking api_calls: { ai_generation: number; // Gemini or Grok isbndb: number; total: number; }; } export interface HybridBackfillResult { candidates: ResolvedCandidate[]; stats: HybridBackfillStats; resolutions: ISBNResolutionResult[]; } // ================================================================================= // Main Hybrid Workflow // ================================================================================= /** * Generate curated book list with hybrid AI + ISBNdb workflow * * WORKFLOW: * 1. AI Provider (Gemini or Grok via orchestrator) generates N high-quality book metadata records * 2. ISBNdb resolves authoritative ISBNs via title/author fuzzy search * 3. Returns enriched candidates with confidence scores * * QUOTA IMPACT: * - AI Provider: 1 API call (N books @ temperature=0.1) * - ISBNdb: N API calls (1 per book, rate-limited to 3 req/sec) * * CONCURRENT MODE: * - Both Gemini and Grok run in parallel for maximum diversity * - Results deduplicated by 60% title similarity threshold * - Succeeds if ANY provider works (resilient to individual failures) * * @param year - Year to generate list for * @param month - Month to generate list for (1-12) * @param env - Environment with API keys * @param logger - Logger instance * @param batchSize - Number of books to generate (default: 20, can test 50) * @param promptVariant - Optional prompt variant name (e.g., "diversity-emphasis", "baseline") * @param modelOverride - Optional model override (for testing) * @param quotaManager - Optional quota tracking manager * @returns Hybrid workflow result with ISBNs resolved */ export async function generateHybridBackfillList( year: number, month: number, env: Env, logger: Logger, batchSize: number = 20, promptVariant?: string, modelOverride?: string, quotaManager?: QuotaManager ): Promise { const startTime = Date.now(); logger.info('[HybridBackfill] Starting hybrid workflow', { year, month, prompt_variant: promptVariant || 'baseline', model_override: modelOverride || 'default', }); // Step 1: Generate metadata from AI Provider (Gemini or Grok) // Use module-level orchestrator for cold start optimization // IMPORTANT: Pass 60s timeout to match orchestrator configuration (fixes 10s default timeout issue) const context = createServiceContext(env, logger, { timeoutMs: 60000 }); // Resolve prompt from shared microservice (security: only accepts registered variants) const prompt = resolvePrompt(promptVariant, year, month, batchSize); const generatedBooks = await bookGenOrchestrator.generateBooks(prompt, batchSize, context); if (generatedBooks.length === 0) { logger.warn('[HybridBackfill] No books generated from AI providers', { year, month }); return { candidates: [], stats: { model_used: 'none', total_books: 0, books_with_publisher: 0, books_with_significance: 0, format_breakdown: { Hardcover: 0, Paperback: 0, eBook: 0, Audiobook: 0, Unknown: 0, }, duration_ms: Date.now() - startTime, ai_provider_used: 'none', isbn_resolution: { total_attempted: 0, resolved: 0, high_confidence: 0, medium_confidence: 0, low_confidence: 0, not_found: 0, resolution_rate: 0, }, api_calls: { ai_generation: 0, isbndb: 0, total: 0, }, }, resolutions: [], }; } // Convert GeneratedBook to metadataCandidates format const metadataCandidates = generatedBooks.map(book => { // Parse year with NaN validation const parsedYear = parseInt(book.publishDate || ''); const validYear = !isNaN(parsedYear) ? parsedYear : year; // Fallback to input year if invalid return { title: book.title, author: book.author, authors: [book.author], publisher: book.publisher, format: 'Unknown', // Not available from GeneratedBook interface year: validYear, significance: book.description, }; }); logger.info('[HybridBackfill] AI generation complete', { books_generated: metadataCandidates.length, provider: generatedBooks[0]?.source || 'unknown', }); // Step 2: Resolve ISBNs via ISBNdb title/author search const apiKey = await env.ISBNDB_API_KEY.get(); if (!apiKey) { throw new Error('ISBNDB_API_KEY not configured'); } const booksMetadata = metadataCandidates.map(candidate => ({ title: candidate.title!, // Required from Gemini (validated during generation) author: (candidate.authors?.[0] || candidate.author)!, // Required from Gemini })); logger.info('[HybridBackfill] Starting ISBN resolution', { books_to_resolve: booksMetadata.length, }); // Pass env to enable fallback resolvers when ISBNdb quota exhausted const resolutions = await batchResolveISBNs(booksMetadata, apiKey, logger, quotaManager, env); // Step 3: Merge AI metadata with resolved ISBNs // IMPORTANT: Include candidates WITHOUT ISBNs for staged enrichment // When ISBNdb quota exhausted, we still want to save AI metadata const enrichedCandidates: ResolvedCandidate[] = []; for (let i = 0; i < metadataCandidates.length; i++) { const metadata = metadataCandidates[i]; const resolution = resolutions[i]; // Always add candidate - even without ISBN (for staged enrichment) const resolved: ResolvedCandidate = { // AI-generated metadata fields title: metadata.title!, // Required from AI (validated during generation) author: (metadata.authors?.[0] || metadata.author)!, // From AI (authors array or author field) authors: metadata.authors, publisher: metadata.publisher, format: metadata.format, year: metadata.year, significance: metadata.significance, // Resolution fields isbn: resolution.isbn || undefined, // undefined if ISBNdb failed (quota exhausted) resolution_confidence: resolution.confidence, // Direct assignment - types already match resolution_source: resolution.isbn ? 'isbndb' : undefined, }; enrichedCandidates.push(resolved); if (!resolution.isbn) { logger.debug('[HybridBackfill] ISBN not resolved - saving AI metadata only', { title: metadata.title, author: metadata.authors?.[0] || metadata.author, confidence: resolution.confidence, }); } } // Step 4: Calculate resolution stats const resolutionStats = { total_attempted: resolutions.length, resolved: resolutions.filter(r => r.isbn !== null).length, high_confidence: resolutions.filter(r => r.confidence === 'high').length, medium_confidence: resolutions.filter(r => r.confidence === 'medium').length, low_confidence: resolutions.filter(r => r.confidence === 'low').length, not_found: resolutions.filter(r => r.confidence === 'not_found').length, resolution_rate: 0, }; resolutionStats.resolution_rate = resolutionStats.total_attempted > 0 ? (resolutionStats.resolved / resolutionStats.total_attempted) * 100 : 0; const duration = Date.now() - startTime; // Calculate generation stats const aiProviderUsed = generatedBooks[0]?.source || 'unknown'; const booksWithPublisher = metadataCandidates.filter(b => b.publisher).length; const booksWithSignificance = metadataCandidates.filter(b => b.significance).length; const formatCounts = metadataCandidates.reduce((acc, b) => { const fmt = b.format || 'Unknown'; acc[fmt] = (acc[fmt] || 0) + 1; return acc; }, {} as Record); logger.info('[HybridBackfill] Workflow complete', { year, month, ai_books: metadataCandidates.length, ai_provider: aiProviderUsed, isbns_resolved: enrichedCandidates.length, resolution_rate: `${resolutionStats.resolution_rate.toFixed(1)}%`, high_confidence: resolutionStats.high_confidence, duration_ms: duration, }); return { candidates: enrichedCandidates, stats: { model_used: modelOverride || 'default', total_books: metadataCandidates.length, books_with_publisher: booksWithPublisher, books_with_significance: booksWithSignificance, format_breakdown: { Hardcover: formatCounts['Hardcover'] || 0, Paperback: formatCounts['Paperback'] || 0, eBook: formatCounts['eBook'] || 0, Audiobook: formatCounts['Audiobook'] || 0, Unknown: formatCounts['Unknown'] || 0, }, duration_ms: duration, ai_provider_used: aiProviderUsed, isbn_resolution: resolutionStats, api_calls: { ai_generation: 1, isbndb: resolutions.length, total: 1 + resolutions.length, }, }, resolutions, }; }