Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions backend/src/ai/README.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions backend/src/ai/aiProvider.ts
Original file line number Diff line number Diff line change
@@ -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<AIResponse>;
suggestCategory(productText: string): Promise<AIResponse>;
explainPreview(context: PreviewContext): Promise<AIResponse>;
}

export class AIProviderError extends Error {
code: string;

constructor(code: string, message: string) {
super(message);
this.code = code;
}
}
43 changes: 43 additions & 0 deletions backend/src/ai/aiService.ts
Original file line number Diff line number Diff line change
@@ -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]
};
}
11 changes: 11 additions & 0 deletions backend/src/ai/gemma3nProvider.ts
Original file line number Diff line number Diff line change
@@ -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
});
}
106 changes: 106 additions & 0 deletions backend/src/ai/ollamaProvider.ts
Original file line number Diff line number Diff line change
@@ -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<AIResponse> {
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<AIResponse> {
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<AIResponse> {
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<AIResponse> {
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);
}
};
}
78 changes: 78 additions & 0 deletions backend/src/routes/ai.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>
) => {
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));
});
}
2 changes: 2 additions & 0 deletions backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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 () => {
Expand Down