The admin panel had one shared "AI service connection" and three model
dropdowns (embedding, synthesis, image), but the image slot was pure
vestigial plumbing — no image-selection AI call exists anywhere. Remove
it, along with the shared connection panel, and give embedding/
clustering and article synthesis each their own independent host/port/
Test/Save connection in the Models tab, so they can point at different
Ollama installs. Threads an AiProviders { embedding, synthesis } bundle
through the pipeline/queue/scheduler layers wherever both are needed
together; functions that only ever call one or the other keep a plain
InferenceProvider argument.
Also, since nomic-embed already touches every article for clustering:
- Give non-synthesized (single-source/direct-published) articles tags
too, by matching their embedding against existing tags by cosine
similarity (tags.ts's scoreTagsBySimilarity) instead of running the
reasoning-heavy LLM tag-extraction step that was starving these
articles of tags in the first place.
- Dedupe resolveTagIds's output — two different LLM-proposed labels
can resolve to the same existing tag via embedding similarity, which
was producing literal duplicate tag ids on published articles.
- Add a "disable reasoning" (think:false) toggle for the synthesis
model, since Ollama's think parameter isn't respected uniformly
across reasoning models and CPU-only inference makes the hidden
reasoning pass expensive for no quality benefit on a rewrite task.
- Record real per-call throughput (prompt/gen tokens-per-second,
duration) from Ollama's own stats into a new synthesis_runs table,
surfaced as benchmark history in the admin Logs tab, for comparing
models on the actual production hardware.
150 lines
4.5 KiB
JavaScript
150 lines
4.5 KiB
JavaScript
// Mock backend — implements the public API surface from homefeed-data-schema.md
|
|
// so the frontend can be built and tested before the real backend exists.
|
|
// GET /api/feed?category=&geo=&eventId=&tag=&before=&limit=
|
|
// GET /api/article/:id
|
|
// GET /api/events
|
|
// GET /api/tags
|
|
// GET /api/categories
|
|
|
|
const express = require("express");
|
|
const cors = require("cors");
|
|
const { articles, tags, events } = require("./data");
|
|
const admin = require("./admin-data");
|
|
|
|
const app = express();
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
|
|
const PORT = process.env.PORT || 4000;
|
|
|
|
app.get("/api/feed", (req, res) => {
|
|
const { category, geo, eventId, tag, before, limit } = req.query;
|
|
let results = articles;
|
|
|
|
if (category) {
|
|
results = results.filter((a) => a.category.some((c) => c.toLowerCase() === String(category).toLowerCase()));
|
|
}
|
|
if (geo) {
|
|
results = results.filter((a) => a.geo === geo);
|
|
}
|
|
if (eventId) {
|
|
results = results.filter((a) => a.eventId === eventId);
|
|
}
|
|
if (tag) {
|
|
results = results.filter((a) => a.tags.includes(tag));
|
|
}
|
|
|
|
results = [...results].sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
|
|
|
if (before) {
|
|
const cursor = new Date(before).getTime();
|
|
results = results.filter((a) => new Date(a.publishedAt).getTime() < cursor);
|
|
}
|
|
|
|
const pageSize = Math.min(Number(limit) || 15, 50);
|
|
res.json(results.slice(0, pageSize));
|
|
});
|
|
|
|
app.get("/api/article/:id", (req, res) => {
|
|
const article = articles.find((a) => a.id === req.params.id);
|
|
if (!article) return res.status(404).json({ error: "not found" });
|
|
res.json(article);
|
|
});
|
|
|
|
app.get("/api/tags", (req, res) => {
|
|
res.json(tags.filter((t) => t.status === "active"));
|
|
});
|
|
|
|
app.get("/api/events", (req, res) => {
|
|
res.json(events);
|
|
});
|
|
|
|
app.get("/api/categories", (req, res) => {
|
|
const { categoryPriority } = admin.getSettings();
|
|
res.json([...categoryPriority].sort((a, b) => a.priorityRank - b.priorityRank));
|
|
});
|
|
|
|
// --- Admin API ---
|
|
// No auth in the mock — the real backend enforces session auth on all /api/admin/* routes.
|
|
|
|
app.get("/api/admin/settings", (req, res) => {
|
|
res.json(admin.getSettings());
|
|
});
|
|
|
|
app.patch("/api/admin/settings", (req, res) => {
|
|
res.json(admin.updateSettings(req.body));
|
|
});
|
|
|
|
app.get("/api/admin/sources", (req, res) => {
|
|
res.json(admin.getSources());
|
|
});
|
|
|
|
app.post("/api/admin/sources", (req, res) => {
|
|
res.status(201).json(admin.addSource(req.body));
|
|
});
|
|
|
|
app.patch("/api/admin/sources/:id", (req, res) => {
|
|
const updated = admin.updateSource(req.params.id, req.body);
|
|
if (!updated) return res.status(404).json({ error: "not found" });
|
|
res.json(updated);
|
|
});
|
|
|
|
app.delete("/api/admin/sources/:id", (req, res) => {
|
|
admin.deleteSource(req.params.id);
|
|
res.status(204).end();
|
|
});
|
|
|
|
app.get("/api/admin/events", (req, res) => {
|
|
res.json(admin.getEvents());
|
|
});
|
|
|
|
app.post("/api/admin/events", (req, res) => {
|
|
res.status(201).json(admin.addEvent(req.body));
|
|
});
|
|
|
|
app.patch("/api/admin/events/:id", (req, res) => {
|
|
const updated = admin.updateEvent(req.params.id, req.body);
|
|
if (!updated) return res.status(404).json({ error: "not found" });
|
|
res.json(updated);
|
|
});
|
|
|
|
app.delete("/api/admin/events/:id", (req, res) => {
|
|
admin.deleteEvent(req.params.id);
|
|
res.status(204).end();
|
|
});
|
|
|
|
app.get("/api/admin/models", (req, res) => {
|
|
res.json(admin.getModels());
|
|
});
|
|
|
|
app.post("/api/admin/categories", (req, res) => {
|
|
const { name } = req.body;
|
|
if (!name || !name.trim()) return res.status(400).json({ error: "name required" });
|
|
res.status(201).json(admin.createCategory(name.trim()));
|
|
});
|
|
|
|
app.delete("/api/admin/categories/:id", (req, res) => {
|
|
admin.deleteCategory(req.params.id);
|
|
res.status(204).end();
|
|
});
|
|
|
|
app.get("/api/admin/logs", (req, res) => {
|
|
// The mock backend doesn't run a scheduler/pipeline, so there's nothing to log —
|
|
// returns an empty list rather than 404ing, so the Logs tab renders its empty state
|
|
// instead of erroring when pointed at the mock backend.
|
|
res.json([]);
|
|
});
|
|
|
|
app.get("/api/admin/ai-status", (req, res) => {
|
|
// Simulates pinging both independently-configured Ollama hosts
|
|
const { embeddingServiceHost, embeddingServicePort, synthesisServiceHost, synthesisServicePort } = admin.getSettings();
|
|
res.json({
|
|
embedding: { connected: true, host: embeddingServiceHost, port: embeddingServicePort, ramGB: 48, gpu: "none reported" },
|
|
synthesis: { connected: true, host: synthesisServiceHost, port: synthesisServicePort, ramGB: 48, gpu: "none reported" }
|
|
});
|
|
});
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`Mock backend running at http://localhost:${PORT}`);
|
|
});
|