diff --git a/backend/src/ai/README.md b/backend/src/ai/README.md new file mode 100644 index 0000000..6c2ef60 --- /dev/null +++ b/backend/src/ai/README.md @@ -0,0 +1,26 @@ +# AI Providers (Backend) + +This folder contains optional AI integrations for PantryOps. AI features are **disabled by default**. + +## Environment Variables + +- `AI_ENABLED`: set to `true` to enable AI endpoints. +- `AI_PROVIDER`: provider identifier (defaults to `gemma3n:e4b`). +- `OLLAMA_BASE_URL`: Ollama API base URL (defaults to `http://localhost:11434`). +- `OLLAMA_TIMEOUT_MS`: timeout in milliseconds (defaults to `30000`). +- `OLLAMA_MODEL`: override the model name (defaults to `gemma3n:e4b`). + +## Available Providers + +- `gemma3n:e4b` via Ollama (default) +- any other provider name (treated as an Ollama model name) + +## Endpoints + +When `AI_ENABLED=true`: + +- `POST /api/ai/recipe-draft` +- `POST /api/ai/category-suggest` +- `POST /api/ai/explain-preview` + +Each endpoint validates input and returns a safe fallback response if the provider fails. diff --git a/backend/src/ai/aiProvider.ts b/backend/src/ai/aiProvider.ts new file mode 100644 index 0000000..3ddaaad --- /dev/null +++ b/backend/src/ai/aiProvider.ts @@ -0,0 +1,32 @@ +export interface AIResponse { + provider: string; + model: string; + content: string; + warnings?: string[]; + raw?: unknown; +} + +export interface PreviewContext { + summary: string; + details?: string; + ingredients?: string[]; + servings?: number; + locale?: string; +} + +export interface AIProvider { + name: string; + model: string; + generateRecipeDraft(prompt: string): Promise; + suggestCategory(productText: string): Promise; + explainPreview(context: PreviewContext): Promise; +} + +export class AIProviderError extends Error { + code: string; + + constructor(code: string, message: string) { + super(message); + this.code = code; + } +} diff --git a/backend/src/ai/aiService.ts b/backend/src/ai/aiService.ts new file mode 100644 index 0000000..2bf20d7 --- /dev/null +++ b/backend/src/ai/aiService.ts @@ -0,0 +1,43 @@ +import { AIProvider, AIProviderError } from './aiProvider'; +import { createGemma3nProvider } from './gemma3nProvider'; +import { createOllamaProvider } from './ollamaProvider'; + +export type AIService = { + enabled: boolean; + provider: AIProvider | null; +}; + +const DEFAULT_PROVIDER = 'gemma3n:e4b'; + +export function getAIService(): AIService { + const enabled = (process.env.AI_ENABLED ?? 'false').toLowerCase() === 'true'; + + if (!enabled) { + return { enabled, provider: null }; + } + + const providerName = process.env.AI_PROVIDER ?? DEFAULT_PROVIDER; + + if (providerName === 'gemma3n:e4b') { + return { enabled, provider: createGemma3nProvider() }; + } + + return { + enabled, + provider: createOllamaProvider({ + name: providerName, + model: providerName + }) + }; +} + +export function buildFallbackResponse(providerName: string, model: string, error: unknown) { + const warning = error instanceof AIProviderError ? error.code : 'AI_PROVIDER_FAILED'; + + return { + provider: providerName, + model, + content: 'AI provider unavailable. Please try again later.', + warnings: [warning] + }; +} diff --git a/backend/src/ai/gemma3nProvider.ts b/backend/src/ai/gemma3nProvider.ts new file mode 100644 index 0000000..3218bcc --- /dev/null +++ b/backend/src/ai/gemma3nProvider.ts @@ -0,0 +1,11 @@ +import { AIProvider } from './aiProvider'; +import { createOllamaProvider } from './ollamaProvider'; + +const GEMMA3N_MODEL = 'gemma3n:e4b'; + +export function createGemma3nProvider(): AIProvider { + return createOllamaProvider({ + name: 'gemma3n:e4b', + model: process.env.OLLAMA_MODEL ?? GEMMA3N_MODEL + }); +} diff --git a/backend/src/ai/ollamaProvider.ts b/backend/src/ai/ollamaProvider.ts new file mode 100644 index 0000000..bc03de8 --- /dev/null +++ b/backend/src/ai/ollamaProvider.ts @@ -0,0 +1,106 @@ +import { AIProvider, AIProviderError, AIResponse, PreviewContext } from './aiProvider'; + +type OllamaGenerateResponse = { + response: string; + model: string; +}; + +type OllamaProviderOptions = { + name?: string; + model: string; + baseUrl?: string; + timeoutMs?: number; +}; + +const DEFAULT_BASE_URL = 'http://localhost:11434'; +const DEFAULT_TIMEOUT_MS = 30000; + +async function callOllama(prompt: string, options: OllamaProviderOptions): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS); + const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; + + try { + const response = await fetch(`${baseUrl}/api/generate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: options.model, + prompt, + stream: false + }), + signal: controller.signal + }); + + if (!response.ok) { + throw new AIProviderError('OLLAMA_REQUEST_FAILED', `Ollama request failed with status ${response.status}`); + } + + const data = (await response.json()) as OllamaGenerateResponse; + + return { + provider: options.name ?? 'ollama', + model: data.model ?? options.model, + content: data.response?.trim() ?? '', + raw: data + }; + } catch (error: any) { + if (error?.name === 'AbortError') { + throw new AIProviderError('OLLAMA_TIMEOUT', 'Ollama request timed out'); + } + if (error instanceof AIProviderError) { + throw error; + } + throw new AIProviderError('OLLAMA_UNKNOWN_ERROR', error?.message ?? 'Unknown Ollama error'); + } finally { + clearTimeout(timeout); + } +} + +export function createOllamaProvider(options: OllamaProviderOptions): AIProvider { + const baseOptions = { + name: options.name ?? 'ollama', + model: options.model, + baseUrl: options.baseUrl ?? process.env.OLLAMA_BASE_URL ?? DEFAULT_BASE_URL, + timeoutMs: options.timeoutMs ?? Number(process.env.OLLAMA_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS + }; + + return { + name: baseOptions.name, + model: baseOptions.model, + async generateRecipeDraft(prompt: string): Promise { + const fullPrompt = `You are PantryOps AI. Draft a universal recipe with ingredient categories and amounts. +Requirements: +- Use ingredient categories, not specific products. +- Keep it concise and grounded in the user prompt. +- Return plain text. + +User request: +${prompt}`; + return callOllama(fullPrompt, baseOptions); + }, + async suggestCategory(productText: string): Promise { + const fullPrompt = `You are PantryOps AI. Suggest a single ingredient category. +Requirements: +- Respond with a single category name. +- Grounded only on the input. + +Product description: +${productText}`; + return callOllama(fullPrompt, baseOptions); + }, + async explainPreview(context: PreviewContext): Promise { + const fullPrompt = `You are PantryOps AI. Explain the preview in human-friendly terms. +Requirements: +- Grounded only on the provided context. +- Keep it concise. +- Do not invent data. + +Preview context (JSON): +${JSON.stringify(context, null, 2)}`; + return callOllama(fullPrompt, baseOptions); + } + }; +} diff --git a/backend/src/routes/ai.ts b/backend/src/routes/ai.ts new file mode 100644 index 0000000..d6b904b --- /dev/null +++ b/backend/src/routes/ai.ts @@ -0,0 +1,78 @@ +import { FastifyInstance } from 'fastify'; +import z from 'zod'; +import { buildFallbackResponse, getAIService } from '../ai/aiService'; +import { AIProvider, PreviewContext } from '../ai/aiProvider'; + +const recipeDraftSchema = z.object({ + prompt: z.string().min(3) +}); + +const categorySuggestSchema = z.object({ + productText: z.string().min(2) +}); + +const previewContextSchema = z.object({ + summary: z.string().min(3), + details: z.string().optional(), + ingredients: z.array(z.string()).optional(), + servings: z.number().int().positive().optional(), + locale: z.string().optional() +}); + +export async function aiRoutes(app: FastifyInstance) { + const aiService = getAIService(); + + const ensureEnabled = (reply: any): reply is { status: (code: number) => any } => { + if (!aiService.enabled || !aiService.provider) { + reply.status(503).send({ + error: 'AI_DISABLED', + message: 'AI support is disabled on this server.' + }); + return false; + } + return true; + }; + + const handleRequest = async ( + provider: AIProvider, + reply: any, + handler: () => Promise + ) => { + try { + const result = await handler(); + reply.send(result); + } catch (error) { + reply.status(502).send(buildFallbackResponse(provider.name, provider.model, error)); + } + }; + + app.post('/recipe-draft', { + schema: { body: recipeDraftSchema } + }, async (req, reply) => { + if (!ensureEnabled(reply)) return; + const { prompt } = req.body as { prompt: string }; + const provider = aiService.provider!; + + await handleRequest(provider, reply, () => provider.generateRecipeDraft(prompt)); + }); + + app.post('/category-suggest', { + schema: { body: categorySuggestSchema } + }, async (req, reply) => { + if (!ensureEnabled(reply)) return; + const { productText } = req.body as { productText: string }; + const provider = aiService.provider!; + + await handleRequest(provider, reply, () => provider.suggestCategory(productText)); + }); + + app.post('/explain-preview', { + schema: { body: z.object({ context: previewContextSchema }) } + }, async (req, reply) => { + if (!ensureEnabled(reply)) return; + const { context } = req.body as { context: PreviewContext }; + const provider = aiService.provider!; + + await handleRequest(provider, reply, () => provider.explainPreview(context)); + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 320176e..02b6890 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -12,6 +12,7 @@ import { suggestionRoutes } from './routes/suggestions'; import { categoryRoutes } from './routes/categories'; import { onboardingRoutes } from './routes/onboarding'; import { gamificationRoutes } from './routes/gamification'; +import { aiRoutes } from './routes/ai'; import { seedGlobalData } from './services/seedService'; const app = Fastify({ @@ -54,6 +55,7 @@ app.register(suggestionRoutes, { prefix: '/api/suggestions' }); app.register(categoryRoutes, { prefix: '/api/categories' }); app.register(onboardingRoutes, { prefix: '/api/onboarding' }); app.register(gamificationRoutes, { prefix: '/api/gamification' }); +app.register(aiRoutes, { prefix: '/api/ai' }); // Start server const start = async () => {