Initial commit: Vision scanner for shelf/pantry product extraction
This commit is contained in:
176
src/server.ts
Normal file
176
src/server.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
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}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user