add intelligence features; implement signals and predictions pages in admin panel

This commit is contained in:
ImBenji
2026-04-23 22:58:19 +01:00
parent 4ffd31c2ab
commit bec6763191
26 changed files with 3265 additions and 3128 deletions
+111
View File
@@ -0,0 +1,111 @@
// shared helpers + global chrome (stats bar) used by every admin page.
// each page-specific script depends on this loading first.
const PAGE = 50;
const api = (path, opts) => fetch(path, opts).then(r => {
if (!r.ok) throw new Error(r.status);
return r.json();
});
function toast(msg, err) {
const el = document.getElementById("toast");
if (!el) return;
document.getElementById("toast-msg").textContent = msg;
el.className = "show" + (err ? " error" : "");
clearTimeout(toast._t);
toast._t = setTimeout(() => el.className = "", 2500);
}
function badgeHtml(status) {
const s = status || "null";
const cls = s === "ok" ? "ok" : s === "error" ? "err" : s === "pending" ? "pending" : "null";
return `<span class="badge ${cls}">${s}</span>`;
}
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[c]));
}
// ── url query-param helpers ────────────────────────────────────────────────
//
// filters and sort state live in the url so reloads and shared links keep
// their shape. queryGet reads a single param, queryAll returns them all,
// queryWrite replaces the query string with the cleaned-up params (empty
// values removed). we use replaceState so each filter change doesnt spam
// history.
function queryGet(key, fallback = "") {
const v = new URLSearchParams(location.search).get(key);
return v == null ? fallback : v;
}
function queryAll() {
return Object.fromEntries(new URLSearchParams(location.search));
}
function queryWrite(params) {
const clean = {};
for (const [k, v] of Object.entries(params)) {
if (v === "" || v == null) continue;
clean[k] = v;
}
const qs = new URLSearchParams(clean).toString();
const next = location.pathname + (qs ? "?" + qs : "");
history.replaceState(null, "", next);
}
// apply current query params onto form inputs — call on page init
function queryApplyToInputs(bindings) {
for (const [id, key] of Object.entries(bindings)) {
const el = document.getElementById(id);
if (!el) continue;
const v = queryGet(key, "");
if (v !== "") el.value = v;
}
}
// global stats bar — small counters shown on articles/events/stats pages
async function loadGlobalStats() {
const bar = document.getElementById("statsBar");
if (!bar) return;
try {
const data = await api("/admin/api/stats");
const t = document.getElementById("s-total");
if (t) t.textContent = data.total.toLocaleString();
const c = document.getElementById("s-content");
if (c) c.textContent = data.withContent.toLocaleString();
const em = document.getElementById("s-embed");
if (em) em.textContent = data.withEmbedding.toLocaleString();
const ev = document.getElementById("s-events");
if (ev) ev.textContent = data.eventCount.toLocaleString();
} catch (_) { /* ignore — stats bar is best-effort */ }
}
// common overlay close-on-backdrop wiring
function wireOverlays() {
document.querySelectorAll(".overlay").forEach(ov => {
ov.addEventListener("click", e => {
if (e.target === ov) ov.classList.remove("open");
});
});
}
document.addEventListener("DOMContentLoaded", () => {
wireOverlays();
loadGlobalStats();
});
+145
View File
@@ -0,0 +1,145 @@
// articles page — list + keyword/status/source filtering + edit modal
// depends on: app.js (api, toast, escapeHtml, PAGE, badgeHtml)
let articleOffset = 0;
let currentArticle = null;
async function loadSources() {
const sources = await api("/admin/api/sources");
const sel = document.getElementById("f-source");
sources.forEach(s => {
const opt = document.createElement("option");
opt.value = s;
opt.textContent = s;
sel.appendChild(opt);
});
}
function getFilters() {
return {
keyword: document.getElementById("f-keyword").value.trim(),
source: document.getElementById("f-source").value,
content_status: document.getElementById("f-status").value,
from: document.getElementById("f-from").value,
to: document.getElementById("f-to").value,
};
}
async function loadArticles() {
const f = getFilters();
const params = new URLSearchParams({ limit: PAGE, offset: articleOffset });
if (f.keyword) params.set("keyword", f.keyword);
if (f.source) params.set("source", f.source);
if (f.content_status) params.set("content_status", f.content_status);
if (f.from) params.set("from", f.from + "T00:00:00");
if (f.to) params.set("to", f.to + "T23:59:59");
const data = await api(`/admin/api/articles?${params}`);
const tbody = document.getElementById("articleTable");
tbody.innerHTML = data.rows.map(r => `
<tr>
<td style="color:var(--muted-dark); font-size:12px">${r.id}</td>
<td>
<span class="truncate" title="${escapeHtml(r.title)}">${escapeHtml(r.title)}</span>
<a class="url-link" href="${r.url}" target="_blank" style="font-size:11px; display:block; margin-top:3px; color:var(--muted-dark)">↗ ${new URL(r.url).hostname}</a>
</td>
<td style="color:var(--muted)">${r.source}</td>
<td>${badgeHtml(r.content_status)}</td>
<td style="color:var(--muted-dark); white-space:nowrap; font-size:12px">${r.ingested_at ? r.ingested_at.slice(0, 16) : "—"}</td>
<td><button onclick="openArticle(${r.id})">Edit</button></td>
</tr>
`).join("");
const total = data.total;
document.getElementById("pageInfo").textContent =
`${articleOffset + 1}${Math.min(articleOffset + PAGE, total)} of ${total.toLocaleString()}`;
document.getElementById("prevBtn").disabled = articleOffset === 0;
document.getElementById("nextBtn").disabled = articleOffset + PAGE >= total;
}
async function openArticle(id) {
currentArticle = await api(`/admin/api/articles/${id}`);
const a = currentArticle;
document.getElementById("modalTitle").textContent = `Article #${a.id}`;
document.getElementById("modalMeta").innerHTML = [
a.source && `<span>source: <b>${escapeHtml(a.source)}</b></span>`,
a.pub_date && `<span>pub: ${a.pub_date.slice(0, 16)}</span>`,
`<span>has_embedding: ${a.has_embedding ? "yes" : "no"}</span>`,
a.content_error && `<span style="color:#fca5a5">error: ${escapeHtml(a.content_error.slice(0, 80))}</span>`,
].filter(Boolean).join("");
document.getElementById("m-title").value = a.title || "";
document.getElementById("m-desc").value = a.description || "";
document.getElementById("m-content").value = a.content || "";
document.getElementById("m-status").value = a.content_status || "";
document.getElementById("m-lang").value = a.language || "";
document.getElementById("m-pubdate").value = a.pub_date || "";
document.getElementById("m-indexpage").value = String(a.is_index_page || 0);
document.getElementById("articleOverlay").classList.add("open");
}
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("searchBtn").onclick = () => { articleOffset = 0; loadArticles(); };
document.getElementById("prevBtn").onclick = () => { articleOffset = Math.max(0, articleOffset - PAGE); loadArticles(); };
document.getElementById("nextBtn").onclick = () => { articleOffset += PAGE; loadArticles(); };
document.querySelector(".filters").addEventListener("keydown", e => {
if (e.key === "Enter") { articleOffset = 0; loadArticles(); }
});
document.getElementById("cancelBtn").onclick = () =>
document.getElementById("articleOverlay").classList.remove("open");
document.getElementById("saveBtn").onclick = async () => {
if (!currentArticle) return;
try {
await api(`/admin/api/articles/${currentArticle.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: document.getElementById("m-title").value,
description: document.getElementById("m-desc").value,
content: document.getElementById("m-content").value,
content_status: document.getElementById("m-status").value || null,
language: document.getElementById("m-lang").value || null,
pub_date: document.getElementById("m-pubdate").value || null,
is_index_page: parseInt(document.getElementById("m-indexpage").value, 10),
}),
});
document.getElementById("articleOverlay").classList.remove("open");
toast("Saved");
loadArticles();
} catch (e) {
toast("Save failed", true);
}
};
document.getElementById("deleteBtn").onclick = async () => {
if (!currentArticle) return;
if (!confirm(`Delete article #${currentArticle.id}? This cannot be undone.`)) return;
try {
await api(`/admin/api/articles/${currentArticle.id}`, { method: "DELETE" });
document.getElementById("articleOverlay").classList.remove("open");
toast("Deleted");
loadArticles();
loadGlobalStats();
} catch (e) {
toast("Delete failed", true);
}
};
loadSources();
loadArticles();
});
+73
View File
@@ -0,0 +1,73 @@
// events page — list + title edit + detach-and-delete
// depends on: app.js
let eventOffset = 0;
let currentEvent = null;
async function loadEvents() {
const data = await api(`/admin/api/events?limit=${PAGE}&offset=${eventOffset}`);
const tbody = document.getElementById("eventTable");
tbody.innerHTML = data.rows.map(r => `
<tr>
<td style="color:var(--muted-dark); font-size:12px">${r.id}</td>
<td><span class="truncate" title="${escapeHtml(r.title)}">${escapeHtml(r.title)}</span></td>
<td>${r.article_count}</td>
<td style="color:var(--muted-dark); white-space:nowrap; font-size:12px">${r.created_at ? r.created_at.slice(0, 16) : "—"}</td>
<td><button onclick='openEvent(${r.id}, ${JSON.stringify(r.title)})'>Edit</button></td>
</tr>
`).join("");
const total = data.total;
document.getElementById("ePageInfo").textContent =
`${eventOffset + 1}${Math.min(eventOffset + PAGE, total)} of ${total.toLocaleString()}`;
document.getElementById("ePrevBtn").disabled = eventOffset === 0;
document.getElementById("eNextBtn").disabled = eventOffset + PAGE >= total;
}
function openEvent(id, title) {
currentEvent = { id, title };
document.getElementById("em-title").value = title;
document.getElementById("eventOverlay").classList.add("open");
}
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("ePrevBtn").onclick = () => { eventOffset = Math.max(0, eventOffset - PAGE); loadEvents(); };
document.getElementById("eNextBtn").onclick = () => { eventOffset += PAGE; loadEvents(); };
document.getElementById("eCancelBtn").onclick = () =>
document.getElementById("eventOverlay").classList.remove("open");
document.getElementById("eSaveBtn").onclick = async () => {
if (!currentEvent) return;
try {
await api(`/admin/api/events/${currentEvent.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: document.getElementById("em-title").value }),
});
document.getElementById("eventOverlay").classList.remove("open");
toast("Saved");
loadEvents();
} catch (e) { toast("Save failed", true); }
};
document.getElementById("eDeleteBtn").onclick = async () => {
if (!currentEvent) return;
if (!confirm(`Delete event #${currentEvent.id}? Articles will be detached but not deleted.`)) return;
try {
await api(`/admin/api/events/${currentEvent.id}`, { method: "DELETE" });
document.getElementById("eventOverlay").classList.remove("open");
toast("Event deleted");
loadEvents();
loadGlobalStats();
} catch (e) { toast("Delete failed", true); }
};
loadEvents();
});
+567
View File
@@ -0,0 +1,567 @@
// intelligence → graph (d3-force relationship network)
// depends on: app.js, intel-shared.js, d3.v7
const SECTOR_COLOR = {
AI: "#378ADD",
Tech: "#534AB7",
Semiconductor: "#BA7517",
Storage: "#888780",
Media: "#D4537E",
Auto: "#1D9E75",
Finance: "#639922",
Defense: "#6B7280",
Space: "#534AB7",
Telecom: "#1D9E75",
Cloud: "#D85A30",
};
const EDGE_COLOR = {
competitor: "#E24B4A",
customer: "#639922",
supplier: "#BA7517",
investor: "#378ADD",
};
// sector lookup for tickers we track — new tickers fall through to inferSector
const SECTOR_BY_TICKER = {
NVDA: "Semiconductor", AMD: "Semiconductor", INTC: "Semiconductor",
TSM: "Semiconductor", "2330.TW": "Semiconductor", ASML: "Semiconductor",
QCOM: "Semiconductor", AVGO: "Semiconductor", MRVL: "Semiconductor",
"005930.KS": "Semiconductor", "000660.KS": "Semiconductor",
KLAC: "Semiconductor", AMAT: "Semiconductor", LRCX: "Semiconductor",
TXN: "Semiconductor", ADI: "Semiconductor", ON: "Semiconductor",
AAPL: "Tech", GOOGL: "Tech", GOOG: "Tech", META: "Tech",
MSFT: "Tech", AMZN: "Tech", NFLX: "Tech",
OPENAI: "AI", ANTHROPIC: "AI", XAI: "AI",
MU: "Storage", WDC: "Storage", STX: "Storage",
DIS: "Media", CMCSA: "Media", WBD: "Media", SPOT: "Media", PARA: "Media",
TSLA: "Auto", F: "Auto", GM: "Auto", TM: "Auto", HMC: "Auto",
STLA: "Auto", RIVN: "Auto", LCID: "Auto",
JPM: "Finance", GS: "Finance", MS: "Finance", BAC: "Finance",
C: "Finance", BLK: "Finance", "BRK.B": "Finance", V: "Finance",
MA: "Finance", AXP: "Finance",
LMT: "Defense", RTX: "Defense", NOC: "Defense", GD: "Defense", HII: "Defense",
SPCX: "Space", RKLB: "Space",
VZ: "Telecom", T: "Telecom", TMUS: "Telecom",
ORCL: "Cloud", CRM: "Cloud", NOW: "Cloud", SNOW: "Cloud",
};
function inferSector(ticker, name) {
if (ticker && SECTOR_BY_TICKER[ticker.toUpperCase()]) {
return SECTOR_BY_TICKER[ticker.toUpperCase()];
}
if (name) {
const upper = name.toUpperCase();
if (SECTOR_BY_TICKER[upper]) return SECTOR_BY_TICKER[upper];
const low = name.toLowerCase();
if (/bank|capital|asset|fund|invest/.test(low)) return "Finance";
if (/semi|chip/.test(low)) return "Semiconductor";
if (/media|studio|entertain/.test(low)) return "Media";
if (/telecom|wireless|mobile/.test(low)) return "Telecom";
if (/cloud/.test(low)) return "Cloud";
if (/motor|automot/.test(low)) return "Auto";
}
return "Tech"; // sensible default
}
let graphNodes = [];
let graphEdges = [];
let graphDegree = new Map();
let graphFilterType = "all";
let graphSearchTerm = "";
let graphSelectedId = null;
async function loadIntelGraph() {
const data = await api("/admin/api/intelligence/graph");
if (!data.nodes || data.nodes.length === 0) {
document.getElementById("graph-empty").style.display = "block";
graphNodes = []; graphEdges = [];
document.getElementById("intel-graph-svg").innerHTML = "";
return;
}
document.getElementById("graph-empty").style.display = "none";
const idByCompanyId = new Map();
const untrackedIds = new Set();
graphNodes = [];
for (const n of data.nodes) {
if (n.tracked) {
const nodeId = n.ticker || `C${n.id}`;
idByCompanyId.set(n.id, nodeId);
graphNodes.push({
id: nodeId,
companyId: n.id,
label: n.name,
sector: inferSector(n.ticker, n.name),
tracked: true,
});
} else {
const nodeId = `U:${n.name}`;
if (untrackedIds.has(nodeId)) continue;
untrackedIds.add(nodeId);
graphNodes.push({
id: nodeId,
companyId: null,
label: n.name,
sector: null,
tracked: false,
});
}
}
const seen = new Set();
graphEdges = [];
for (const e of data.edges) {
let type = (e.relationship_type || "").toLowerCase();
let src = idByCompanyId.get(e.from_company_id);
let tgt;
if (e.to_company_id) {
tgt = idByCompanyId.get(e.to_company_id);
} else if (e.to_entity && untrackedIds.has(`U:${e.to_entity}`)) {
tgt = `U:${e.to_entity}`;
}
if (!src || !tgt) continue;
// dependency is the reciprocal of investor — flip direction and normalize
if (type === "dependency" && e.to_company_id) {
type = "investor";
[src, tgt] = [tgt, src];
}
if (!EDGE_COLOR[type]) continue;
const key = `${src}|${tgt}|${type}`;
if (seen.has(key)) continue;
seen.add(key);
graphEdges.push({ source: src, target: tgt, type });
}
graphDegree = new Map();
for (const n of graphNodes) graphDegree.set(n.id, 0);
for (const e of graphEdges) {
graphDegree.set(e.source, (graphDegree.get(e.source) || 0) + 1);
graphDegree.set(e.target, (graphDegree.get(e.target) || 0) + 1);
}
graphSelectedId = null;
clearGraphInfo();
renderIntelGraph();
}
function graphNodeRadius(d) {
return 5 + Math.min((graphDegree.get(d.id) || 0) * 0.8, 12);
}
function renderIntelGraph() {
const svgEl = document.getElementById("intel-graph-svg");
svgEl.innerHTML = "";
if (graphNodes.length === 0) return;
const width = svgEl.clientWidth || 900;
const height = svgEl.clientHeight || 600;
const svg = d3.select(svgEl).attr("viewBox", [0, 0, width, height]);
const root = svg.append("g");
const zoom = d3.zoom()
.scaleExtent([0.25, 4])
.on("zoom", ev => root.attr("transform", ev.transform));
svg.call(zoom);
svg.on("click", ev => {
if (ev.target === svg.node()) clearGraphSelection();
});
const linkLayer = root.append("g").attr("class", "ig-links");
const nodeLayer = root.append("g").attr("class", "ig-nodes");
const nodesCopy = graphNodes.map(n => ({ ...n }));
const edgesCopy = graphEdges.map(e => ({ ...e }));
const linkSel = linkLayer.selectAll("line")
.data(edgesCopy)
.join("line")
.attr("stroke", d => EDGE_COLOR[d.type] || "#888")
.attr("stroke-width", 1.5)
.attr("stroke-opacity", 0.55);
const nodeSel = nodeLayer.selectAll("g.ig-node")
.data(nodesCopy, d => d.id)
.join("g")
.attr("class", "ig-node")
.style("cursor", d => d.tracked ? "pointer" : "default")
.call(d3.drag()
.on("start", dragStart)
.on("drag", dragMove)
.on("end", dragEnd));
nodeSel.append("circle")
.attr("r", graphNodeRadius)
.attr("fill", d => d.tracked ? (SECTOR_COLOR[d.sector] || "#888") : "none")
.attr("stroke", d => d.tracked ? "transparent" : "#6b7280")
.attr("stroke-width", d => d.tracked ? 2 : 1.5)
.on("mouseenter", function (ev, d) {
if (!d.tracked) {
d3.select(this).attr("stroke", "#94a3b8");
return;
}
if (d.id !== graphSelectedId) {
d3.select(this).attr("stroke", "rgba(255,255,255,0.6)");
}
})
.on("mouseleave", function (ev, d) {
if (!d.tracked) {
d3.select(this).attr("stroke", "#6b7280");
return;
}
if (d.id !== graphSelectedId) {
d3.select(this).attr("stroke", "transparent");
}
})
.on("click", (ev, d) => {
ev.stopPropagation();
if (!d.tracked) return;
selectGraphNode(d);
});
nodeSel.append("text")
.attr("class", d => d.tracked ? "graph-node-label" : "graph-node-label graph-node-label-untracked")
.attr("text-anchor", "middle")
.attr("y", d => graphNodeRadius(d) + 13)
.text(d => d.label);
nodeSel.insert("rect", "text.graph-node-label")
.attr("class", "ig-label-bg")
.attr("rx", 2).attr("ry", 2);
nodeSel.each(function () {
const g = d3.select(this);
const t = g.select("text.graph-node-label").node();
if (!t) return;
const bb = t.getBBox();
g.select("rect.ig-label-bg")
.attr("x", bb.x - 3)
.attr("y", bb.y - 1)
.attr("width", bb.width + 6)
.attr("height", bb.height + 2);
});
const sim = d3.forceSimulation(nodesCopy)
.force("link", d3.forceLink(edgesCopy).id(d => d.id).distance(80))
.force("charge", d3.forceManyBody().strength(-300))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("x", d3.forceX(width / 2).strength(0.07))
.force("y", d3.forceY(height / 2).strength(0.07))
.force("collide", d3.forceCollide(d => graphNodeRadius(d) + 4));
sim.on("tick", () => {
linkSel
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
nodeSel.attr("transform", d => `translate(${d.x},${d.y})`);
});
window._igSim = sim;
window._igLinkSel = linkSel;
window._igNodeSel = nodeSel;
applyGraphFilters();
function dragStart(ev, d) {
if (!ev.active) sim.alphaTarget(0.3).restart();
d.fx = d.x; d.fy = d.y;
}
function dragMove(ev, d) {
d.fx = ev.x; d.fy = ev.y;
}
function dragEnd(ev, d) {
if (!ev.active) sim.alphaTarget(0);
d.fx = null; d.fy = null;
}
}
function applyGraphFilters() {
const linkSel = window._igLinkSel;
const nodeSel = window._igNodeSel;
if (!linkSel || !nodeSel) return;
const term = graphSearchTerm;
const filter = graphFilterType;
let visibleIds;
if (term) {
const direct = graphNodes.filter(n =>
n.label.toLowerCase().includes(term) ||
n.id.toLowerCase().includes(term)
).map(n => n.id);
visibleIds = new Set(direct);
for (const e of graphEdges) {
if (visibleIds.has(e.source)) visibleIds.add(e.target);
if (visibleIds.has(e.target)) visibleIds.add(e.source);
}
} else {
visibleIds = new Set(graphNodes.map(n => n.id));
}
const lit = graphSelectedId ? neighborsOfNode(graphSelectedId) : null;
nodeSel
.style("display", d => visibleIds.has(d.id) ? null : "none")
.style("opacity", d => (lit && !lit.has(d.id)) ? 0.15 : 1);
nodeSel.select("circle")
.attr("stroke", d => {
if (!d.tracked) return "#6b7280";
return d.id === graphSelectedId ? "#ffffff" : "transparent";
})
.attr("stroke-width", d => {
if (!d.tracked) return 1.5;
return d.id === graphSelectedId ? 2.5 : 2;
});
linkSel
.style("display", e => {
if (filter !== "all" && e.type !== filter) return "none";
const s = typeof e.source === "object" ? e.source.id : e.source;
const t = typeof e.target === "object" ? e.target.id : e.target;
if (!visibleIds.has(s) || !visibleIds.has(t)) return "none";
return null;
})
.attr("stroke-opacity", e => {
if (!lit) return 0.55;
const s = typeof e.source === "object" ? e.source.id : e.source;
const t = typeof e.target === "object" ? e.target.id : e.target;
return (s !== graphSelectedId && t !== graphSelectedId) ? 0.08 : 0.55;
});
}
function neighborsOfNode(id) {
const set = new Set([id]);
for (const e of graphEdges) {
const s = typeof e.source === "object" ? e.source.id : e.source;
const t = typeof e.target === "object" ? e.target.id : e.target;
if (s === id) set.add(t);
if (t === id) set.add(s);
}
return set;
}
function selectGraphNode(d) {
graphSelectedId = d.id;
applyGraphFilters();
renderGraphInfo(d);
}
function clearGraphSelection() {
graphSelectedId = null;
applyGraphFilters();
clearGraphInfo();
}
function clearGraphInfo() {
document.getElementById("graph-info").innerHTML =
'<p class="graph-empty-msg">Click a node to see its connections.</p>';
}
function renderGraphInfo(node) {
const groups = { competitor: [], customer: [], supplier: [], investor: [] };
const seenPer = { competitor: new Set(), customer: new Set(), supplier: new Set(), investor: new Set() };
for (const e of graphEdges) {
let otherId = null;
if (e.source === node.id) otherId = e.target;
else if (e.target === node.id) otherId = e.source;
if (!otherId) continue;
if (seenPer[e.type].has(otherId)) continue;
seenPer[e.type].add(otherId);
const other = graphNodes.find(n => n.id === otherId);
if (other) groups[e.type].push(other);
}
let html = `<div id="graph-info-title">${escapeHtml(node.label)}</div>`;
html += `<span id="graph-info-sector">${escapeHtml(node.sector)}</span>`;
let any = false;
for (const type of ["competitor", "customer", "supplier", "investor"]) {
const list = groups[type];
if (!list.length) continue;
any = true;
html += `<div class="graph-group-title">${type} (${list.length})</div>`;
for (const o of list) {
const canExpand = !!o.companyId;
if (canExpand) {
html += `
<div class="graph-conn-row" data-from="${node.companyId}" data-to="${o.companyId}" data-type="${type}">
<div class="graph-conn-head">
<span class="graph-conn-label">${escapeHtml(o.label)}</span>
<span class="graph-conn-right">
<span class="graph-conn-sector">${escapeHtml(o.sector)}</span>
<span class="graph-conn-toggle">▸</span>
</span>
</div>
<div class="graph-conn-body"></div>
</div>`;
} else {
html += `
<div class="graph-conn-row untracked">
<div class="graph-conn-head">
<span class="graph-conn-label">${escapeHtml(o.label)}</span>
<span class="graph-conn-right">
<span class="graph-conn-sector">untracked</span>
</span>
</div>
</div>`;
}
}
}
if (!any) {
html += `<p class="graph-empty-msg" style="margin-top:12px">No relationships recorded.</p>`;
}
document.getElementById("graph-info").innerHTML = html;
document.querySelectorAll("#graph-info .graph-conn-row").forEach(row => {
if (row.classList.contains("untracked")) return;
row.querySelector(".graph-conn-head").addEventListener("click", () => toggleEvidenceRow(row));
});
}
async function toggleEvidenceRow(row) {
const body = row.querySelector(".graph-conn-body");
const toggle = row.querySelector(".graph-conn-toggle");
const expanded = row.classList.toggle("expanded");
toggle.textContent = expanded ? "▾" : "▸";
if (!expanded) return;
if (row.dataset.loaded) return;
body.innerHTML = '<div class="graph-evidence-loading">Loading evidence…</div>';
const fromId = row.dataset.from;
const toId = row.dataset.to;
const type = row.dataset.type;
try {
const data = await api(`/admin/api/intelligence/edge-evidence?from_id=${fromId}&to_id=${toId}&type=${encodeURIComponent(type)}`);
let html = "";
if (data.edge) {
html += `<div class="graph-evidence-stats">
<span class="graph-evidence-badge">${escapeHtml(data.edge.confidence || "low")}</span>
<span>×${data.edge.confirmation_count || 1} confirmations</span>
</div>`;
}
if (data.facts && data.facts.length) {
html += '<div class="graph-evidence-section">Backing facts</div>';
for (const f of data.facts) {
html += `<div class="graph-evidence-fact">
<div class="graph-evidence-claim">${escapeHtml(f.claim)}</div>
<div class="graph-evidence-meta">${escapeHtml(f.confidence || "low")} · ×${f.confirmation_count || 1}</div>
</div>`;
}
}
if (data.events && data.events.length) {
html += `<div class="graph-evidence-section">Source events (${data.events.length})</div>`;
for (const ev of data.events) {
const title = ev.title || `Event #${ev.id}`;
html += `<details class="graph-evidence-event">
<summary>
<span class="graph-evidence-event-title">${escapeHtml(title)}</span>
<span class="graph-evidence-event-id">#${ev.id}</span>
</summary>`;
if (ev.articles && ev.articles.length) {
html += '<ul class="graph-evidence-articles">';
for (const a of ev.articles) {
const date = a.pub_date ? String(a.pub_date).slice(0, 10) : "";
html += `<li>
<a href="${escapeHtml(a.url || "#")}" target="_blank" rel="noopener">${escapeHtml(a.title || a.url || "untitled")}</a>
<div class="graph-evidence-article-meta">${escapeHtml(a.source || "")}${date ? " · " + date : ""}</div>
</li>`;
}
html += "</ul>";
} else {
html += '<div class="graph-evidence-empty">No articles.</div>';
}
html += "</details>";
}
}
if (!html) {
html = '<div class="graph-evidence-empty">No backing evidence recorded.</div>';
}
body.innerHTML = html;
row.dataset.loaded = "1";
} catch (err) {
body.innerHTML = '<div class="graph-evidence-error">Failed to load evidence.</div>';
}
}
document.addEventListener("DOMContentLoaded", async () => {
document.getElementById("graph-search").addEventListener("input", ev => {
graphSearchTerm = ev.target.value.trim().toLowerCase();
applyGraphFilters();
});
document.getElementById("graph-chips").addEventListener("click", ev => {
const btn = ev.target.closest(".graph-chip");
if (!btn) return;
document.querySelectorAll(".graph-chip").forEach(c => c.classList.remove("active"));
btn.classList.add("active");
graphFilterType = btn.dataset.type;
applyGraphFilters();
});
const ok = await loadIntelStatsRow();
if (!ok) return;
loadIntelGraph();
});
+63
View File
@@ -0,0 +1,63 @@
// intelligence → knowledge table
// depends on: app.js, intel-shared.js
let knowledgeOffset = 0;
async function loadKnowledge() {
const companyId = document.getElementById("i-company").value;
const type = document.getElementById("i-type").value;
const sort = document.getElementById("i-sort").value;
const params = new URLSearchParams({ limit: PAGE, offset: knowledgeOffset });
if (companyId) params.set("company_id", companyId);
if (type) params.set("type", type);
if (sort) params.set("sort", sort);
const data = await api(`/admin/api/intelligence/knowledge?${params}`);
intelRows = data.rows;
document.getElementById("intel-thead").innerHTML = `
<tr><th>ID</th><th>Company</th><th>Event</th><th>Type</th><th>Data</th><th>Event date</th></tr>`;
document.getElementById("intel-tbody").innerHTML = data.rows.map(r => {
let parsed = {};
try { parsed = JSON.parse(r.data); } catch (_) {}
const summary = Object.values(parsed).filter(v => typeof v === "string").join(" · ").slice(0, 120);
return `<tr style="cursor:pointer" onclick="openIntelDetail(${r.id}, 'knowledge')">
<td style="color:var(--muted-dark); font-size:12px">${r.id}</td>
<td style="white-space:nowrap">${escapeHtml(r.company_name)}</td>
<td style="color:var(--muted-dark); font-size:12px">${r.event_id}</td>
<td><span class="badge null">${escapeHtml(r.type)}</span></td>
<td><span class="truncate" style="max-width:360px">${escapeHtml(summary)}</span></td>
<td style="color:var(--muted-dark); white-space:nowrap; font-size:12px">${r.event_date ? r.event_date.slice(0,10) : "—"}</td>
</tr>`;
}).join("");
const total = data.total;
document.getElementById("iPageInfo").textContent =
`${knowledgeOffset + 1}${Math.min(knowledgeOffset + PAGE, total)} of ${total.toLocaleString()}`;
document.getElementById("iPrevBtn").disabled = knowledgeOffset === 0;
document.getElementById("iNextBtn").disabled = knowledgeOffset + PAGE >= total;
}
document.addEventListener("DOMContentLoaded", async () => {
document.getElementById("iPrevBtn").onclick = () => {
knowledgeOffset = Math.max(0, knowledgeOffset - PAGE);
loadKnowledge();
};
document.getElementById("iNextBtn").onclick = () => {
knowledgeOffset += PAGE;
loadKnowledge();
};
document.getElementById("i-filter-btn").onclick = () => {
knowledgeOffset = 0;
loadKnowledge();
};
const ok = await loadIntelStatsRow();
if (!ok) return;
await loadIntelCompanies();
loadKnowledge();
});
@@ -0,0 +1,61 @@
// intelligence → predictions table
// depends on: app.js, intel-shared.js
let predictionsOffset = 0;
async function loadPredictions() {
const companyId = document.getElementById("i-company").value;
const sort = document.getElementById("i-sort").value;
const params = new URLSearchParams({ limit: PAGE, offset: predictionsOffset });
if (companyId) params.set("company_id", companyId);
if (sort) params.set("sort", sort);
const data = await api(`/admin/api/intelligence/predictions?${params}`);
intelRows = data.rows;
document.getElementById("intel-thead").innerHTML = `
<tr><th>ID</th><th>Company</th><th>Event</th><th>Type</th><th>Direction</th><th>Magnitude</th><th>Timeframe</th><th>Rationale</th><th>Event date</th></tr>`;
document.getElementById("intel-tbody").innerHTML = data.rows.map(r => `
<tr style="cursor:pointer" onclick="openIntelDetail(${r.id}, 'predictions')">
<td style="color:var(--muted-dark); font-size:12px">${r.id}</td>
<td style="white-space:nowrap">${escapeHtml(r.company_name)}</td>
<td style="color:var(--muted-dark); font-size:12px">${r.event_id}</td>
<td><span class="badge null">${escapeHtml(r.type)}</span></td>
<td>${escapeHtml(r.direction || "—")}</td>
<td>${escapeHtml(r.magnitude || "—")}</td>
<td>${escapeHtml(r.timeframe || "—")}</td>
<td><span class="truncate" style="max-width:300px">${escapeHtml(r.rationale || "—")}</span></td>
<td style="color:var(--muted-dark); white-space:nowrap; font-size:12px">${r.event_date ? r.event_date.slice(0,10) : "—"}</td>
</tr>
`).join("");
const total = data.total;
document.getElementById("iPageInfo").textContent =
`${predictionsOffset + 1}${Math.min(predictionsOffset + PAGE, total)} of ${total.toLocaleString()}`;
document.getElementById("iPrevBtn").disabled = predictionsOffset === 0;
document.getElementById("iNextBtn").disabled = predictionsOffset + PAGE >= total;
}
document.addEventListener("DOMContentLoaded", async () => {
document.getElementById("iPrevBtn").onclick = () => {
predictionsOffset = Math.max(0, predictionsOffset - PAGE);
loadPredictions();
};
document.getElementById("iNextBtn").onclick = () => {
predictionsOffset += PAGE;
loadPredictions();
};
document.getElementById("i-filter-btn").onclick = () => {
predictionsOffset = 0;
loadPredictions();
};
const ok = await loadIntelStatsRow();
if (!ok) return;
await loadIntelCompanies();
loadPredictions();
});
+97
View File
@@ -0,0 +1,97 @@
// helpers shared across all intelligence sub-pages:
// stats-row loader, company dropdown, detail modal opener.
// depends on: app.js
async function loadIntelStatsRow() {
const row = document.getElementById("intel-stats-row");
if (!row) return true;
const data = await api("/admin/api/intelligence/stats");
if (!data.available) {
const notice = document.getElementById("intel-unavailable");
const content = document.getElementById("intel-content");
if (notice) notice.style.display = "";
if (content) content.style.display = "none";
return false;
}
const notice = document.getElementById("intel-unavailable");
const content = document.getElementById("intel-content");
if (notice) notice.style.display = "none";
if (content) content.style.display = "";
const queueMap = {};
(data.queue || []).forEach(r => queueMap[r.status] = r.n);
row.innerHTML = [
["Queue pending", (queueMap.pending || 0).toLocaleString()],
["Processed", (queueMap.processed || 0).toLocaleString()],
["Skipped", (queueMap.skipped || 0).toLocaleString()],
["Knowledge rows", data.knowledge.toLocaleString()],
["Predictions", data.predictions.toLocaleString()],
["Companies", `${data.embeddings}/${data.companies} embedded`],
].map(([label, value]) => `
<div class="intel-stat-card">
<span class="label">${label}</span>
<span class="value">${value}</span>
</div>
`).join("");
return true;
}
async function loadIntelCompanies() {
const sel = document.getElementById("i-company");
if (!sel) return;
const companies = await api("/admin/api/intelligence/companies");
sel.innerHTML = '<option value="">All companies</option>';
companies.forEach(c => {
const opt = document.createElement("option");
opt.value = c.id;
opt.textContent = `${c.name} (${c.ticker})`;
sel.appendChild(opt);
});
}
let intelRows = [];
function openIntelDetail(id, view) {
const row = intelRows.find(r => r.id === id);
if (!row) return;
document.getElementById("intel-modal-title").textContent =
`${row.company_name} — Event ${row.event_id}`;
const meta = [`type: ${row.type || view}`, `created: ${row.created_at ? row.created_at.slice(0,16) : "—"}`];
document.getElementById("intel-modal-meta").innerHTML = meta.map(m => `<span>${escapeHtml(m)}</span>`).join("");
let body = "";
if (view === "knowledge") {
try {
const parsed = JSON.parse(row.data);
body = Object.entries(parsed).map(([k, v]) => `${k}: ${v}`).join("\n");
} catch (_) { body = row.data; }
} else {
body = [
row.rationale,
"",
`direction: ${row.direction || "—"}`,
`magnitude: ${row.magnitude || "—"}`,
`timeframe: ${row.timeframe || "—"}`,
].join("\n");
}
document.getElementById("intel-modal-body").textContent = body;
document.getElementById("intelOverlay").classList.add("open");
}
document.addEventListener("DOMContentLoaded", () => {
const closeBtn = document.getElementById("intelCloseBtn");
if (closeBtn) {
closeBtn.onclick = () => document.getElementById("intelOverlay").classList.remove("open");
}
});
+103
View File
@@ -0,0 +1,103 @@
// intelligence → signals (trade signal cards)
// depends on: app.js, intel-shared.js
async function loadSignals() {
let data;
try {
data = await api("/admin/api/intelligence/signals");
} catch (_) {
data = [];
}
const grid = document.getElementById("intel-signals-grid");
const empty = document.getElementById("intel-signals-empty");
if (!data || data.length === 0) {
grid.innerHTML = "";
empty.style.display = "";
return;
}
empty.style.display = "none";
grid.innerHTML = data.map(s => {
const firstSentence = (s.summary || "").split(/\.\s+/)[0].replace(/\.$/, "") + ".";
const ts = s.generated_at ? s.generated_at.slice(0, 16).replace("T", " ") : "—";
let drivers = [];
let risks = [];
let predIds = [];
try { drivers = JSON.parse(s.key_drivers || "[]"); } catch (_) {}
try { risks = JSON.parse(s.risk_factors || "[]"); } catch (_) {}
try { predIds = JSON.parse(s.supporting_prediction_ids || "[]"); } catch (_) {}
const driverItems = drivers.map(d => `<li>${escapeHtml(d)}</li>`).join("");
const riskItems = risks.map(r => `<li>${escapeHtml(r)}</li>`).join("");
return `
<div class="signal-card" id="signal-card-${s.company_id}">
<div class="signal-card-glance" onclick="toggleSignalCard(${s.company_id})">
<div class="signal-card-header">
<div>
<div class="signal-company">${escapeHtml(s.company_name)}</div>
<div class="signal-ticker">${escapeHtml(s.ticker)}</div>
</div>
<span class="signal-badge ${s.signal}">${s.signal}</span>
</div>
<div class="signal-tags">
<span class="signal-tag">conf: ${escapeHtml(s.confidence)}</span>
<span class="signal-tag">risk: ${escapeHtml(s.risk_level)}</span>
<span class="signal-tag">${escapeHtml(s.timeframe)}</span>
</div>
<div class="signal-summary">${escapeHtml(firstSentence)}</div>
<div class="signal-ts">Generated ${ts}</div>
</div>
<div class="signal-card-detail">
<div class="signal-detail-section">
<div class="signal-detail-label">Summary</div>
<p>${escapeHtml(s.summary || "—")}</p>
</div>
${driverItems ? `<div class="signal-detail-section"><div class="signal-detail-label">Key drivers</div><ul>${driverItems}</ul></div>` : ""}
${riskItems ? `<div class="signal-detail-section"><div class="signal-detail-label">Risk factors</div><ul>${riskItems}</ul></div>` : ""}
<div class="signal-detail-section">
<div class="signal-detail-label">Data used</div>
<p style="color:var(--muted-dark)">${predIds.length} predictions &nbsp;·&nbsp; window: 90 days</p>
</div>
<button class="signal-regen-btn" onclick="regenerateSignal(event, ${s.company_id})">Regenerate</button>
</div>
</div>
`;
}).join("");
}
function toggleSignalCard(companyId) {
const card = document.getElementById(`signal-card-${companyId}`);
if (card) card.classList.toggle("expanded");
}
async function regenerateSignal(ev, companyId) {
ev.stopPropagation();
try {
await api(`/admin/api/intelligence/signals/${companyId}`, { method: "DELETE" });
toast("Signal cleared — worker will regenerate on next cycle");
const card = document.getElementById(`signal-card-${companyId}`);
if (card) card.remove();
const grid = document.getElementById("intel-signals-grid");
if (!grid.children.length) {
document.getElementById("intel-signals-empty").style.display = "";
}
} catch (_) {
toast("Failed to clear signal", true);
}
}
document.addEventListener("DOMContentLoaded", async () => {
const ok = await loadIntelStatsRow();
if (!ok) return;
loadSignals();
});
+71
View File
@@ -0,0 +1,71 @@
// sql console — run ad-hoc queries against archive or intelligence db
// depends on: app.js
async function runSql() {
const sql = document.getElementById("sql-input").value.trim();
if (!sql) return;
const database = document.getElementById("sql-db").value;
const errEl = document.getElementById("sql-error");
const resultsEl = document.getElementById("sql-results");
const elapsedEl = document.getElementById("sql-elapsed");
errEl.style.display = "none";
resultsEl.innerHTML = "";
elapsedEl.textContent = "";
try {
const data = await fetch("/admin/api/sql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sql, database }),
}).then(r => r.json());
if (data.error) {
errEl.textContent = data.error;
errEl.style.display = "";
return;
}
elapsedEl.textContent = `${data.elapsed}ms`;
const blocks = [];
for (const r of (data.results || [])) {
if (r.error) {
blocks.push(`<div style="color:#fca5a5; font-size:13px; margin-bottom:12px"><span style="color:var(--muted-dark); font-size:11px; display:block; margin-bottom:4px; font-family:'SF Mono','Fira Code',monospace">${escapeHtml(r.sql.slice(0, 120))}</span>${escapeHtml(r.error)}</div>`);
} else if (r.rows && r.rows.length > 0) {
const cols = Object.keys(r.rows[0]);
blocks.push(`
<div style="margin-bottom:16px">
<div class="table-wrap">
<table>
<thead><tr>${cols.map(c => `<th>${escapeHtml(c)}</th>`).join("")}</tr></thead>
<tbody>${r.rows.map(row =>
`<tr>${cols.map(c => `<td><span class="truncate" style="max-width:300px" title="${escapeHtml(String(row[c] ?? ""))}">${row[c] == null ? '<span style="color:var(--muted-dark)">NULL</span>' : escapeHtml(row[c])}</span></td>`).join("")}</tr>`
).join("")}</tbody>
</table>
</div>
<div style="color:var(--muted-dark); font-size:12px; margin-top:6px">${r.rows.length} row${r.rows.length !== 1 ? "s" : ""}</div>
</div>
`);
} else if (r.rows) {
blocks.push(`<div style="color:var(--muted); font-size:13px; margin-bottom:12px">No rows returned.</div>`);
} else {
blocks.push(`<div style="color:#86efac; font-size:13px; margin-bottom:12px">${r.changes} row${r.changes !== 1 ? "s" : ""} affected.</div>`);
}
}
resultsEl.innerHTML = blocks.join("");
} catch (e) {
errEl.textContent = e.message;
errEl.style.display = "";
}
}
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("sql-run-btn").onclick = runSql;
document.getElementById("sql-input").addEventListener("keydown", e => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) runSql();
});
});
+19
View File
@@ -0,0 +1,19 @@
// stats page — pipeline throughput + per-source/per-status breakdowns
// depends on: app.js
async function loadStatsPage() {
const data = await api("/admin/api/stats");
document.getElementById("sourceTable").innerHTML = data.bySource
.map(r => `<tr><td>${escapeHtml(r.source)}</td><td style="text-align:right; padding-left:24px">${r.n.toLocaleString()}</td></tr>`).join("");
document.getElementById("statusTable").innerHTML = data.byStatus
.map(r => `<tr><td>${badgeHtml(r.status === "null" ? null : r.status)}</td><td style="text-align:right; padding-left:24px">${r.n.toLocaleString()}</td></tr>`).join("");
document.getElementById("rate-ingested").textContent = (data.ingestedPerHour || 0).toLocaleString();
document.getElementById("rate-content").textContent = (data.contentPerHour || 0).toLocaleString();
document.getElementById("rate-embeddings").textContent = (data.embeddingsPerHour || 0).toLocaleString();
}
document.addEventListener("DOMContentLoaded", loadStatsPage);