refactor: update worker commands and add new scripts for API rebuilding and queue feeding

This commit is contained in:
ImBenji
2026-04-27 15:03:15 +01:00
parent b4df02da0d
commit 04966fac55
21 changed files with 58 additions and 13 deletions
+263
View File
@@ -0,0 +1,263 @@
const https = require("https");
const http = require("http");
const { findMatchedCompaniesByEmbedding } = require("./embeddings");
async function runAugorWorker(archiveDb, intelligenceDb, config) {
const loopDelay = config.workers?.augorLoopDelayMs ?? 1500;
const llmConfig = config.openRouter || {};
const getPending = intelligenceDb.prepare(`
SELECT * FROM article_queue WHERE status = 'pending' LIMIT 1
`);
const recordEvent = intelligenceDb.prepare(
`INSERT INTO worker_events (worker) VALUES ('augor')`
);
const pruneEvents = intelligenceDb.prepare(
`DELETE FROM worker_events WHERE worker = 'augor' AND completed_at < datetime('now', '-1 hour')`
);
let pruneCounter = 0;
const setStatus = intelligenceDb.prepare(`
UPDATE article_queue SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE article_id = ?
`);
const getEventArticleIds = archiveDb.prepare(
"SELECT id FROM articles WHERE event_id = ?"
);
const setStatusByArticleId = intelligenceDb.prepare(`
UPDATE article_queue SET status = 'processed', updated_at = CURRENT_TIMESTAMP
WHERE article_id = ? AND status = 'pending'
`);
const deleteKnowledge = intelligenceDb.prepare(
"DELETE FROM event_knowledge WHERE event_id = ?"
);
const deletePredictions = intelligenceDb.prepare(
"DELETE FROM event_predictions WHERE event_id = ?"
);
const insertKnowledge = intelligenceDb.prepare(`
INSERT INTO event_knowledge (event_id, company_id, type, data, event_date)
VALUES (?, ?, ?, ?, ?)
`);
const insertPrediction = intelligenceDb.prepare(`
INSERT INTO event_predictions (event_id, company_id, type, direction, magnitude, timeframe, rationale, event_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const getEventDate = archiveDb.prepare(`
SELECT pub_date_effective FROM articles
WHERE event_id = ? AND pub_date_effective IS NOT NULL
ORDER BY pub_date_effective ASC LIMIT 1
`);
const getCompanyFacts = intelligenceDb.prepare(`
SELECT claim, confidence, confirmation_count FROM company_facts
WHERE company_id = ? ORDER BY confirmation_count DESC LIMIT 30
`);
while (true) {
try {
const queueRow = getPending.get();
if (!queueRow) {
await sleep(loopDelay);
continue;
}
const article = archiveDb.prepare(`
SELECT id, event_id, content, has_embedding
FROM articles WHERE id = ?
`).get(queueRow.article_id);
if (!article || !article.content || !article.has_embedding || !article.event_id) {
setStatus.run("skipped", queueRow.article_id);
continue;
}
const eventId = article.event_id;
const event = archiveDb.prepare("SELECT * FROM events WHERE id = ?").get(eventId);
if (!event) {
setStatus.run("skipped", queueRow.article_id);
continue;
}
const eventArticles = archiveDb.prepare(`
SELECT id, title, description, content
FROM articles
WHERE event_id = ? AND content IS NOT NULL AND content != ''
ORDER BY id ASC
LIMIT 25
`).all(eventId);
const eventArticleIds = eventArticles.map(a => a.id);
const matchedCompanies = findMatchedCompaniesByEmbedding(
eventArticleIds, archiveDb, intelligenceDb, config
);
if (matchedCompanies.length === 0) {
for (const r of getEventArticleIds.all(eventId)) setStatusByArticleId.run(r.id);
console.log(`[augor] event ${eventId} — no company match, skipped`);
continue;
}
deleteKnowledge.run(eventId);
deletePredictions.run(eventId);
const eventDateRow = getEventDate.get(eventId);
const eventDate = eventDateRow ? eventDateRow.pub_date_effective : null;
const articleText = eventArticles.map((a, i) => {
const body = (a.content || a.description || "").slice(0, 2000);
return `[Article ${i + 1}] ${a.title}\n${body}`;
}).join("\n\n---\n\n");
for (const company of matchedCompanies) {
try {
const facts = getCompanyFacts.all(company.id);
let factsBlock = null;
if (facts.length > 0) {
const lines = facts.map(f => `- ${f.claim} (confirmed ${f.confirmation_count} times)`).join("\n");
factsBlock = `Known facts about ${company.name}:\n${lines}`;
}
const result = await callLlm(llmConfig, buildPrompt(company.name, event.title, articleText, factsBlock));
if (result) {
const writeAll = intelligenceDb.transaction(() => {
for (const r of (result.knowledge?.relationships || [])) {
insertKnowledge.run(eventId, company.id, "relationship", JSON.stringify(r), eventDate);
}
for (const t of (result.knowledge?.themes || [])) {
insertKnowledge.run(eventId, company.id, "theme", JSON.stringify(t), eventDate);
}
for (const f of (result.knowledge?.factors || [])) {
insertKnowledge.run(eventId, company.id, "factor", JSON.stringify(f), eventDate);
}
for (const p of (result.predictions || [])) {
insertPrediction.run(eventId, company.id, p.type, p.direction, p.magnitude, p.timeframe, p.rationale, eventDate);
}
});
writeAll();
}
} catch (llmErr) {
console.error(`[augor] LLM error for ${company.name} on event ${eventId}:`, llmErr.message);
}
}
for (const r of getEventArticleIds.all(eventId)) setStatusByArticleId.run(r.id);
recordEvent.run();
pruneCounter++;
if (pruneCounter >= 100) { pruneEvents.run(); pruneCounter = 0; }
console.log(`[augor] processed event ${eventId} (${matchedCompanies.length} companies, ${eventArticles.length} articles)`);
} catch (err) {
console.error("[augor] error:", err.message);
await sleep(loopDelay);
}
}
}
function buildPrompt(companyName, eventTitle, articleText, factsBlock) {
const factsPart = factsBlock ? `${factsBlock}\n\n` : "";
return `You are a financial intelligence analyst focused on ${companyName}. Always respond in English regardless of the language of the input articles.
${factsPart}Assess the impact of the following news event on ${companyName} given what you already know about the company.
Event: ${eventTitle}
${articleText}
Return JSON only — no explanation. Shape:
{
"knowledge": {
"relationships": [
{ "type": "supplier|customer|competitor", "entity": "string", "confidence": "high|medium|low", "evidence": "string" }
],
"themes": [
{ "theme": "string", "direction": "increasing|stable|decreasing", "evidence": "string" }
],
"factors": [
{ "factor": "string", "relationship": "string", "evidence": "string" }
]
},
"predictions": [
{ "type": "market_share|stock_price|competitive_position|other", "direction": "positive|negative|neutral", "magnitude": "high|medium|low", "timeframe": "short|medium|long", "rationale": "string" }
]
}
Only include claims directly supported by the articles. Use empty arrays if nothing applies.`;
}
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 (e) {
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 = { runAugorWorker };
+284
View File
@@ -0,0 +1,284 @@
// consolidationWorker.js
// reads from event_knowledge (never writes to it) and maintains company_facts
// runs on a slow loop — one full pass over all tracked companies per cycle
const https = require("https");
const http = require("http");
async function runConsolidationWorker(archiveDb, intelligenceDb, config) {
const loopDelay = config.workers?.consolidationLoopDelayMs ?? 60000;
const llmConfig = config.openRouter || {};
const getCompanies = intelligenceDb.prepare("SELECT * FROM tracked_companies");
const recordEvent = intelligenceDb.prepare(
`INSERT INTO worker_events (worker) VALUES ('consolidation')`
);
const pruneEvents = intelligenceDb.prepare(
`DELETE FROM worker_events WHERE worker = 'consolidation' AND completed_at < datetime('now', '-1 hour')`
);
const getKnowledge = intelligenceDb.prepare(`
SELECT * FROM event_knowledge WHERE company_id = ? ORDER BY event_date ASC
`);
const getExistingFirstSeen = intelligenceDb.prepare(`
SELECT claim, first_seen_at FROM company_facts WHERE company_id = ?
`);
const deleteCompanyFacts = intelligenceDb.prepare(
"DELETE FROM company_facts WHERE company_id = ?"
);
const insertFact = intelligenceDb.prepare(`
INSERT INTO company_facts
(company_id, type, claim, confidence, confirmation_count, first_seen_at, last_seen_at, last_event_id, supporting_event_ids)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
// stale = seen only once and not reinforced in 90 days
const deleteStaleFacts = intelligenceDb.prepare(`
DELETE FROM company_facts
WHERE confirmation_count = 1
AND last_seen_at < datetime('now', '-90 days')
`);
while (true) {
try {
const companies = getCompanies.all();
let pruneCounter = 0;
for (const company of companies) {
try {
await processCompany(
company, intelligenceDb, llmConfig,
getKnowledge, getExistingFirstSeen, deleteCompanyFacts, insertFact
);
recordEvent.run();
pruneCounter++;
if (pruneCounter >= 50) { pruneEvents.run(); pruneCounter = 0; }
} catch (err) {
console.error(`[consolidation] error on ${company.name}:`, err.message);
}
}
const staleResult = deleteStaleFacts.run();
if (staleResult.changes > 0) {
console.log(`[consolidation] pruned ${staleResult.changes} stale facts`);
}
console.log("[consolidation] cycle complete");
} catch (err) {
console.error("[consolidation] cycle error:", err.message);
}
await sleep(loopDelay);
}
}
async function processCompany(company, intelligenceDb, llmConfig, getKnowledge, getExistingFirstSeen, deleteCompanyFacts, insertFact) {
const rows = getKnowledge.all(company.id);
if (rows.length === 0) return;
const rawClaims = [];
for (const row of rows) {
let data;
try { data = JSON.parse(row.data); } catch (_) { continue; }
const text = extractClaimText(row.type, data);
if (!text) continue;
rawClaims.push({
text,
type: row.type,
eventId: row.event_id,
eventDate: row.event_date || row.created_at,
});
}
if (rawClaims.length === 0) return;
let groups;
try {
groups = await normalizeClaims(company.name, rawClaims, llmConfig);
} catch (err) {
console.error(`[consolidation] LLM failed for ${company.name}:`, err.message);
return;
}
if (!groups || groups.length === 0) return;
// save first_seen_at before we wipe the rows
const priorFirstSeen = {};
for (const f of getExistingFirstSeen.all(company.id)) {
priorFirstSeen[f.claim] = f.first_seen_at;
}
const writeAll = intelligenceDb.transaction(() => {
deleteCompanyFacts.run(company.id);
for (const group of groups) {
const { canonical, type, eventIds, dates } = group;
const distinctEventIds = [...new Set(eventIds)];
const count = distinctEventIds.size || distinctEventIds.length;
const sortedDates = dates.filter(Boolean).sort();
const firstSeen = priorFirstSeen[canonical] || sortedDates[0] || new Date().toISOString();
const lastSeen = sortedDates[sortedDates.length - 1] || firstSeen;
const lastEventId = eventIds[eventIds.length - 1];
const confidence = countToConfidence(count);
insertFact.run(
company.id,
type,
canonical,
confidence,
count,
firstSeen,
lastSeen,
lastEventId,
JSON.stringify(distinctEventIds)
);
}
});
writeAll();
console.log(`[consolidation] ${company.name}: ${groups.length} distinct facts`);
}
function extractClaimText(type, data) {
if (type === "relationship") {
if (!data.entity) return null;
return `${data.entity} is a ${data.type || "partner"}`;
}
if (type === "theme") {
if (!data.theme) return null;
return `${data.theme} is ${data.direction || "relevant"}`;
}
if (type === "factor") {
if (!data.factor) return null;
return `${data.factor}: ${data.relationship || "relevant"}`;
}
return null;
}
function countToConfidence(count) {
if (count >= 8) return "very_high";
if (count >= 4) return "high";
if (count >= 2) return "medium";
return "low";
}
async function normalizeClaims(companyName, rawClaims, llmConfig) {
// cap to keep the prompt manageable
const capped = rawClaims.slice(0, 120);
const lines = capped.map((c, i) => `${i + 1}. [${c.type}] ${c.text}`).join("\n");
const prompt = `You are normalizing knowledge claims about ${companyName}. Always respond in English regardless of the language of the input articles.
Group semantically equivalent claims and provide a canonical short form for each group (lowercase, no punctuation).
Claims:
${lines}
Return JSON only — no explanation. Shape:
{
"groups": [
{ "canonical": "canonical claim text", "type": "relationship|theme|factor", "indices": [1, 2] }
]
}
Rules:
- canonical must be lowercase with no punctuation marks
- merge claims that mean the same thing even if worded differently
- each index appears in exactly one group
- pick the type that best fits the group`;
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, 200)}`);
}
const content = parsed.choices?.[0]?.message?.content;
if (!content) return [];
const stripped = content.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim();
const result = JSON.parse(stripped);
const groups = [];
for (const g of (result.groups || [])) {
const indices = (g.indices || [])
.map(i => i - 1)
.filter(i => i >= 0 && i < capped.length);
if (indices.length === 0) continue;
const eventIds = indices.map(i => capped[i].eventId);
const dates = indices.map(i => capped[i].eventDate);
const type = g.type || capped[indices[0]].type;
groups.push({ canonical: g.canonical, type, eventIds, dates });
}
return groups;
}
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 = { runConsolidationWorker };
+327
View File
@@ -0,0 +1,327 @@
const Database = require("better-sqlite3");
const sqliteVec = require("sqlite-vec");
let archiveDb = null;
let intelligenceDb = null;
function getArchiveDb(dbPath) {
if (!archiveDb) {
archiveDb = new Database(dbPath, { readonly: true });
sqliteVec.load(archiveDb);
archiveDb.pragma("journal_mode = WAL");
}
return archiveDb;
}
function getIntelligenceDb(dbPath) {
if (!intelligenceDb) {
intelligenceDb = new Database(dbPath);
intelligenceDb.pragma("journal_mode = WAL");
}
return intelligenceDb;
}
function runMigrations(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS cursors (
key TEXT PRIMARY KEY,
value INTEGER
);
CREATE TABLE IF NOT EXISTS tracked_companies (
id INTEGER PRIMARY KEY,
name TEXT,
ticker TEXT,
aliases TEXT
);
CREATE TABLE IF NOT EXISTS article_queue (
id INTEGER PRIMARY KEY,
article_id INTEGER UNIQUE,
status TEXT DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME
);
CREATE TABLE IF NOT EXISTS event_knowledge (
id INTEGER PRIMARY KEY,
event_id INTEGER,
company_id INTEGER,
type TEXT,
data TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS company_embeddings (
company_id INTEGER PRIMARY KEY,
embedding BLOB NOT NULL,
model TEXT NOT NULL,
generated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS event_predictions (
id INTEGER PRIMARY KEY,
event_id INTEGER,
company_id INTEGER,
type TEXT,
direction TEXT,
magnitude TEXT,
timeframe TEXT,
rationale TEXT,
event_date TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS company_facts (
id INTEGER PRIMARY KEY,
company_id INTEGER,
type TEXT,
claim TEXT,
confidence TEXT,
confirmation_count INTEGER DEFAULT 1,
first_seen_at DATETIME,
last_seen_at DATETIME,
last_event_id INTEGER,
supporting_event_ids TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_company_facts_unique ON company_facts (company_id, claim);
CREATE TABLE IF NOT EXISTS company_relationships (
id INTEGER PRIMARY KEY,
from_company_id INTEGER,
relationship_type TEXT,
to_entity TEXT,
to_company_id INTEGER,
confidence TEXT,
confirmation_count INTEGER DEFAULT 1,
first_seen_at DATETIME,
last_seen_at DATETIME,
supporting_event_ids TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_company_relationships_unique
ON company_relationships (from_company_id, relationship_type, to_entity);
`);
}
function runColumnMigrations(db) {
try { db.exec("ALTER TABLE event_predictions ADD COLUMN event_date TEXT"); } catch (_) {}
try { db.exec("ALTER TABLE event_knowledge ADD COLUMN event_date TEXT"); } catch (_) {}
db.exec(`
CREATE TABLE IF NOT EXISTS worker_events (
id INTEGER PRIMARY KEY,
worker TEXT NOT NULL,
completed_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
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')`);
}
function seedCompanies(db) {
const exists = db.prepare("SELECT id FROM tracked_companies WHERE name = ?");
const insert = db.prepare(
"INSERT INTO tracked_companies (name, ticker, aliases) VALUES (?, ?, ?)"
);
const companies = [
// semiconductors
{ name: "NVIDIA", ticker: "NVDA", aliases: ["Nvidia Corporation"] },
{ name: "TSMC", ticker: "TSM", aliases: ["Taiwan Semiconductor", "Taiwan Semiconductor Manufacturing Company"] },
{ name: "ASML", ticker: "ASML", aliases: ["ASML Holding"] },
{ name: "Intel", ticker: "INTC", aliases: ["Intel Corporation"] },
{ name: "AMD", ticker: "AMD", aliases: ["Advanced Micro Devices"] },
{ name: "Qualcomm", ticker: "QCOM", aliases: ["Qualcomm Incorporated"] },
{ name: "Broadcom", ticker: "AVGO", aliases: ["Broadcom Inc"] },
{ name: "Micron", ticker: "MU", aliases: ["Micron Technology"] },
{ name: "Texas Instruments", ticker: "TXN", aliases: ["TI"] },
{ name: "Applied Materials", ticker: "AMAT", aliases: ["Applied Materials Inc"] },
{ name: "Lam Research", ticker: "LRCX", aliases: ["Lam Research Corporation"] },
{ name: "KLA Corporation", ticker: "KLAC", aliases: ["KLA"] },
{ name: "Samsung", ticker: "005930.KS", aliases: ["Samsung Electronics", "Samsung Group"] },
{ name: "SK Hynix", ticker: "000660.KS", aliases: ["Hynix"] },
// big tech / cloud
{ name: "Microsoft", ticker: "MSFT", aliases: ["Microsoft Corporation"] },
{ name: "Apple", ticker: "AAPL", aliases: ["Apple Inc"] },
{ name: "Alphabet", ticker: "GOOGL", aliases: ["Google", "Google LLC", "DeepMind"] },
{ name: "Amazon", ticker: "AMZN", aliases: ["Amazon Web Services", "AWS"] },
{ name: "Meta", ticker: "META", aliases: ["Meta Platforms", "Facebook"] },
{ name: "Tesla", ticker: "TSLA", aliases: ["Tesla Inc", "Tesla Motors"] },
// AI / infrastructure
{ name: "OpenAI", ticker: "OPENAI", aliases: ["Open AI"] },
{ name: "Anthropic", ticker: "ANTHROPIC", aliases: [] },
{ name: "xAI", ticker: "XAI", aliases: ["x.AI"] },
{ name: "Palantir", ticker: "PLTR", aliases: ["Palantir Technologies"] },
{ name: "Super Micro Computer", ticker: "SMCI", aliases: ["Supermicro", "SMCI"] },
{ name: "Arista Networks", ticker: "ANET", aliases: ["Arista"] },
// networking / hardware
{ name: "Cisco", ticker: "CSCO", aliases: ["Cisco Systems"] },
{ name: "Marvell Technology", ticker: "MRVL", aliases: ["Marvell"] },
{ name: "Arm Holdings", ticker: "ARM", aliases: ["Arm", "ARM Ltd"] },
// enterprise software
{ name: "Oracle", ticker: "ORCL", aliases: ["Oracle Corporation"] },
{ name: "Salesforce", ticker: "CRM", aliases: ["Salesforce Inc"] },
{ name: "SAP", ticker: "SAP", aliases: ["SAP SE"] },
{ name: "ServiceNow", ticker: "NOW", aliases: [] },
// storage / infra
{ name: "Western Digital", ticker: "WDC", aliases: ["WD", "Western Digital Corporation"] },
{ name: "Seagate", ticker: "STX", aliases: ["Seagate Technology"] },
{ name: "Pure Storage", ticker: "PSTG", aliases: [] },
// more AI labs
{ name: "Mistral AI", ticker: "MISTRAL", aliases: ["Mistral"] },
{ name: "Cohere", ticker: "COHERE", aliases: [] },
{ name: "DeepSeek", ticker: "DEEPSEEK", aliases: ["DeepSeek AI"] },
{ name: "Stability AI", ticker: "STABILITY", aliases: [] },
{ name: "Inflection AI", ticker: "INFLECTION", aliases: [] },
{ name: "Scale AI", ticker: "SCALEAI", aliases: ["Scale"] },
{ name: "Hugging Face", ticker: "HF", aliases: [] },
{ name: "Cerebras Systems", ticker: "CBRS", aliases: ["Cerebras"] },
{ name: "Groq", ticker: "GROQ", aliases: [] },
// chinese tech
{ name: "Alibaba", ticker: "BABA", aliases: ["Alibaba Group", "Taobao", "Alipay"] },
{ name: "Tencent", ticker: "0700.HK", aliases: ["Tencent Holdings", "WeChat"] },
{ name: "Baidu", ticker: "BIDU", aliases: ["Baidu Inc"] },
{ name: "Huawei", ticker: "HUAWEI", aliases: ["Huawei Technologies"] },
{ name: "ByteDance", ticker: "BYTEDANCE", aliases: ["TikTok", "Douyin"] },
{ name: "Xiaomi", ticker: "1810.HK", aliases: [] },
{ name: "SMIC", ticker: "688981.SS", aliases: ["Semiconductor Manufacturing International Corporation"] },
{ name: "DJI", ticker: "DJI", aliases: ["Da-Jiang Innovations"] },
// defense / aerospace
{ name: "Lockheed Martin", ticker: "LMT", aliases: ["Lockheed"] },
{ name: "Raytheon Technologies", ticker: "RTX", aliases: ["RTX", "Raytheon"] },
{ name: "Northrop Grumman", ticker: "NOC", aliases: ["Northrop"] },
{ name: "Boeing", ticker: "BA", aliases: ["Boeing Company"] },
{ name: "General Dynamics", ticker: "GD", aliases: [] },
{ name: "BAE Systems", ticker: "BA.L", aliases: ["BAE"] },
{ name: "L3Harris Technologies", ticker: "LHX", aliases: ["L3Harris"] },
{ name: "Leidos", ticker: "LDOS", aliases: [] },
{ name: "SAIC", ticker: "SAIC", aliases: ["Science Applications International Corporation"] },
{ name: "Booz Allen Hamilton", ticker: "BAH", aliases: ["Booz Allen"] },
{ name: "Thales Group", ticker: "HO.PA", aliases: ["Thales"] },
{ name: "Airbus", ticker: "AIR.PA", aliases: ["Airbus SE"] },
{ name: "Leonardo", ticker: "LDO.MI", aliases: ["Leonardo SpA"] },
{ name: "Rheinmetall", ticker: "RHM.DE", aliases: [] },
// telecom
{ name: "Ericsson", ticker: "ERIC", aliases: ["Telefonaktiebolaget LM Ericsson"] },
{ name: "Nokia", ticker: "NOK", aliases: ["Nokia Corporation"] },
{ name: "AT&T", ticker: "T", aliases: [] },
{ name: "Verizon", ticker: "VZ", aliases: ["Verizon Communications"] },
{ name: "T-Mobile", ticker: "TMUS", aliases: ["T-Mobile US"] },
{ name: "Deutsche Telekom", ticker: "DTE.DE", aliases: [] },
{ name: "SoftBank", ticker: "9984.T", aliases: ["SoftBank Group"] },
// finance / banking
{ name: "JPMorgan Chase", ticker: "JPM", aliases: ["JPMorgan", "JP Morgan"] },
{ name: "Goldman Sachs", ticker: "GS", aliases: ["Goldman"] },
{ name: "BlackRock", ticker: "BLK", aliases: [] },
{ name: "Visa", ticker: "V", aliases: [] },
{ name: "Mastercard", ticker: "MA", aliases: [] },
{ name: "Morgan Stanley", ticker: "MS", aliases: [] },
{ name: "Citigroup", ticker: "C", aliases: ["Citi"] },
{ name: "Bank of America", ticker: "BAC", aliases: ["BofA"] },
{ name: "HSBC", ticker: "HSBA.L", aliases: ["HSBC Holdings"] },
{ name: "UBS", ticker: "UBS", aliases: [] },
// energy / resources
{ name: "ExxonMobil", ticker: "XOM", aliases: ["Exxon"] },
{ name: "Chevron", ticker: "CVX", aliases: [] },
{ name: "Shell", ticker: "SHEL", aliases: ["Shell plc", "Royal Dutch Shell"] },
{ name: "BP", ticker: "BP", aliases: ["British Petroleum"] },
{ name: "TotalEnergies", ticker: "TTE.PA", aliases: ["Total"] },
{ name: "Saudi Aramco", ticker: "2222.SR", aliases: ["Aramco"] },
{ name: "NextEra Energy", ticker: "NEE", aliases: [] },
// pharma / biotech
{ name: "Pfizer", ticker: "PFE", aliases: [] },
{ name: "Johnson & Johnson", ticker: "JNJ", aliases: ["J&J"] },
{ name: "Moderna", ticker: "MRNA", aliases: [] },
{ name: "AstraZeneca", ticker: "AZN", aliases: [] },
{ name: "Novartis", ticker: "NVS", aliases: [] },
{ name: "Roche", ticker: "ROG.SW", aliases: ["Roche Holding"] },
{ name: "Eli Lilly", ticker: "LLY", aliases: [] },
{ name: "CRISPR Therapeutics", ticker: "CRSP", aliases: [] },
// automotive
{ name: "Toyota", ticker: "TM", aliases: ["Toyota Motor"] },
{ name: "Volkswagen", ticker: "VOW.DE", aliases: ["VW"] },
{ name: "Ford", ticker: "F", aliases: ["Ford Motor"] },
{ name: "General Motors", ticker: "GM", aliases: ["GM"] },
{ name: "BYD", ticker: "002594.SZ", aliases: ["BYD Company"] },
{ name: "Rivian", ticker: "RIVN", aliases: [] },
// media / entertainment
{ name: "Netflix", ticker: "NFLX", aliases: [] },
{ name: "Disney", ticker: "DIS", aliases: ["The Walt Disney Company"] },
{ name: "Comcast", ticker: "CMCSA", aliases: ["NBCUniversal"] },
{ name: "Spotify", ticker: "SPOT", aliases: [] },
{ name: "Warner Bros Discovery", ticker: "WBD", aliases: ["Warner Bros", "HBO"] },
{ name: "News Corp", ticker: "NWSA", aliases: ["Rupert Murdoch"] },
// retail / e-commerce
{ name: "Walmart", ticker: "WMT", aliases: [] },
{ name: "Shopify", ticker: "SHOP", aliases: [] },
{ name: "eBay", ticker: "EBAY", aliases: [] },
// cybersecurity
{ name: "CrowdStrike", ticker: "CRWD", aliases: [] },
{ name: "Palo Alto Networks", ticker: "PANW", aliases: [] },
{ name: "SentinelOne", ticker: "S", aliases: [] },
{ name: "Fortinet", ticker: "FTNT", aliases: [] },
{ name: "Cloudflare", ticker: "NET", aliases: [] },
{ name: "Recorded Future", ticker: "RF", aliases: [] },
// space
{ name: "SpaceX", ticker: "SPACEX", aliases: ["Space Exploration Technologies"] },
{ name: "Blue Origin", ticker: "BLUEORIGIN", aliases: [] },
{ name: "Planet Labs", ticker: "PL", aliases: [] },
// consulting / professional services
{ name: "McKinsey", ticker: "MCKINSEY", aliases: ["McKinsey & Company"] },
{ name: "Accenture", ticker: "ACN", aliases: [] },
{ name: "Deloitte", ticker: "DELOITTE", aliases: [] },
];
let added = 0;
for (const c of companies) {
if (!exists.get(c.name)) {
insert.run(c.name, c.ticker, JSON.stringify(c.aliases));
added++;
}
}
if (added > 0) console.log(`[db] seeded ${added} new tracked companies`);
}
module.exports = { getArchiveDb, getIntelligenceDb, runMigrations, runColumnMigrations, seedCompanies };
+134
View File
@@ -0,0 +1,134 @@
// embedding generation and cosine similarity for the intelligence layer
async function generateEmbedding(text, openRouterConfig) {
const response = await fetch("https://openrouter.ai/api/v1/embeddings", {
method: "POST",
headers: {
"Authorization": `Bearer ${openRouterConfig.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: openRouterConfig.embeddingModel,
input: text,
}),
});
if (!response.ok) {
let msg = `embedding request failed with ${response.status}`;
try {
const payload = await response.json();
if (payload?.error?.message) msg = payload.error.message;
} catch (_) {}
throw new Error(msg);
}
const payload = await response.json();
const embedding = payload?.data?.[0]?.embedding;
if (!Array.isArray(embedding) || embedding.length === 0) {
throw new Error("invalid embedding response");
}
return embedding;
}
// Float32 BLOB -> Float32Array
function blobToFloat32(buf) {
return new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
}
function cosineSimilarity(a, b) {
if (a.length !== b.length) {
// if dims differ just use the shorter length — handles edge cases gracefully
const len = Math.min(a.length, b.length);
a = a.subarray(0, len);
b = b.subarray(0, len);
}
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 0 : dot / denom;
}
// generates company embeddings for any tracked company that doesnt have one yet
async function ensureCompanyEmbeddings(intelligenceDb, openRouterConfig) {
const companies = intelligenceDb.prepare("SELECT * FROM tracked_companies").all();
const getEmbed = intelligenceDb.prepare(
"SELECT embedding FROM company_embeddings WHERE company_id = ?"
);
const upsertEmbed = intelligenceDb.prepare(`
INSERT INTO company_embeddings (company_id, embedding, model, generated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(company_id) DO UPDATE SET
embedding = excluded.embedding,
model = excluded.model,
generated_at = excluded.generated_at
`);
for (const company of companies) {
const existing = getEmbed.get(company.id);
if (existing) continue;
const text = `${company.name} is a company with ticker ${company.ticker}`;
try {
const embedding = await generateEmbedding(text, openRouterConfig);
const buf = Buffer.from(new Float32Array(embedding).buffer);
upsertEmbed.run(company.id, buf, openRouterConfig.embeddingModel);
console.log(`[embeddings] generated embedding for ${company.name}`);
} catch (err) {
console.error(`[embeddings] failed for ${company.name}:`, err.message);
}
}
}
// returns matched company objects from tracked_companies
// checks cosine similarity between each company embedding and
// the raw embeddings of all articles in the event
function findMatchedCompaniesByEmbedding(eventArticleIds, archiveDb, intelligenceDb, config) {
const threshold = config.intelligence?.similarityThreshold ?? 0.35;
const model = config.openRouter?.embeddingModel;
const companies = intelligenceDb.prepare(
"SELECT id, name, ticker FROM company_embeddings ce JOIN tracked_companies tc ON tc.id = ce.company_id"
).all();
if (companies.length === 0) return [];
// load article embeddings from archive — only articles that have one
const articleEmbeddings = [];
for (const articleId of eventArticleIds) {
const row = archiveDb.prepare(
"SELECT embedding FROM article_embedding_store WHERE article_id = ? AND model = ?"
).get(articleId, model);
if (row) articleEmbeddings.push(blobToFloat32(row.embedding));
}
if (articleEmbeddings.length === 0) return [];
const matched = [];
for (const company of companies) {
const companyRow = intelligenceDb.prepare(
"SELECT embedding FROM company_embeddings WHERE company_id = ?"
).get(company.id);
if (!companyRow) continue;
const companyVec = blobToFloat32(companyRow.embedding);
const hit = articleEmbeddings.some(articleVec => {
const sim = cosineSimilarity(companyVec, articleVec);
return sim >= threshold;
});
if (hit) matched.push(company);
}
return matched;
}
module.exports = { generateEmbedding, ensureCompanyEmbeddings, findMatchedCompaniesByEmbedding };
+274
View File
@@ -0,0 +1,274 @@
// builds cross-company relationship graph from company_facts rows where type = 'relationship'
const VALID_TYPES = ["supplier", "customer", "competitor", "partner", "investor", "dependency"];
const KEYWORD_MAP = [
["manufactur", "supplier"],
["suppli", "supplier"],
["distribut", "supplier"],
["compet", "competitor"],
["rival", "competitor"],
["invest", "investor"],
["fund", "investor"],
["backer", "investor"],
["customer", "customer"],
["client", "customer"],
["depend", "dependency"],
["partner", "partner"],
["collaborat", "partner"],
["joint", "partner"],
];
const RECIPROCAL = {
supplier: "customer",
customer: "supplier",
competitor: "competitor",
partner: "partner",
investor: "dependency",
dependency: "investor",
};
function parseRelationshipType(rawType) {
const t = (rawType || "").toLowerCase().trim();
if (VALID_TYPES.includes(t)) return t;
for (const [kw, mapped] of KEYWORD_MAP) {
if (t.includes(kw)) return mapped;
}
return "partner";
}
function parseClaim(claim) {
// format from consolidationWorker: "entity is a type"
const parts = claim.split(" is a ");
if (parts.length < 2) return null;
const toEntity = parts[0].trim();
const rawType = parts.slice(1).join(" is a ").trim();
if (!toEntity || !rawType) return null;
return {
toEntity,
relationshipType: parseRelationshipType(rawType),
};
}
function resolveCompany(toEntity, companies) {
const needle = toEntity.toLowerCase();
for (const co of companies) {
if (co.name.toLowerCase() === needle) return co;
if (co.ticker && co.ticker.toLowerCase() === needle) return co;
let aliases = [];
try { aliases = JSON.parse(co.aliases || "[]"); } catch (_) {}
for (const alias of aliases) {
if (alias.toLowerCase() === needle) return co;
}
}
return null;
}
function confidenceFromCount(count) {
if (count >= 8) return "very_high";
if (count >= 4) return "high";
if (count >= 2) return "medium";
return "low";
}
function mergeEventIds(existingJson, newJson) {
let existing = [];
let incoming = [];
try { existing = JSON.parse(existingJson || "[]"); } catch (_) {}
try { incoming = JSON.parse(newJson || "[]"); } catch (_) {}
const merged = [...new Set([...existing, ...incoming])];
return JSON.stringify(merged);
}
async function runGraphWorker(archiveDb, intelligenceDb, config) {
const loopDelay = config.workers?.graphWorkerLoopDelayMs ?? 90000;
const getRelationshipFacts = intelligenceDb.prepare(`
SELECT * FROM company_facts WHERE type = 'relationship'
`);
const recordEvents = intelligenceDb.prepare(
`INSERT INTO worker_events (worker) VALUES ('graph')`
);
const pruneEvents = intelligenceDb.prepare(
`DELETE FROM worker_events WHERE worker = 'graph' AND completed_at < datetime('now', '-1 hour')`
);
const getCompanies = intelligenceDb.prepare("SELECT * FROM tracked_companies");
const getCompanyById = intelligenceDb.prepare("SELECT * FROM tracked_companies WHERE id = ?");
const getExistingRel = intelligenceDb.prepare(`
SELECT * FROM company_relationships
WHERE from_company_id = ? AND relationship_type = ? AND to_entity = ?
`);
const insertRel = intelligenceDb.prepare(`
INSERT INTO company_relationships
(from_company_id, relationship_type, to_entity, to_company_id, confidence, confirmation_count, first_seen_at, last_seen_at, supporting_event_ids)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const updateRel = intelligenceDb.prepare(`
UPDATE company_relationships
SET confirmation_count = ?,
confidence = ?,
last_seen_at = ?,
supporting_event_ids = ?
WHERE from_company_id = ? AND relationship_type = ? AND to_entity = ?
`);
const getExistingReciprocal = intelligenceDb.prepare(`
SELECT id FROM company_relationships
WHERE from_company_id = ? AND relationship_type = ? AND to_entity = ?
`);
const insertReciprocal = intelligenceDb.prepare(`
INSERT OR IGNORE INTO company_relationships
(from_company_id, relationship_type, to_entity, to_company_id, confidence, confirmation_count, first_seen_at, last_seen_at, supporting_event_ids)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
while (true) {
try {
const facts = getRelationshipFacts.all();
const companies = getCompanies.all();
let upserted = 0;
let reciprocals = 0;
const processAll = intelligenceDb.transaction(() => {
for (const fact of facts) {
const parsed = parseClaim(fact.claim);
if (!parsed) continue;
const { toEntity, relationshipType } = parsed;
const resolved = resolveCompany(toEntity, companies);
const toCompanyId = resolved ? resolved.id : null;
const existing = getExistingRel.get(fact.company_id, relationshipType, toEntity);
const now = new Date().toISOString();
let finalCount;
let finalFirst;
let finalLast;
let finalEventIds;
if (existing) {
// derive count from the fact itself, not accumulate — avoids compounding on re-runs
finalCount = fact.confirmation_count;
finalFirst = existing.first_seen_at;
finalLast = fact.last_seen_at || now;
finalEventIds = mergeEventIds(existing.supporting_event_ids, fact.supporting_event_ids);
updateRel.run(
finalCount,
confidenceFromCount(finalCount),
finalLast,
finalEventIds,
fact.company_id,
relationshipType,
toEntity
);
} else {
finalCount = fact.confirmation_count || 1;
finalFirst = fact.first_seen_at || now;
finalLast = fact.last_seen_at || now;
finalEventIds = fact.supporting_event_ids || "[]";
insertRel.run(
fact.company_id,
relationshipType,
toEntity,
toCompanyId,
confidenceFromCount(finalCount),
finalCount,
finalFirst,
finalLast,
finalEventIds
);
}
upserted++;
// reciprocal edge — only where both companies are tracked
if (toCompanyId) {
const fromCompany = getCompanyById.get(fact.company_id);
if (!fromCompany) continue;
const recipType = RECIPROCAL[relationshipType] || "partner";
const recipToEntity = fromCompany.name;
const recipExists = getExistingReciprocal.get(toCompanyId, recipType, recipToEntity);
if (!recipExists) {
insertReciprocal.run(
toCompanyId,
recipType,
recipToEntity,
fact.company_id,
confidenceFromCount(finalCount),
finalCount,
finalFirst,
finalLast,
finalEventIds
);
reciprocals++;
}
}
}
});
processAll();
// record one event per edge upserted so the rate tracks actual work
if (upserted > 0) {
const insertMany = intelligenceDb.transaction((n) => {
for (let i = 0; i < n; i++) recordEvents.run();
});
insertMany(upserted);
pruneEvents.run();
}
if (upserted > 0 || reciprocals > 0) {
console.log(`[graph] cycle complete — ${upserted} edges upserted, ${reciprocals} reciprocals added`);
} else {
console.log("[graph] cycle complete — no new data");
}
} catch (err) {
console.error("[graph] cycle error:", err.message);
}
await sleep(loopDelay);
}
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
module.exports = { runGraphWorker };
+81
View File
@@ -0,0 +1,81 @@
const fs = require("fs");
const path = require("path");
const { getArchiveDb, getIntelligenceDb, runMigrations, runColumnMigrations, seedCompanies } = require("./db");
const { runQueueFeeder } = require("./queueFeeder");
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");
const configDir = path.dirname(configPath);
const rawConfig = JSON.parse(fs.readFileSync(configPath, "utf8"));
function resolvePath(p, fallback) {
if (!p) return fallback;
return path.isAbsolute(p) ? p : path.resolve(configDir, p);
}
const config = {
duriin_db: process.env.DURIIN_DB || resolvePath(rawConfig.duriin_db, path.resolve(configDir, "archive.sqlite")),
intelligence_db: process.env.INTELLIGENCE_DB || resolvePath(rawConfig.intelligence_db, path.resolve(configDir, "intelligence.sqlite")),
llm: rawConfig.llm || {},
workers: rawConfig.workers || {},
openRouter: rawConfig.openRouter || {},
intelligence: rawConfig.intelligence || {},
};
console.log("[intelligence] starting up");
console.log(`[intelligence] archive: ${config.duriin_db}`);
console.log(`[intelligence] intelligence: ${config.intelligence_db}`);
const archiveDb = getArchiveDb(config.duriin_db);
const intelligenceDb = getIntelligenceDb(config.intelligence_db);
runMigrations(intelligenceDb);
runColumnMigrations(intelligenceDb);
seedCompanies(intelligenceDb);
ensureCompanyEmbeddings(intelligenceDb, config.openRouter).then(() => {
console.log("[intelligence] company embeddings ready");
}).catch(err => {
console.error("[intelligence] company embedding generation failed:", err.message);
});
runQueueFeeder(archiveDb, intelligenceDb, config).catch(err => {
console.error("[feeder] fatal:", err);
process.exit(1);
});
runAugorWorker(archiveDb, intelligenceDb, config).catch(err => {
console.error("[augor] fatal:", err);
process.exit(1);
});
runConsolidationWorker(archiveDb, intelligenceDb, config).catch(err => {
console.error("[consolidation] fatal:", err);
process.exit(1);
});
runGraphWorker(archiveDb, intelligenceDb, config).catch(err => {
console.error("[graph] fatal:", 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);
});
process.on("SIGTERM", () => {
console.log("[intelligence] shutting down");
process.exit(0);
});
+67
View File
@@ -0,0 +1,67 @@
// Pulls usable articles from duriin into article_queue continuously
// Usable = has content, has embedding, has event assignment
async function runQueueFeeder(archiveDb, intelligenceDb, config) {
const batchSize = config.workers?.queueFeederBatchSize ?? 100;
const loopDelay = config.workers?.queueFeederLoopDelayMs ?? 3000;
const getCursor = intelligenceDb.prepare(
"SELECT value FROM cursors WHERE key = 'queue_feeder'"
);
const setCursor = intelligenceDb.prepare(
"INSERT OR REPLACE INTO cursors (key, value) VALUES ('queue_feeder', ?)"
);
const insertQueued = intelligenceDb.prepare(`
INSERT OR IGNORE INTO article_queue (article_id, status, created_at)
VALUES (?, 'pending', CURRENT_TIMESTAMP)
`);
while (true) {
try {
const cursorRow = getCursor.get();
const cursor = cursorRow ? cursorRow.value : 0;
const articles = archiveDb.prepare(`
SELECT id FROM articles
WHERE id > ?
AND content IS NOT NULL
AND content != ''
AND has_embedding = 1
AND event_id IS NOT NULL
ORDER BY id ASC
LIMIT ?
`).all(cursor, batchSize);
if (articles.length === 0) {
await sleep(loopDelay);
continue;
}
let newCursor = cursor;
let inserted = 0;
for (const a of articles) {
const info = insertQueued.run(a.id);
if (info.changes > 0) inserted++;
if (a.id > newCursor) newCursor = a.id;
}
setCursor.run(newCursor);
if (inserted > 0) {
console.log(`[feeder] queued ${inserted} articles, cursor now ${newCursor}`);
}
} catch (err) {
console.error("[feeder] error:", err.message);
await sleep(loopDelay);
}
}
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
module.exports = { runQueueFeeder };
+256
View File
@@ -0,0 +1,256 @@
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 that has enough predictions
let target = null;
let oldestTs = null;
const companiesByAge = [];
for (const company of companies) {
const row = getLastSignal.get(company.id);
companiesByAge.push({ company, ts: row ? row.generated_at : null });
}
// null ts (never generated) first, then oldest
companiesByAge.sort((a, b) => {
if (a.ts === null && b.ts === null) return 0;
if (a.ts === null) return -1;
if (b.ts === null) return 1;
return a.ts < b.ts ? -1 : 1;
});
let predictions = null;
for (const entry of companiesByAge) {
const preds = getPredictions.all(entry.company.id);
if (preds.length >= 3) {
target = entry.company;
predictions = preds;
break;
}
}
if (!target) {
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 };