add signal generation feature; implement signals view in admin panel
This commit is contained in:
parent
53058ab94d
commit
9d89dc95e4
5 changed files with 1009 additions and 20 deletions
623
admin.html
623
admin.html
|
|
@ -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;
|
||||
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 · 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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[c]));
|
||||
}
|
||||
|
||||
|
||||
// ── sql console ────────────────────────────────────────────────────────────
|
||||
|
||||
async function runSql() {
|
||||
|
|
|
|||
|
|
@ -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')`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
255
intelligence/signalWorker.js
Normal file
255
intelligence/signalWorker.js
Normal file
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue