Files
VisionScannerService/src/server.ts

177 lines
4.4 KiB
TypeScript
Raw Normal View History

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<string, unknown> = {
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}`);
});