add signal generation feature; implement signals view in admin panel

This commit is contained in:
ImBenji
2026-04-23 22:00:36 +01:00
parent 53058ab94d
commit 9d89dc95e4
5 changed files with 1009 additions and 20 deletions
+617 -20
View File
@@ -602,18 +602,165 @@
}
.graph-conn-row {
padding: 5px 0;
font-size: 12px;
border-bottom: 1px solid var(--border-light);
}
.graph-conn-row:last-child {
border-bottom: none;
}
.graph-conn-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 6px 0;
cursor: pointer;
user-select: none;
}
.graph-conn-head:hover .graph-conn-label { color: var(--accent); }
.graph-conn-right {
display: flex;
align-items: center;
gap: 8px;
}
.graph-conn-toggle {
color: var(--muted-dark);
font-size: 10px;
width: 10px;
display: inline-block;
}
.graph-conn-row.expanded .graph-conn-toggle { color: var(--accent); }
.graph-conn-sector {
color: var(--muted-dark);
font-size: 11px;
}
.graph-conn-body {
display: none;
padding: 4px 0 10px 4px;
border-left: 2px solid var(--border);
margin-left: 2px;
padding-left: 10px;
}
.graph-evidence-loading,
.graph-evidence-error,
.graph-evidence-empty {
color: var(--muted-dark);
font-size: 11px;
padding: 4px 0;
}
.graph-evidence-error { color: var(--destructive-fg); }
.graph-evidence-stats {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--muted);
padding: 2px 0 6px;
}
.graph-evidence-badge {
display: inline-block;
padding: 1px 7px;
border-radius: 10px;
background: var(--border);
color: var(--foreground);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.graph-evidence-section {
margin-top: 8px;
margin-bottom: 4px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--muted-dark);
}
.graph-evidence-fact {
padding: 5px 0;
border-bottom: 1px dashed var(--border-light);
}
.graph-evidence-fact:last-child { border-bottom: none; }
.graph-evidence-claim {
font-size: 11.5px;
color: var(--foreground);
line-height: 1.4;
}
.graph-evidence-meta {
font-size: 10.5px;
color: var(--muted-dark);
margin-top: 2px;
}
.graph-evidence-event {
margin: 4px 0;
font-size: 11px;
}
.graph-evidence-event > summary {
cursor: pointer;
list-style: none;
display: flex;
justify-content: space-between;
gap: 8px;
padding: 4px 0;
color: var(--foreground);
}
.graph-evidence-event > summary::-webkit-details-marker { display: none; }
.graph-evidence-event > summary:hover { color: var(--accent); }
.graph-evidence-event-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.graph-evidence-event-id {
color: var(--muted-dark);
font-size: 10px;
flex-shrink: 0;
}
.graph-evidence-articles {
list-style: none;
padding: 4px 0 6px 10px;
margin: 0;
border-left: 1px solid var(--border-light);
}
.graph-evidence-articles li {
padding: 4px 0;
}
.graph-evidence-articles a {
color: var(--accent);
text-decoration: none;
font-size: 11px;
line-height: 1.4;
display: block;
}
.graph-evidence-articles a:hover { text-decoration: underline; }
.graph-evidence-article-meta {
color: var(--muted-dark);
font-size: 10px;
margin-top: 1px;
}
.graph-empty-msg {
color: var(--muted-dark);
font-size: 13px;
@@ -628,11 +775,177 @@
font-family: inherit;
}
.graph-node-label-untracked {
fill: var(--muted-dark);
font-size: 10px;
}
.ig-label-bg {
fill: var(--bg-subtle);
fill-opacity: 0.8;
}
.graph-conn-row.untracked .graph-conn-head {
cursor: default;
}
.graph-conn-row.untracked .graph-conn-head:hover .graph-conn-label {
color: inherit;
}
.graph-conn-row.untracked .graph-conn-label {
color: var(--muted);
}
.graph-conn-row.untracked .graph-conn-sector {
font-style: italic;
}
/* ── signal cards ── */
#intel-signals-wrap {
display: none;
}
.signal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 14px;
}
.signal-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
cursor: pointer;
transition: border-color .15s;
}
.signal-card:hover { border-color: var(--muted-dark); }
.signal-card-glance {
padding: 16px;
}
.signal-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 10px;
gap: 8px;
}
.signal-company {
font-size: 14px;
font-weight: 600;
color: var(--foreground);
line-height: 1.2;
}
.signal-ticker {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
}
.signal-badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 5px;
font-size: 12px;
font-weight: 700;
letter-spacing: .04em;
flex-shrink: 0;
}
.signal-badge.BUY { background: rgba(20,83,45,.6); color: #4ade80; border: 1px solid rgba(74,222,128,.2); }
.signal-badge.HOLD { background: rgba(92,66,14,.6); color: #fbbf24; border: 1px solid rgba(251,191,36,.2); }
.signal-badge.SELL { background: rgba(127,29,29,.6); color: #f87171; border: 1px solid rgba(248,113,113,.2); }
.signal-tags {
display: flex;
gap: 5px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.signal-tag {
background: var(--bg-subtle);
border: 1px solid var(--border);
color: var(--muted);
padding: 2px 7px;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
}
.signal-summary {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
margin-bottom: 8px;
}
.signal-ts {
font-size: 10.5px;
color: var(--muted-dark);
}
.signal-card-detail {
display: none;
padding: 0 16px 16px;
border-top: 1px solid var(--border-light);
padding-top: 14px;
}
.signal-card.expanded .signal-card-detail { display: block; }
.signal-detail-section {
margin-bottom: 12px;
}
.signal-detail-label {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--muted-dark);
font-weight: 600;
margin-bottom: 5px;
}
.signal-detail-section ul {
padding-left: 16px;
margin: 0;
font-size: 12px;
color: var(--muted);
line-height: 1.7;
}
.signal-detail-section p {
font-size: 12px;
color: var(--muted);
line-height: 1.6;
margin: 0;
}
.signal-regen-btn {
margin-top: 10px;
font-size: 12px;
padding: 5px 12px;
background: transparent;
border-color: var(--border);
color: var(--muted);
}
.signal-regen-btn:hover { color: var(--foreground); background: var(--bg-subtle); }
.signal-empty {
color: var(--muted-dark);
font-size: 13px;
padding: 40px 0;
text-align: center;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
</head>
@@ -740,6 +1053,7 @@
<select id="i-view">
<option value="knowledge">Knowledge</option>
<option value="predictions">Predictions</option>
<option value="signals">Signals</option>
<option value="graph">Graph</option>
</select>
</label>
@@ -773,6 +1087,12 @@
<button id="iNextBtn">Next →</button>
</div>
<!-- signals view -->
<div id="intel-signals-wrap">
<div id="intel-signals-grid" class="signal-grid"></div>
<div id="intel-signals-empty" class="signal-empty" style="display:none">No signals generated yet — waiting for the signal worker to run.</div>
</div>
<!-- graph view -->
<div id="intel-graph-wrap">
<div id="intel-graph-layout">
@@ -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 += `<div class="graph-group-title">${type} (${list.length})</div>`;
for (const o of list) {
html += `<div class="graph-conn-row"><span>${escapeHtmlG(o.label)}</span><span class="graph-conn-sector">${escapeHtmlG(o.sector)}</span></div>`;
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">${escapeHtmlG(o.label)}</span>
<span class="graph-conn-right">
<span class="graph-conn-sector">${escapeHtmlG(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">${escapeHtmlG(o.label)}</span>
<span class="graph-conn-right">
<span class="graph-conn-sector">untracked</span>
</span>
</div>
</div>`;
}
}
}
@@ -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 = '<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">${escapeHtmlG(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">${escapeHtmlG(f.claim)}</div>
<div class="graph-evidence-meta">${escapeHtmlG(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">${escapeHtmlG(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="${escapeHtmlG(a.url || '#')}" target="_blank" rel="noopener">${escapeHtmlG(a.title || a.url || 'untitled')}</a>
<div class="graph-evidence-article-meta">${escapeHtmlG(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>';
}
}
@@ -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 => `<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: ${s.confidence}</span>
<span class="signal-tag">risk: ${s.risk_level}</span>
<span class="signal-tag">${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);
}
}
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
// ── sql console ────────────────────────────────────────────────────────────
async function runSql() {