import "dotenv/config"; import express, { Request, Response, NextFunction } from "express"; import multer from "multer"; import { config } from "./config"; import { scanShelfPhoto } from "./shelf"; import { scanPantryPhoto } from "./pantry"; import { extractProductInfo } from "./enrich"; import { getCount } from "./chroma"; const ALLOWED_MIMES = new Set([ "image/jpeg", "image/png", "image/webp", "image/gif", "image/heic", "image/heif", ]); const app = express(); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.maxFileSize, files: 1, }, fileFilter: (_req, file, cb) => { if (ALLOWED_MIMES.has(file.mimetype)) { cb(null, true); } else { cb(new Error("Invalid file type. Allowed: JPEG, PNG, WebP, GIF, HEIC, HEIF")); } }, }); // --- Health check --- app.get("/health", async (_req: Request, res: Response) => { const status: Record = { status: "ok", vision_model: config.visionAiModel, }; // Check vision model try { const visionUrl = config.visionAiUrl.replace("/v1/chat/completions", "/v1/models"); const resp = await fetch(visionUrl, { signal: AbortSignal.timeout(5000) }); status.vision = resp.ok ? "connected" : `status ${resp.status}`; } catch (err) { status.vision = `unreachable: ${err instanceof Error ? err.message : err}`; status.status = "degraded"; } // Check Ollama try { const resp = await fetch(`${config.ollamaHost}/api/tags`, { signal: AbortSignal.timeout(5000), }); status.ollama = resp.ok ? "connected" : `status ${resp.status}`; } catch (err) { status.ollama = `unreachable: ${err instanceof Error ? err.message : err}`; status.status = "degraded"; } // Check ChromaDB try { const count = await getCount(); status.chroma = "connected"; status.chroma_count = count; } catch (err) { status.chroma = `unreachable: ${err instanceof Error ? err.message : err}`; status.status = "degraded"; } res.json(status); }); // --- Scan endpoints --- app.post( "/scan/shelf", upload.single("image"), async (req: Request, res: Response, next: NextFunction) => { try { const file = req.file; if (!file) { res.status(400).json({ error: "No image provided" }); return; } const storeName = req.body?.store_name; if (!storeName) { res.status(400).json({ error: "store_name is required" }); return; } const result = await scanShelfPhoto(file.buffer, storeName); res.json(result); } catch (err) { next(err); } } ); app.post( "/scan/pantry", upload.single("image"), async (req: Request, res: Response, next: NextFunction) => { try { const file = req.file; if (!file) { res.status(400).json({ error: "No image provided" }); return; } const result = await scanPantryPhoto(file.buffer); res.json(result); } catch (err) { next(err); } } ); // --- Product enrichment --- app.post( "/enrich/product", upload.single("image"), async (req: Request, res: Response, next: NextFunction) => { try { const file = req.file; if (!file) { res.status(400).json({ error: "No image provided" }); return; } const result = await extractProductInfo(file.buffer); res.json(result); } catch (err) { next(err); } } ); // --- 404 --- app.use((_req: Request, res: Response) => { res.status(404).json({ error: "Not found" }); }); // --- Error handler --- app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { if (err instanceof multer.MulterError && err.code === "LIMIT_FILE_SIZE") { res.status(413).json({ error: `File too large. Maximum size is ${config.maxFileSize / 1024 / 1024}MB.` }); return; } if (err.message.startsWith("Invalid file type")) { res.status(400).json({ error: err.message }); return; } console.error("Scan error:", err.message); res.status(502).json({ error: "Failed to process image" }); }); // --- Start --- app.listen(config.port, "0.0.0.0", () => { console.log(`Vision Scanner Service listening on port ${config.port}`); console.log(`Vision AI: ${config.visionAiUrl} (model: ${config.visionAiModel})`); console.log(`Ollama: ${config.ollamaHost} (embed: ${config.ollamaEmbedModel})`); console.log(`ChromaDB: ${config.chromaHost}`); });