@@ -1253,10 +1573,18 @@ async function loadIntelligence() {
const view = document.getElementById('i-view').value;
if (view === 'graph') {
+ showSignalsView(false);
await loadIntelGraph();
return;
}
+ if (view === 'signals') {
+ showGraphView(false);
+ await loadSignals();
+ return;
+ }
+
+ showSignalsView(false);
showGraphView(false);
const companyId = document.getElementById('i-company').value;
@@ -1452,31 +1780,55 @@ async function loadIntelGraph() {
// map company_id → node id (we use ticker; fall back to synthetic id)
const idByCompanyId = new Map();
+ const untrackedIds = new Set();
graphNodes = [];
for (const n of data.nodes) {
- if (!n.tracked) continue;
- const nodeId = n.ticker || `C${n.id}`;
- idByCompanyId.set(n.id, nodeId);
- graphNodes.push({
- id: nodeId,
- label: n.name,
- sector: inferSector(n.ticker, n.name),
- });
+ 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 {
+ // untracked entity — keyed by name (prefixed so it cant collide with a ticker)
+ 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,
+ });
+ }
}
- // normalize edges: only tracked↔tracked, strict spec types, dedupe
+ // normalize edges: include tracked↔tracked and tracked→untracked, dedupe
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 = e.to_company_id ? idByCompanyId.get(e.to_company_id) : null;
+ 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') {
+ // only safe to flip when both ends are tracked
+ if (type === 'dependency' && e.to_company_id) {
type = 'investor';
[src, tgt] = [tgt, src];
}
@@ -1549,7 +1901,7 @@ function renderIntelGraph() {
.data(nodesCopy, d => d.id)
.join('g')
.attr('class', 'ig-node')
- .style('cursor', 'pointer')
+ .style('cursor', d => d.tracked ? 'pointer' : 'default')
.call(d3.drag()
.on('start', dragStart)
.on('drag', dragMove)
@@ -1557,27 +1909,36 @@ function renderIntelGraph() {
nodeSel.append('circle')
.attr('r', graphNodeRadius)
- .attr('fill', d => SECTOR_COLOR[d.sector] || '#888')
- .attr('stroke', 'transparent')
- .attr('stroke-width', 2)
+ .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);
});
// label text first (to measure), then bg rect inserted before it
nodeSel.append('text')
- .attr('class', 'graph-node-label')
+ .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);
@@ -1672,8 +2033,14 @@ function applyGraphFilters() {
.style('opacity', d => (lit && !lit.has(d.id)) ? 0.15 : 1);
nodeSel.select('circle')
- .attr('stroke', d => d.id === graphSelectedId ? '#ffffff' : 'transparent')
- .attr('stroke-width', d => d.id === graphSelectedId ? 2.5 : 2);
+ .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 => {
@@ -1747,7 +2114,30 @@ function renderGraphInfo(node) {
any = true;
html += `
${type} (${list.length})
`;
for (const o of list) {
- html += `
${escapeHtmlG(o.label)}${escapeHtmlG(o.sector)}
`;
+ const canExpand = !!o.companyId;
+ if (canExpand) {
+ html += `
+
+
+ ${escapeHtmlG(o.label)}
+
+ ${escapeHtmlG(o.sector)}
+ ▸
+
+
+
+
`;
+ } else {
+ html += `
+
+
+ ${escapeHtmlG(o.label)}
+
+ untracked
+
+
+
`;
+ }
}
}
@@ -1756,6 +2146,96 @@ function renderGraphInfo(node) {
}
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) {
+ body.style.display = 'none';
+ return;
+ }
+
+ body.style.display = '';
+
+ if (row.dataset.loaded) return;
+
+ body.innerHTML = '
Loading evidence…
';
+
+ 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 += `
+ ${escapeHtmlG(data.edge.confidence || 'low')}
+ ×${data.edge.confirmation_count || 1} confirmations
+
`;
+ }
+
+ if (data.facts && data.facts.length) {
+ html += '
Backing facts
';
+ for (const f of data.facts) {
+ html += `
+
${escapeHtmlG(f.claim)}
+
${escapeHtmlG(f.confidence || 'low')} · ×${f.confirmation_count || 1}
+
`;
+ }
+ }
+
+ if (data.events && data.events.length) {
+ html += `
Source events (${data.events.length})
`;
+ for (const ev of data.events) {
+ const title = ev.title || `Event #${ev.id}`;
+ html += `
+
+ ${escapeHtmlG(title)}
+ #${ev.id}
+
`;
+
+ if (ev.articles && ev.articles.length) {
+ html += '';
+ for (const a of ev.articles) {
+ const date = a.pub_date ? String(a.pub_date).slice(0, 10) : '';
+ html += `-
+ ${escapeHtmlG(a.title || a.url || 'untitled')}
+
${escapeHtmlG(a.source || '')}${date ? ' · ' + date : ''}
+ `;
+ }
+ html += '
';
+ } else {
+ html += 'No articles.
';
+ }
+
+ html += ' ';
+ }
+ }
+
+ if (!html) {
+ html = '
No backing evidence recorded.
';
+ }
+
+ body.innerHTML = html;
+ row.dataset.loaded = '1';
+
+ } catch (err) {
+ body.innerHTML = '
Failed to load evidence.
';
+ }
}
@@ -1821,6 +2301,123 @@ document.getElementById('intelOverlay').onclick = function(e) {
if (e.target === this) this.classList.remove('open');
};
+// ── signals ────────────────────────────────────────────────────────────────
+
+function showSignalsView(visible) {
+ document.getElementById('intel-signals-wrap').style.display = visible ? 'block' : 'none';
+ document.getElementById('intel-table-wrap').style.display = visible ? 'none' : '';
+ document.getElementById('intel-pagination').style.display = visible ? 'none' : '';
+ document.getElementById('intel-graph-wrap').style.display = 'none';
+}
+
+
+async function loadSignals() {
+ showSignalsView(true);
+
+ document.getElementById('i-type').parentElement.style.display = 'none';
+ document.getElementById('i-sort-wrap').style.display = 'none';
+
+ 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 => `
${escapeHtml(d)}`).join('');
+ const riskItems = risks.map(r => `
${escapeHtml(r)}`).join('');
+
+ return `
+
+
+
+
+ conf: ${s.confidence}
+ risk: ${s.risk_level}
+ ${s.timeframe}
+
+
${escapeHtml(firstSentence)}
+
Generated ${ts}
+
+
+
+
Summary
+
${escapeHtml(s.summary || '—')}
+
+ ${driverItems ? `
` : ''}
+ ${riskItems ? `
` : ''}
+
+
Data used
+
${predIds.length} predictions · window: 90 days
+
+
+
+
+ `;
+ }).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);
+ }
+}
+
+
+function escapeHtml(s) {
+ return String(s || '').replace(/[&<>"']/g, c => ({
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": '''
+ }[c]));
+}
+
+
// ── sql console ────────────────────────────────────────────────────────────
async function runSql() {
diff --git a/intelligence/db.js b/intelligence/db.js
index 83e125b..ec5e089 100644
--- a/intelligence/db.js
+++ b/intelligence/db.js
@@ -120,6 +120,23 @@ function runColumnMigrations(db) {
CREATE INDEX IF NOT EXISTS idx_worker_events_lookup ON worker_events (worker, completed_at);
`);
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS trade_signals (
+ id INTEGER PRIMARY KEY,
+ company_id INTEGER,
+ signal TEXT,
+ confidence TEXT,
+ timeframe TEXT,
+ risk_level TEXT,
+ risk_factors TEXT,
+ summary TEXT,
+ key_drivers TEXT,
+ supporting_prediction_ids TEXT,
+ generated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ window_days INTEGER
+ );
+ `);
+
// prune rows older than 1 hour so the table doesnt grow unbounded
db.exec(`DELETE FROM worker_events WHERE completed_at < datetime('now', '-1 hour')`);
}
diff --git a/intelligence/index.js b/intelligence/index.js
index ea48d51..d0fe233 100644
--- a/intelligence/index.js
+++ b/intelligence/index.js
@@ -7,6 +7,7 @@ const { runAugorWorker } = require("./augorWorker");
const { ensureCompanyEmbeddings } = require("./embeddings");
const { runConsolidationWorker } = require("./consolidationWorker");
const { runGraphWorker } = require("./graphWorker");
+const { runSignalWorker } = require("./signalWorker");
const configPath = path.resolve(__dirname, "../config.json");
@@ -64,6 +65,11 @@ runGraphWorker(archiveDb, intelligenceDb, config).catch(err => {
process.exit(1);
});
+runSignalWorker(archiveDb, intelligenceDb, config).catch(err => {
+ console.error("[signal] fatal:", err);
+ process.exit(1);
+});
+
process.on("SIGINT", () => {
console.log("[intelligence] shutting down");
process.exit(0);
diff --git a/intelligence/signalWorker.js b/intelligence/signalWorker.js
new file mode 100644
index 0000000..c0ef82b
--- /dev/null
+++ b/intelligence/signalWorker.js
@@ -0,0 +1,255 @@
+const https = require("https");
+const http = require("http");
+
+
+async function runSignalWorker(archiveDb, intelligenceDb, config) {
+ const loopDelay = config.workers?.signalLoopDelayMs ?? 120000;
+ const llmConfig = config.openRouter || {};
+
+ const getCompanies = intelligenceDb.prepare("SELECT * FROM tracked_companies ORDER BY id");
+
+ const getLastSignal = intelligenceDb.prepare(`
+ SELECT generated_at FROM trade_signals WHERE company_id = ? ORDER BY generated_at DESC LIMIT 1
+ `);
+
+ const getFacts = intelligenceDb.prepare(`
+ SELECT claim, type, confidence, confirmation_count
+ FROM company_facts
+ WHERE company_id = ?
+ ORDER BY confirmation_count DESC
+ LIMIT 40
+ `);
+
+ const getPredictions = intelligenceDb.prepare(`
+ SELECT type, direction, magnitude, timeframe, rationale, event_date, id
+ FROM event_predictions
+ WHERE company_id = ?
+ AND created_at >= datetime('now', '-90 days')
+ ORDER BY created_at DESC
+ LIMIT 50
+ `);
+
+ const getRelationships = intelligenceDb.prepare(`
+ SELECT relationship_type, to_entity, confidence, confirmation_count
+ FROM company_relationships
+ WHERE from_company_id = ?
+ ORDER BY confirmation_count DESC
+ LIMIT 20
+ `);
+
+ const deleteSignal = intelligenceDb.prepare(
+ "DELETE FROM trade_signals WHERE company_id = ?"
+ );
+
+ const insertSignal = intelligenceDb.prepare(`
+ INSERT INTO trade_signals
+ (company_id, signal, confidence, timeframe, risk_level, risk_factors, summary, key_drivers, supporting_prediction_ids, window_days)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 90)
+ `);
+
+ const recordEvent = intelligenceDb.prepare(
+ `INSERT INTO worker_events (worker) VALUES ('signal')`
+ );
+
+ const pruneEvents = intelligenceDb.prepare(
+ `DELETE FROM worker_events WHERE worker = 'signal' AND completed_at < datetime('now', '-1 hour')`
+ );
+
+ let pruneCounter = 0;
+
+ while (true) {
+ try {
+ const companies = getCompanies.all();
+
+ // pick the company with the oldest (or missing) signal
+ let target = null;
+ let oldestTs = null;
+
+ for (const company of companies) {
+ const row = getLastSignal.get(company.id);
+
+ if (!row) {
+ // never generated — highest priority
+ target = company;
+ break;
+ }
+
+ if (oldestTs === null || row.generated_at < oldestTs) {
+ oldestTs = row.generated_at;
+ target = company;
+ }
+ }
+
+ if (!target) {
+ await sleep(loopDelay);
+ continue;
+ }
+
+
+ const predictions = getPredictions.all(target.id);
+
+ if (predictions.length < 3) {
+ await sleep(loopDelay);
+ continue;
+ }
+
+ const facts = getFacts.all(target.id);
+ const relationships = getRelationships.all(target.id);
+
+ const prompt = buildPrompt(target.name, facts, relationships, predictions);
+
+ let result;
+ try {
+ result = await callLlm(llmConfig, prompt);
+ } catch (err) {
+ console.error(`[signal] LLM error for ${target.name}:`, err.message);
+ await sleep(loopDelay);
+ continue;
+ }
+
+ if (!result) {
+ console.log(`[signal] ${target.name} — LLM returned null, skipping`);
+ await sleep(loopDelay);
+ continue;
+ }
+
+ const predictionIds = predictions.map(p => p.id);
+
+ const write = intelligenceDb.transaction(() => {
+ deleteSignal.run(target.id);
+ insertSignal.run(
+ target.id,
+ result.signal,
+ result.confidence,
+ result.timeframe,
+ result.risk_level,
+ JSON.stringify(result.risk_factors || []),
+ result.summary,
+ JSON.stringify(result.key_drivers || []),
+ JSON.stringify(predictionIds)
+ );
+ });
+
+ write();
+
+ recordEvent.run();
+ pruneCounter++;
+ if (pruneCounter >= 20) { pruneEvents.run(); pruneCounter = 0; }
+
+ console.log(`[signal] ${target.name} — ${result.signal} (${result.confidence} confidence, ${result.risk_level} risk)`);
+
+ } catch (err) {
+ console.error("[signal] cycle error:", err.message);
+ }
+
+ await sleep(loopDelay);
+ }
+}
+
+
+function buildPrompt(companyName, facts, relationships, predictions) {
+ const factsBlock = facts.length > 0
+ ? facts.map(f => `- ${f.claim} (confirmed ${f.confirmation_count}x)`).join("\n")
+ : "No known facts yet.";
+
+ const relBlock = relationships.length > 0
+ ? relationships.map(r => `- ${r.relationship_type}: ${r.to_entity} (${r.confidence})`).join("\n")
+ : "No known relationships.";
+
+ const predBlock = predictions.map((p, i) =>
+ `${i + 1}. [${p.type}] ${p.direction} / ${p.magnitude} / ${p.timeframe} — ${p.rationale || "no rationale"}`
+ ).join("\n");
+
+ return `You are a financial intelligence analyst generating a trade signal for ${companyName}.
+
+Known facts about ${companyName} (most confirmed first):
+${factsBlock}
+
+Known relationships:
+${relBlock}
+
+Recent event predictions (last 90 days):
+${predBlock}
+
+Generate a trade signal as JSON with this exact shape:
+{
+ "signal": "BUY | HOLD | SELL",
+ "confidence": "low | medium | high | very_high",
+ "timeframe": "short | medium | long",
+ "risk_level": "low | medium | high | very_high",
+ "risk_factors": ["string", ...],
+ "key_drivers": ["string", ...],
+ "summary": "2-3 sentence plain English summary"
+}
+
+Risk factors should be derived from:
+- Supply chain concentration (heavy dependence on single suppliers)
+- Geopolitical exposure (relationships with entities in sensitive regions)
+- Competitive threats (strong competitors gaining ground)
+- Regulatory exposure (themes mentioning regulation or export controls)
+- Negative prediction patterns in recent events
+
+Only output valid JSON. Always respond in English.`;
+}
+
+
+async function callLlm(llmConfig, prompt) {
+ const body = JSON.stringify({
+ model: llmConfig.llmModel || llmConfig.model,
+ messages: [{ role: "user", content: prompt }],
+ temperature: 0.1,
+ });
+
+ const url = new URL("https://openrouter.ai/api/v1/chat/completions");
+
+ const responseText = await httpPost(url, body, {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${llmConfig.apiKey || ""}`,
+ });
+
+ let parsed;
+ try {
+ parsed = JSON.parse(responseText);
+ } catch (_) {
+ throw new Error(`LLM response not JSON: ${responseText.slice(0, 300)}`);
+ }
+
+ const content = parsed.choices?.[0]?.message?.content;
+ if (!content) return null;
+
+ const stripped = content.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim();
+ return JSON.parse(stripped);
+}
+
+
+function httpPost(url, body, headers) {
+ return new Promise((resolve, reject) => {
+ const lib = url.protocol === "https:" ? https : http;
+
+ const req = lib.request({
+ hostname: url.hostname,
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
+ path: url.pathname + url.search,
+ method: "POST",
+ headers: { ...headers, "Content-Length": Buffer.byteLength(body) },
+ }, (res) => {
+ let data = "";
+ res.on("data", chunk => data += chunk);
+ res.on("end", () => {
+ if (res.statusCode >= 200 && res.statusCode < 300) resolve(data);
+ else reject(new Error(`LLM ${res.statusCode}: ${data.slice(0, 300)}`));
+ });
+ });
+
+ req.on("error", reject);
+ req.write(body);
+ req.end();
+ });
+}
+
+
+function sleep(ms) {
+ return new Promise(r => setTimeout(r, ms));
+}
+
+module.exports = { runSignalWorker };
diff --git a/src/routes/admin.js b/src/routes/admin.js
index 662812b..795ad00 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -364,6 +364,37 @@ async function adminRoutes(fastify) {
});
+ // trade signals
+ fastify.get('/admin/api/intelligence/signals', async (request, reply) => {
+ if (!checkAuth(request, reply)) return;
+ const db = getIntelligenceDb();
+ if (!db) return [];
+
+ let rows;
+ try {
+ rows = db.prepare(`
+ SELECT ts.*, tc.name as company_name, tc.ticker
+ FROM trade_signals ts
+ JOIN tracked_companies tc ON tc.id = ts.company_id
+ ORDER BY ts.generated_at DESC
+ `).all();
+ } catch (_) {
+ return [];
+ }
+
+ return rows;
+ });
+
+ fastify.delete('/admin/api/intelligence/signals/:company_id', async (request, reply) => {
+ if (!checkAuth(request, reply)) return;
+ const db = getIntelligenceDb();
+ if (!db) { reply.code(503); return { error: 'intelligence db unavailable' }; }
+
+ const companyId = parseInt(request.params.company_id, 10);
+ db.prepare('DELETE FROM trade_signals WHERE company_id = ?').run(companyId);
+ return { ok: true };
+ });
+
// per-company facts for the graph sidebar
fastify.get('/admin/api/intelligence/facts/:company_id', async (request, reply) => {
if (!checkAuth(request, reply)) return;
@@ -381,6 +412,89 @@ async function adminRoutes(fastify) {
});
+ // evidence backing a single graph edge — the relationship row itself,
+ // the consolidated facts that produced it, and the source events+articles.
+ fastify.get('/admin/api/intelligence/edge-evidence', async (request, reply) => {
+ if (!checkAuth(request, reply)) return;
+ const idb = getIntelligenceDb();
+ if (!idb) return { edge: null, facts: [], events: [] };
+
+ const fromId = parseInt(request.query.from_id, 10);
+ const toId = parseInt(request.query.to_id, 10);
+ const type = (request.query.type || '').toLowerCase();
+
+ if (!fromId || !toId || !type) {
+ reply.code(400);
+ return { error: 'missing from_id / to_id / type' };
+ }
+
+ // direct edge — try the exact match first
+ let edge = idb.prepare(`
+ SELECT * FROM company_relationships
+ WHERE from_company_id = ? AND to_company_id = ? AND relationship_type = ?
+ `).get(fromId, toId, type);
+
+ // investor edges are stored as 'dependency' in the reverse direction too —
+ // the frontend normalizes dependency→investor, so lookup the flipped row
+ if (!edge && type === 'investor') {
+ edge = idb.prepare(`
+ SELECT * FROM company_relationships
+ WHERE from_company_id = ? AND to_company_id = ? AND relationship_type = 'dependency'
+ `).get(toId, fromId);
+ }
+
+ const toCompany = idb.prepare(`SELECT name FROM tracked_companies WHERE id = ?`).get(toId);
+ const toName = toCompany ? toCompany.name : null;
+
+ // backing facts — relationship-type facts on the source company that mention the target
+ let facts = [];
+ if (toName) {
+ facts = idb.prepare(`
+ SELECT id, claim, confidence, confirmation_count, supporting_event_ids, first_seen_at, last_seen_at
+ FROM company_facts
+ WHERE company_id = ? AND type = 'relationship' AND claim LIKE ?
+ ORDER BY confirmation_count DESC
+ LIMIT 20
+ `).all(fromId, `%${toName}%`);
+ }
+
+ // merge event ids from the edge row + all backing facts
+ const eventIds = new Set();
+ if (edge && edge.supporting_event_ids) {
+ try { JSON.parse(edge.supporting_event_ids).forEach(id => eventIds.add(id)); } catch (_) {}
+ }
+ for (const f of facts) {
+ try { JSON.parse(f.supporting_event_ids || '[]').forEach(id => eventIds.add(id)); } catch (_) {}
+ }
+
+ // resolve events + articles from archive.sqlite
+ const events = [];
+ if (eventIds.size > 0) {
+ const ids = [...eventIds];
+ const placeholders = ids.map(() => '?').join(',');
+ const eventRows = db.prepare(`
+ SELECT id, title, created_at FROM events
+ WHERE id IN (${placeholders})
+ ORDER BY created_at DESC
+ `).all(...ids);
+
+ const artStmt = db.prepare(`
+ SELECT id, title, source, pub_date, url
+ FROM articles
+ WHERE event_id = ?
+ ORDER BY COALESCE(pub_date, ingested_at) DESC
+ LIMIT 8
+ `);
+
+ for (const ev of eventRows) {
+ events.push({ ...ev, articles: artStmt.all(ev.id) });
+ }
+ }
+
+ return { edge, facts, events };
+ });
+
+
// raw sql console — supports multiple statements separated by ;
fastify.post('/admin/api/sql', async (request, reply) => {
if (!checkAuth(request, reply)) return;