feat: Add distillery enrichment cache
Caches AI enrichment results per distillery to save API calls: - New table: enrichment_cache (distillery, tags, hit_count) - New service: cache-enrichment.ts (get, save, increment, stats) - enrich-data.ts checks cache before AI query - Saves to cache after successful AI response - Returns cached: true/false flag for transparency Benefits: - 0 API cost for repeated distillery scans - Near-instant response for cached distilleries - Shared across all users
This commit is contained in:
32
enrichment_cache_migration.sql
Normal file
32
enrichment_cache_migration.sql
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
-- Enrichment Cache Table
|
||||||
|
-- Caches AI enrichment results per distillery to save API calls
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS enrichment_cache (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
distillery TEXT NOT NULL UNIQUE,
|
||||||
|
suggested_tags TEXT[],
|
||||||
|
suggested_custom_tags TEXT[],
|
||||||
|
search_string TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
hit_count INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for fast lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_enrichment_cache_distillery
|
||||||
|
ON enrichment_cache(distillery);
|
||||||
|
|
||||||
|
-- RLS: Allow all authenticated users to read (it's shared cache)
|
||||||
|
ALTER TABLE enrichment_cache ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "enrichment_cache_select" ON enrichment_cache;
|
||||||
|
CREATE POLICY "enrichment_cache_select" ON enrichment_cache
|
||||||
|
FOR SELECT TO authenticated USING (true);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "enrichment_cache_insert" ON enrichment_cache;
|
||||||
|
CREATE POLICY "enrichment_cache_insert" ON enrichment_cache
|
||||||
|
FOR INSERT TO authenticated WITH CHECK (true);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "enrichment_cache_update" ON enrichment_cache;
|
||||||
|
CREATE POLICY "enrichment_cache_update" ON enrichment_cache
|
||||||
|
FOR UPDATE TO authenticated USING (true);
|
||||||
@@ -6,6 +6,7 @@ import { trackApiUsage } from '@/services/track-api-usage';
|
|||||||
import { deductCredits } from '@/services/credit-service';
|
import { deductCredits } from '@/services/credit-service';
|
||||||
import { getAllSystemTags } from '@/services/tags';
|
import { getAllSystemTags } from '@/services/tags';
|
||||||
import { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } from '@/lib/openrouter';
|
import { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } from '@/lib/openrouter';
|
||||||
|
import { getEnrichmentCache, saveEnrichmentCache, incrementCacheHit } from '@/services/cache-enrichment';
|
||||||
|
|
||||||
// Native Schema Definition for Enrichment Data
|
// Native Schema Definition for Enrichment Data
|
||||||
const enrichmentSchema = {
|
const enrichmentSchema = {
|
||||||
@@ -146,6 +147,31 @@ export async function enrichData(name: string, distillery: string, availableTags
|
|||||||
return { success: false, error: 'OPENROUTER_API_KEY is not configured.' };
|
return { success: false, error: 'OPENROUTER_API_KEY is not configured.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// CACHE CHECK - Return cached data if available
|
||||||
|
// ========================================
|
||||||
|
if (distillery?.trim()) {
|
||||||
|
try {
|
||||||
|
const cached = await getEnrichmentCache(distillery);
|
||||||
|
if (cached) {
|
||||||
|
console.log(`[EnrichData] ✅ CACHE HIT for: ${distillery}`);
|
||||||
|
// Fire and forget: increment hit counter
|
||||||
|
incrementCacheHit(distillery).catch(() => { });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: cached,
|
||||||
|
cached: true,
|
||||||
|
provider: 'cache',
|
||||||
|
perf: { apiDuration: 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
console.log(`[EnrichData] ❌ Cache MISS for: ${distillery}`);
|
||||||
|
} catch (cacheError) {
|
||||||
|
console.warn('[EnrichData] Cache lookup failed:', cacheError);
|
||||||
|
// Continue with AI query
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let supabase;
|
let supabase;
|
||||||
try {
|
try {
|
||||||
let tagsToUse = availableTags;
|
let tagsToUse = availableTags;
|
||||||
@@ -184,6 +210,15 @@ Instructions:
|
|||||||
|
|
||||||
console.log('[EnrichData] Response:', result.data);
|
console.log('[EnrichData] Response:', result.data);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// SAVE TO CACHE - Store for future lookups
|
||||||
|
// ========================================
|
||||||
|
if (distillery?.trim()) {
|
||||||
|
saveEnrichmentCache(distillery, result.data).catch((err) => {
|
||||||
|
console.warn('[EnrichData] Cache save failed:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Track usage
|
// Track usage
|
||||||
await trackApiUsage({
|
await trackApiUsage({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
@@ -197,6 +232,7 @@ Instructions:
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: result.data,
|
data: result.data,
|
||||||
|
cached: false,
|
||||||
provider,
|
provider,
|
||||||
perf: {
|
perf: {
|
||||||
apiDuration: result.apiTime
|
apiDuration: result.apiTime
|
||||||
|
|||||||
108
src/services/cache-enrichment.ts
Normal file
108
src/services/cache-enrichment.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
|
||||||
|
interface EnrichmentCacheData {
|
||||||
|
suggested_tags: string[] | null;
|
||||||
|
suggested_custom_tags: string[] | null;
|
||||||
|
search_string: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached enrichment data for a distillery
|
||||||
|
*/
|
||||||
|
export async function getEnrichmentCache(distillery: string): Promise<EnrichmentCacheData | null> {
|
||||||
|
if (!distillery?.trim()) return null;
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const normalizedDistillery = distillery.trim().toLowerCase();
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('enrichment_cache')
|
||||||
|
.select('suggested_tags, suggested_custom_tags, search_string')
|
||||||
|
.eq('distillery', normalizedDistillery)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save enrichment data to cache
|
||||||
|
*/
|
||||||
|
export async function saveEnrichmentCache(
|
||||||
|
distillery: string,
|
||||||
|
enrichmentData: EnrichmentCacheData
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!distillery?.trim()) return false;
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const normalizedDistillery = distillery.trim().toLowerCase();
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('enrichment_cache')
|
||||||
|
.upsert({
|
||||||
|
distillery: normalizedDistillery,
|
||||||
|
suggested_tags: enrichmentData.suggested_tags,
|
||||||
|
suggested_custom_tags: enrichmentData.suggested_custom_tags,
|
||||||
|
search_string: enrichmentData.search_string,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}, {
|
||||||
|
onConflict: 'distillery'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('[EnrichmentCache] Save error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[EnrichmentCache] Saved cache for: ${normalizedDistillery}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment cache hit counter
|
||||||
|
*/
|
||||||
|
export async function incrementCacheHit(distillery: string): Promise<void> {
|
||||||
|
if (!distillery?.trim()) return;
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const normalizedDistillery = distillery.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Simple increment via update - no RPC needed
|
||||||
|
const { data: current } = await supabase
|
||||||
|
.from('enrichment_cache')
|
||||||
|
.select('hit_count')
|
||||||
|
.eq('distillery', normalizedDistillery)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
await supabase
|
||||||
|
.from('enrichment_cache')
|
||||||
|
.update({ hit_count: (current.hit_count || 0) + 1 })
|
||||||
|
.eq('distillery', normalizedDistillery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics
|
||||||
|
*/
|
||||||
|
export async function getEnrichmentCacheStats() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('enrichment_cache')
|
||||||
|
.select('distillery, hit_count, created_at')
|
||||||
|
.order('hit_count', { ascending: false })
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('[EnrichmentCache] Stats error:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user