177 lines
4.4 KiB
TypeScript
177 lines
4.4 KiB
TypeScript
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}`);
|
|
});
|
|
|