Files
Salastil 7e2b8cd6cc Split AI connection into per-model-type embedding/synthesis servers
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.
2026-08-14 14:30:30 -04:00

157 lines
4.5 KiB
JavaScript

// In-memory admin state for the mock backend. Mirrors GlobalSettings / Source / TrackedEvent
// from homefeed-data-schema.md. Real backend replaces this with SQLite-backed storage.
let settings = {
mergeStrictness: 3,
holdBeforePublishMinutes: 30,
tagDedupThreshold: 0.82,
tagExpiryDays: 21,
followUpMinHoursSinceLast: 6,
followUpMinNewSources: 2,
embeddingServiceHost: "http://10.0.0.14",
embeddingServicePort: 11434,
synthesisServiceHost: "http://10.0.0.14",
synthesisServicePort: 11434,
selectedModels: {
embedding: "nomic-embed-text-v1.5",
synthesis: "qwen2.5:7b-instruct-q4_K_M"
},
retention: {
publishedArticleMaxAgeDays: 7,
rawItemMaxAgeDays: 7,
storageCapEnabled: true,
storageCapValue: 500,
storageCapUnit: "GB",
storageUsedMB: 214
},
categoryPriority: [
{ id: "cat-top", name: "Top stories", priorityRank: 1, isDefault: true },
{ id: "cat-local", name: "Local", priorityRank: 2, isDefault: true },
{ id: "cat-world", name: "World", priorityRank: 3, isDefault: true },
{ id: "cat-business", name: "Business", priorityRank: 4, isDefault: true },
{ id: "cat-tech", name: "Tech", priorityRank: 5, isDefault: true },
{ id: "cat-culture", name: "Culture", priorityRank: 6, isDefault: true }
]
};
let sources = [
{
id: "src-1",
name: "Reuters World",
type: "rss",
category: ["World"],
url: "https://reuters.com/world/rss",
pollIntervalMinutes: 15,
enabled: true,
lastPolledAt: new Date(Date.now() - 6 * 60000).toISOString(),
lastError: null
},
{
id: "src-2",
name: "Middle East Watch",
type: "telegram",
category: ["Iran war (event)"],
url: "@meast_watch",
pollIntervalMinutes: 5,
enabled: true,
lastPolledAt: new Date(Date.now() - 2 * 60000).toISOString(),
lastError: null
},
{
id: "src-3",
name: "6ABC Philadelphia",
type: "rss",
category: ["Local: Philadelphia"],
url: "https://6abc.com/feed",
pollIntervalMinutes: 10,
enabled: true,
lastPolledAt: new Date(Date.now() - 4 * 60000).toISOString(),
lastError: null
},
{
id: "src-4",
name: "PennDOT Alerts",
type: "rss",
category: ["Local: Philadelphia"],
url: "https://penndot.pa.gov/alerts/feed",
pollIntervalMinutes: 10,
enabled: true,
lastPolledAt: new Date(Date.now() - 40 * 60000).toISOString(),
lastError: "502 Bad Gateway"
}
];
let events = [
{
id: "evt-iran",
name: "Iran war",
description: "Ongoing conflict coverage, sourced primarily from Telegram channels for speed.",
sourceIds: ["src-2"],
recapIntervalHours: 24,
active: true,
isSpillover: false,
retentionOverrideDays: null
},
{
id: "evt-fed",
name: "Fed rate decisions",
description: "",
sourceIds: ["src-1"],
recapIntervalHours: 1,
active: true,
isSpillover: false,
retentionOverrideDays: 7
}
];
// Simulated Ollama-backed model catalog, per task
const models = {
embedding: ["nomic-embed-text-v1.5", "all-MiniLM-L6-v2"],
synthesis: ["qwen2.5:7b-instruct-q4_K_M", "phi3.5:3.8b-mini-instruct-q4", "llama3.1:8b-instruct-q4"]
};
module.exports = {
getSettings: () => settings,
updateSettings: (patch) => {
settings = { ...settings, ...patch, retention: { ...settings.retention, ...(patch.retention || {}) } };
return settings;
},
getSources: () => sources,
addSource: (source) => {
const newSource = { id: `src-${Date.now()}`, lastPolledAt: null, lastError: null, enabled: true, ...source };
sources = [...sources, newSource];
return newSource;
},
updateSource: (id, patch) => {
sources = sources.map((s) => (s.id === id ? { ...s, ...patch } : s));
return sources.find((s) => s.id === id);
},
deleteSource: (id) => {
sources = sources.filter((s) => s.id !== id);
},
getEvents: () => events,
addEvent: (event) => {
const newEvent = { id: `evt-${Date.now()}`, active: true, sourceIds: [], ...event };
events = [...events, newEvent];
return newEvent;
},
updateEvent: (id, patch) => {
events = events.map((e) => (e.id === id ? { ...e, ...patch } : e));
return events.find((e) => e.id === id);
},
deleteEvent: (id) => {
events = events.filter((e) => e.id !== id);
},
getModels: () => models,
createCategory: (name) => {
const id = `cat-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${Date.now()}`;
const maxRank = Math.max(0, ...settings.categoryPriority.map((c) => c.priorityRank));
const created = { id, name, priorityRank: maxRank + 1, isDefault: false };
settings.categoryPriority = [...settings.categoryPriority, created];
return created;
},
deleteCategory: (id) => {
settings.categoryPriority = settings.categoryPriority.filter((c) => c.id !== id);
}
};