feat: extend memory prediction loop
This commit is contained in:
@@ -2,6 +2,9 @@
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
const PREDICTION_STATES = new Set(['open', 'monitoring', 'observed', 'expired_unverified', 'invalidated']);
|
||||
|
||||
export class IntelligenceStore {
|
||||
constructor(dbPath) {
|
||||
@@ -30,15 +33,24 @@ export class IntelligenceStore {
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS predictions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stable_id TEXT UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT,
|
||||
title TEXT NOT NULL,
|
||||
type TEXT,
|
||||
hypothesis TEXT,
|
||||
evidence_json TEXT,
|
||||
confidence TEXT,
|
||||
horizon TEXT,
|
||||
outcome_state TEXT DEFAULT 'open',
|
||||
outcome_json TEXT,
|
||||
last_evaluated_at TEXT,
|
||||
source TEXT,
|
||||
payload_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS entities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stable_id TEXT UNIQUE,
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
@@ -46,7 +58,21 @@ export class IntelligenceStore {
|
||||
count INTEGER DEFAULT 1,
|
||||
UNIQUE(name, kind)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stable_id TEXT NOT NULL UNIQUE,
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
region TEXT,
|
||||
severity TEXT,
|
||||
source TEXT,
|
||||
evidence_json TEXT NOT NULL,
|
||||
count INTEGER DEFAULT 1
|
||||
);
|
||||
`);
|
||||
this._migrate();
|
||||
this.available = true;
|
||||
} catch (err) {
|
||||
this.available = false;
|
||||
@@ -71,24 +97,141 @@ export class IntelligenceStore {
|
||||
delta?.summary?.direction || null,
|
||||
JSON.stringify({ meta, delta: delta?.summary || null }),
|
||||
);
|
||||
for (const idea of data.ideas || []) {
|
||||
this.db.prepare(`INSERT INTO predictions (created_at, title, type, confidence, source, payload_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`).run(
|
||||
timestamp,
|
||||
idea.title || 'Untitled idea',
|
||||
idea.type || null,
|
||||
idea.confidence || null,
|
||||
idea.source || data.ideasSource || null,
|
||||
JSON.stringify(idea),
|
||||
);
|
||||
}
|
||||
this._recordEntities(data, timestamp);
|
||||
this._recordEvents(data, delta, timestamp);
|
||||
this.evaluatePredictions(data, timestamp);
|
||||
this._recordPredictions(data, timestamp);
|
||||
}
|
||||
|
||||
status() {
|
||||
return { available: this.available, path: this.dbPath, reason: this.reason };
|
||||
}
|
||||
|
||||
queryMemory({ q = '', limit = 25 } = {}) {
|
||||
if (!this.available || !this.db) return { available: false, reason: this.reason, results: [] };
|
||||
const safeLimit = Math.max(1, Math.min(100, Number(limit) || 25));
|
||||
const term = String(q || '').trim();
|
||||
const like = `%${term}%`;
|
||||
const where = term
|
||||
? 'WHERE name LIKE ? OR region LIKE ? OR source LIKE ? OR kind LIKE ?'
|
||||
: '';
|
||||
const params = term ? [like, like, like, like, safeLimit] : [safeLimit];
|
||||
const events = this.db.prepare(`
|
||||
SELECT stable_id, first_seen, last_seen, kind, name, region, severity, source, count, evidence_json
|
||||
FROM events
|
||||
${where}
|
||||
ORDER BY last_seen DESC
|
||||
LIMIT ?
|
||||
`).all(...params).map(row => ({ ...row, evidence: parseJson(row.evidence_json, {}) }));
|
||||
return { available: true, q: term, results: events };
|
||||
}
|
||||
|
||||
listPredictions({ state = null, limit = 25 } = {}) {
|
||||
if (!this.available || !this.db) return { available: false, reason: this.reason, predictions: [] };
|
||||
const safeLimit = Math.max(1, Math.min(100, Number(limit) || 25));
|
||||
const normalizedState = state && PREDICTION_STATES.has(String(state)) ? String(state) : null;
|
||||
const rows = normalizedState
|
||||
? this.db.prepare(`SELECT * FROM predictions WHERE outcome_state = ? ORDER BY created_at DESC LIMIT ?`).all(normalizedState, safeLimit)
|
||||
: this.db.prepare(`SELECT * FROM predictions ORDER BY created_at DESC LIMIT ?`).all(safeLimit);
|
||||
return {
|
||||
available: true,
|
||||
predictions: rows.map(row => ({
|
||||
stable_id: row.stable_id,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
title: row.title,
|
||||
type: row.type,
|
||||
hypothesis: row.hypothesis,
|
||||
confidence: row.confidence,
|
||||
horizon: row.horizon,
|
||||
outcome_state: row.outcome_state,
|
||||
last_evaluated_at: row.last_evaluated_at,
|
||||
source: row.source,
|
||||
evidence: parseJson(row.evidence_json, []),
|
||||
outcome: parseJson(row.outcome_json, null),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
evaluatePredictions(data, timestamp = new Date().toISOString()) {
|
||||
if (!this.available || !this.db) return;
|
||||
const rows = this.db.prepare(`
|
||||
SELECT id, created_at, title, type, horizon, outcome_state, payload_json
|
||||
FROM predictions
|
||||
WHERE outcome_state IN ('open', 'monitoring')
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 200
|
||||
`).all();
|
||||
for (const row of rows) {
|
||||
const payload = parseJson(row.payload_json, {});
|
||||
const evaluation = evaluatePredictionAgainstSweep(row, payload, data, timestamp);
|
||||
this.db.prepare(`UPDATE predictions
|
||||
SET outcome_state = ?, outcome_json = ?, last_evaluated_at = ?, updated_at = ?
|
||||
WHERE id = ?`).run(
|
||||
evaluation.state,
|
||||
JSON.stringify(evaluation),
|
||||
timestamp,
|
||||
timestamp,
|
||||
row.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_migrate() {
|
||||
const columns = {
|
||||
predictions: [
|
||||
['stable_id', 'TEXT'],
|
||||
['updated_at', 'TEXT'],
|
||||
['hypothesis', 'TEXT'],
|
||||
['evidence_json', 'TEXT'],
|
||||
['horizon', 'TEXT'],
|
||||
['outcome_state', "TEXT DEFAULT 'open'"],
|
||||
['outcome_json', 'TEXT'],
|
||||
['last_evaluated_at', 'TEXT'],
|
||||
],
|
||||
entities: [
|
||||
['stable_id', 'TEXT'],
|
||||
],
|
||||
};
|
||||
for (const [table, defs] of Object.entries(columns)) {
|
||||
for (const [name, type] of defs) {
|
||||
try { this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${type}`); } catch { }
|
||||
}
|
||||
}
|
||||
try { this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_predictions_stable_id ON predictions(stable_id)`); } catch { }
|
||||
try { this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_entities_stable_id ON entities(stable_id)`); } catch { }
|
||||
}
|
||||
|
||||
_recordPredictions(data, timestamp) {
|
||||
for (const idea of data.ideas || []) {
|
||||
const title = idea.title || 'Untitled idea';
|
||||
const stableId = stableId('prediction', title, idea.type || '', idea.ticker || '', idea.horizon || '');
|
||||
const evidence = Array.isArray(idea.signals) ? idea.signals : [];
|
||||
this.db.prepare(`INSERT INTO predictions (
|
||||
stable_id, created_at, updated_at, title, type, hypothesis, evidence_json, confidence,
|
||||
horizon, outcome_state, source, payload_json
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)
|
||||
ON CONFLICT(stable_id) DO UPDATE SET
|
||||
updated_at=excluded.updated_at,
|
||||
confidence=excluded.confidence,
|
||||
evidence_json=excluded.evidence_json,
|
||||
payload_json=excluded.payload_json`).run(
|
||||
stableId,
|
||||
timestamp,
|
||||
timestamp,
|
||||
title,
|
||||
idea.type || null,
|
||||
idea.rationale || idea.text || title,
|
||||
JSON.stringify(evidence),
|
||||
idea.confidence || null,
|
||||
idea.horizon || null,
|
||||
idea.source || data.ideasSource || null,
|
||||
JSON.stringify(idea),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_recordEntities(data, timestamp) {
|
||||
const names = [];
|
||||
for (const item of data.acled?.deadliestEvents || []) {
|
||||
@@ -99,14 +242,154 @@ export class IntelligenceStore {
|
||||
if (item.region) names.push([item.region, 'region']);
|
||||
}
|
||||
for (const [name, kind] of names.slice(0, 200)) {
|
||||
this.db.prepare(`INSERT INTO entities (first_seen, last_seen, name, kind, count)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
const cleanName = String(name).slice(0, 160);
|
||||
this.db.prepare(`INSERT INTO entities (stable_id, first_seen, last_seen, name, kind, count)
|
||||
VALUES (?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(name, kind) DO UPDATE SET last_seen=excluded.last_seen, count=count+1`).run(
|
||||
stableId('entity', kind, cleanName),
|
||||
timestamp,
|
||||
timestamp,
|
||||
String(name).slice(0, 160),
|
||||
cleanName,
|
||||
kind,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_recordEvents(data, delta, timestamp) {
|
||||
const events = extractEvents(data, delta);
|
||||
for (const event of events.slice(0, 300)) {
|
||||
this.db.prepare(`INSERT INTO events (
|
||||
stable_id, first_seen, last_seen, kind, name, region, severity, source, evidence_json, count
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(stable_id) DO UPDATE SET
|
||||
last_seen=excluded.last_seen,
|
||||
severity=COALESCE(excluded.severity, severity),
|
||||
evidence_json=excluded.evidence_json,
|
||||
count=count+1`).run(
|
||||
event.stable_id,
|
||||
timestamp,
|
||||
timestamp,
|
||||
event.kind,
|
||||
event.name,
|
||||
event.region || null,
|
||||
event.severity || null,
|
||||
event.source || null,
|
||||
JSON.stringify(event.evidence || {}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stableId(...parts) {
|
||||
const input = parts.map(part => String(part || '').trim().toLowerCase()).join('|');
|
||||
return createHash('sha256').update(input).digest('hex').slice(0, 24);
|
||||
}
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try { return value ? JSON.parse(value) : fallback; } catch { return fallback; }
|
||||
}
|
||||
|
||||
function extractEvents(data, delta) {
|
||||
const events = [];
|
||||
const push = ({ kind, name, region, severity, source, evidence }) => {
|
||||
if (!kind || !name) return;
|
||||
events.push({
|
||||
stable_id: stableId('event', kind, name, region || source || ''),
|
||||
kind,
|
||||
name: String(name).slice(0, 240),
|
||||
region: region ? String(region).slice(0, 120) : null,
|
||||
severity: severity || null,
|
||||
source: source || null,
|
||||
evidence: evidence || {},
|
||||
});
|
||||
};
|
||||
|
||||
for (const item of data.acled?.deadliestEvents || []) {
|
||||
push({
|
||||
kind: 'conflict',
|
||||
name: item.event_type || item.sub_event_type || item.location || item.country,
|
||||
region: item.country || item.location,
|
||||
severity: Number(item.fatalities || 0) > 0 ? 'high' : 'medium',
|
||||
source: 'ACLED',
|
||||
evidence: item,
|
||||
});
|
||||
}
|
||||
for (const item of data.tg?.urgent || []) {
|
||||
push({
|
||||
kind: 'osint',
|
||||
name: (item.text || '').slice(0, 120),
|
||||
region: item.region || 'OSINT',
|
||||
severity: 'high',
|
||||
source: item.channel || item.chat || 'telegram',
|
||||
evidence: item,
|
||||
});
|
||||
}
|
||||
for (const item of data.newsFeed || data.news || []) {
|
||||
if (!item.urgent) continue;
|
||||
push({
|
||||
kind: 'news',
|
||||
name: item.headline || item.title,
|
||||
region: item.region,
|
||||
severity: 'medium',
|
||||
source: item.source,
|
||||
evidence: item,
|
||||
});
|
||||
}
|
||||
for (const signal of delta?.signals?.new || []) {
|
||||
push({
|
||||
kind: 'delta',
|
||||
name: signal.label || signal.reason || signal.key,
|
||||
region: signal.region,
|
||||
severity: signal.severity || 'medium',
|
||||
source: 'delta',
|
||||
evidence: signal,
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function evaluatePredictionAgainstSweep(row, payload, data, timestamp) {
|
||||
const terms = [
|
||||
row.title,
|
||||
payload.ticker,
|
||||
...(Array.isArray(payload.signals) ? payload.signals : []),
|
||||
].filter(Boolean).map(v => String(v).toLowerCase());
|
||||
const evidenceText = [
|
||||
...(data.tSignals || []),
|
||||
...(data.newsFeed || []).slice(0, 40).map(n => `${n.source || ''} ${n.headline || n.title || ''}`),
|
||||
...(data.tg?.urgent || []).slice(0, 20).map(p => p.text || ''),
|
||||
].join('\n').toLowerCase();
|
||||
const matched = terms.filter(term => term.length >= 4 && evidenceText.includes(term.slice(0, 60)));
|
||||
const expired = predictionExpired(row.created_at, row.horizon, timestamp);
|
||||
const state = matched.length
|
||||
? 'observed'
|
||||
: expired
|
||||
? 'expired_unverified'
|
||||
: 'monitoring';
|
||||
return {
|
||||
state,
|
||||
evaluated_at: timestamp,
|
||||
matched_terms: matched.slice(0, 10),
|
||||
expired,
|
||||
reason: matched.length
|
||||
? 'Current sweep contains matching evidence terms.'
|
||||
: expired
|
||||
? 'Prediction horizon elapsed without matching evidence.'
|
||||
: 'Prediction remains open for future sweeps.',
|
||||
};
|
||||
}
|
||||
|
||||
function predictionExpired(createdAt, horizon, nowIso) {
|
||||
const created = new Date(createdAt).getTime();
|
||||
const now = new Date(nowIso).getTime();
|
||||
if (!Number.isFinite(created) || !Number.isFinite(now)) return false;
|
||||
const text = String(horizon || '').toLowerCase();
|
||||
const days = text.includes('intraday') ? 1
|
||||
: text.includes('day') ? 7
|
||||
: text.includes('week') ? 45
|
||||
: text.includes('month') ? 180
|
||||
: text.includes('strategic') ? 365
|
||||
: 30;
|
||||
return now - created > days * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user