import pg from "pg"; import { randomUUID } from "node:crypto"; import { createHash } from "node:crypto"; import type { NormalizedItem, SourceDefinition } from "./types.js"; import type { ScoredItem } from "./types.js"; import { fetchSourceDocument } from "./documents.js"; const { Pool } = pg; if (!process.env.DATABASE_URL) { console.warn("DATABASE_URL is not set; database operations will fail until Arrowstack provisions it."); } export const pool = new Pool({ connectionString: process.env.DATABASE_URL, ssl: process.env.DATABASE_SSL === "require" ? { rejectUnauthorized: false } : undefined, max: Number(process.env.DB_POOL_MAX ?? 8) }); export async function migrate() { await pool.query(` CREATE TABLE IF NOT EXISTS sources ( id TEXT PRIMARY KEY, name TEXT NOT NULL, source_type TEXT NOT NULL, url TEXT NOT NULL, enabled BOOLEAN NOT NULL DEFAULT TRUE, last_synced_at TIMESTAMPTZ, last_error TEXT, baseline_complete BOOLEAN NOT NULL DEFAULT FALSE, publisher TEXT NOT NULL DEFAULT '', source_class TEXT NOT NULL DEFAULT 'catalogue', independence_group TEXT NOT NULL DEFAULT '', authority_tier INTEGER NOT NULL DEFAULT 3, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS source_runs ( id TEXT PRIMARY KEY, source_id TEXT NOT NULL REFERENCES sources(id), started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), finished_at TIMESTAMPTZ, status TEXT NOT NULL, item_count INTEGER NOT NULL DEFAULT 0, new_signal_count INTEGER NOT NULL DEFAULT 0, error TEXT ); CREATE TABLE IF NOT EXISTS signals ( id TEXT PRIMARY KEY, source_id TEXT NOT NULL REFERENCES sources(id), external_id TEXT NOT NULL, title TEXT NOT NULL, summary TEXT NOT NULL DEFAULT '', url TEXT NOT NULL, published_at TIMESTAMPTZ, detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), content_hash TEXT NOT NULL, change_kind TEXT NOT NULL DEFAULT 'new', topics JSONB NOT NULL DEFAULT '[]'::jsonb, raw_json JSONB NOT NULL, UNIQUE(source_id, external_id, content_hash) ); CREATE INDEX IF NOT EXISTS idx_signals_source_detected ON signals(source_id, detected_at DESC); CREATE INDEX IF NOT EXISTS idx_signals_external ON signals(source_id, external_id); CREATE TABLE IF NOT EXISTS source_snapshots ( id TEXT PRIMARY KEY, source_id TEXT NOT NULL REFERENCES sources(id), signal_id TEXT NOT NULL REFERENCES signals(id) ON DELETE CASCADE, source_url TEXT NOT NULL, retrieved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), published_at TIMESTAMPTZ, content_hash TEXT NOT NULL, content_type TEXT NOT NULL DEFAULT 'application/vnd.media-intelligence.normalized+json', byte_length INTEGER NOT NULL DEFAULT 0, snapshot_json JSONB NOT NULL, snapshot_kind TEXT NOT NULL DEFAULT 'normalized', canonical_url TEXT, document_text TEXT, http_status INTEGER, UNIQUE(signal_id, content_hash) ); CREATE INDEX IF NOT EXISTS idx_source_snapshots_signal ON source_snapshots(signal_id, retrieved_at DESC); CREATE TABLE IF NOT EXISTS evidence ( id TEXT PRIMARY KEY, signal_id TEXT NOT NULL REFERENCES signals(id) ON DELETE CASCADE, source_url TEXT NOT NULL, retrieved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), content_hash TEXT NOT NULL, excerpt TEXT NOT NULL DEFAULT '', snapshot_id TEXT REFERENCES source_snapshots(id) ON DELETE SET NULL, evidence_kind TEXT NOT NULL DEFAULT 'normalized_excerpt', locator TEXT ); CREATE TABLE IF NOT EXISTS opportunities ( id TEXT PRIMARY KEY, signal_id TEXT NOT NULL UNIQUE REFERENCES signals(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'new', score INTEGER NOT NULL DEFAULT 0, reasons JSONB NOT NULL DEFAULT '[]'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_opportunities_status_created ON opportunities(status, created_at DESC); CREATE TABLE IF NOT EXISTS notes ( id TEXT PRIMARY KEY, opportunity_id TEXT NOT NULL REFERENCES opportunities(id) ON DELETE CASCADE, note TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS story_threads ( id TEXT PRIMARY KEY, title TEXT NOT NULL, question TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'researching', created_by_sub TEXT NOT NULL, created_by_name TEXT NOT NULL, created_by_client_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_story_threads_status_updated ON story_threads(status, updated_at DESC); CREATE TABLE IF NOT EXISTS story_nodes ( id TEXT PRIMARY KEY, thread_id TEXT NOT NULL REFERENCES story_threads(id) ON DELETE CASCADE, node_type TEXT NOT NULL, title TEXT NOT NULL, summary TEXT NOT NULL DEFAULT '', source_id TEXT REFERENCES sources(id) ON DELETE SET NULL, signal_id TEXT REFERENCES signals(id) ON DELETE SET NULL, evidence_id TEXT REFERENCES evidence(id) ON DELETE SET NULL, opportunity_id TEXT REFERENCES opportunities(id) ON DELETE SET NULL, message_id TEXT, confidence NUMERIC(4,3), review_state TEXT NOT NULL DEFAULT 'active', created_by_sub TEXT NOT NULL, created_by_name TEXT NOT NULL, created_by_client_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_story_nodes_thread ON story_nodes(thread_id, created_at ASC); CREATE TABLE IF NOT EXISTS story_edges ( id TEXT PRIMARY KEY, thread_id TEXT NOT NULL REFERENCES story_threads(id) ON DELETE CASCADE, from_node_id TEXT NOT NULL REFERENCES story_nodes(id) ON DELETE CASCADE, to_node_id TEXT NOT NULL REFERENCES story_nodes(id) ON DELETE CASCADE, relationship TEXT NOT NULL, reason TEXT NOT NULL DEFAULT '', created_by_sub TEXT NOT NULL, created_by_name TEXT NOT NULL, created_by_client_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(thread_id, from_node_id, to_node_id, relationship) ); CREATE INDEX IF NOT EXISTS idx_story_edges_thread ON story_edges(thread_id, created_at ASC); CREATE TABLE IF NOT EXISTS story_research_checks ( id TEXT PRIMARY KEY, thread_id TEXT NOT NULL REFERENCES story_threads(id) ON DELETE CASCADE, check_type TEXT NOT NULL DEFAULT 'counter_evidence', result TEXT NOT NULL, sources_checked JSONB NOT NULL DEFAULT '[]'::jsonb, summary TEXT NOT NULL DEFAULT '', completed_by_sub TEXT NOT NULL, completed_by_name TEXT NOT NULL, completed_by_client_id TEXT NOT NULL, completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(thread_id, check_type) ); CREATE TABLE IF NOT EXISTS agent_messages ( id TEXT PRIMARY KEY, actor_sub TEXT NOT NULL, actor_name TEXT NOT NULL, actor_client_id TEXT NOT NULL, severity TEXT NOT NULL DEFAULT 'info', kind TEXT NOT NULL DEFAULT 'update', title TEXT NOT NULL, body TEXT NOT NULL, related_type TEXT, related_id TEXT, dedupe_key TEXT UNIQUE, acknowledged_at TIMESTAMPTZ, acknowledged_by TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_agent_messages_created ON agent_messages(created_at DESC); CREATE INDEX IF NOT EXISTS idx_agent_messages_unread ON agent_messages(acknowledged_at, created_at DESC); CREATE TABLE IF NOT EXISTS app_meta ( key TEXT PRIMARY KEY, value JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS audit_events ( id TEXT PRIMARY KEY, event_type TEXT NOT NULL, object_type TEXT NOT NULL, object_id TEXT NOT NULL, payload JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS source_trust_policy ( id TEXT PRIMARY KEY, domain TEXT NOT NULL UNIQUE, name TEXT NOT NULL, trust_weight NUMERIC(4,3) NOT NULL CHECK (trust_weight >= 0 AND trust_weight <= 1), independence_group TEXT NOT NULL, source_class TEXT NOT NULL DEFAULT 'independent', notes TEXT NOT NULL DEFAULT '', enabled BOOLEAN NOT NULL DEFAULT TRUE, source_id TEXT REFERENCES sources(id) ON DELETE SET NULL, created_by TEXT NOT NULL DEFAULT 'system', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_source_trust_policy_enabled_weight ON source_trust_policy(enabled, trust_weight DESC); `); await pool.query("ALTER TABLE sources ADD COLUMN IF NOT EXISTS baseline_complete BOOLEAN NOT NULL DEFAULT FALSE"); await pool.query("ALTER TABLE sources ADD COLUMN IF NOT EXISTS publisher TEXT NOT NULL DEFAULT ''"); await pool.query("ALTER TABLE sources ADD COLUMN IF NOT EXISTS source_class TEXT NOT NULL DEFAULT 'catalogue'"); await pool.query("ALTER TABLE sources ADD COLUMN IF NOT EXISTS independence_group TEXT NOT NULL DEFAULT ''"); await pool.query("ALTER TABLE sources ADD COLUMN IF NOT EXISTS authority_tier INTEGER NOT NULL DEFAULT 3"); await pool.query("ALTER TABLE evidence ADD COLUMN IF NOT EXISTS snapshot_id TEXT REFERENCES source_snapshots(id) ON DELETE SET NULL"); await pool.query("ALTER TABLE evidence ADD COLUMN IF NOT EXISTS evidence_kind TEXT NOT NULL DEFAULT 'normalized_excerpt'"); await pool.query("ALTER TABLE evidence ADD COLUMN IF NOT EXISTS locator TEXT"); await pool.query("ALTER TABLE source_snapshots ADD COLUMN IF NOT EXISTS snapshot_kind TEXT NOT NULL DEFAULT 'normalized'"); await pool.query("ALTER TABLE source_snapshots ADD COLUMN IF NOT EXISTS canonical_url TEXT"); await pool.query("ALTER TABLE source_snapshots ADD COLUMN IF NOT EXISTS document_text TEXT"); await pool.query("ALTER TABLE source_snapshots ADD COLUMN IF NOT EXISTS http_status INTEGER"); await pool.query("ALTER TABLE story_nodes ADD COLUMN IF NOT EXISTS review_state TEXT NOT NULL DEFAULT 'active'"); const payloadMarker = await pool.query( `INSERT INTO app_meta(key,value) VALUES('evidence_payload_quality_v1','{"applied":true}'::jsonb) ON CONFLICT(key) DO NOTHING RETURNING key` ); if (payloadMarker.rowCount) { await pool.query(` UPDATE source_snapshots ss SET snapshot_kind='source_payload' FROM sources src WHERE ss.source_id=src.id AND src.source_type IN ('rss','geojson') AND src.authority_tier=1 AND ss.snapshot_kind='normalized' `); await pool.query(` UPDATE evidence e SET evidence_kind='source_payload', locator=COALESCE(locator,'official feed/API item') FROM source_snapshots ss, sources src WHERE e.snapshot_id=ss.id AND ss.source_id=src.id AND ss.snapshot_kind='source_payload' AND src.authority_tier=1 AND e.evidence_kind='normalized_excerpt' `); } await pool.query(` INSERT INTO source_snapshots( id,source_id,signal_id,source_url,retrieved_at,published_at,content_hash, content_type,byte_length,snapshot_json ) SELECT 'snapshot-' || e.id, s.source_id, s.id, e.source_url, e.retrieved_at, s.published_at, e.content_hash, 'application/vnd.media-intelligence.normalized+json', LENGTH(s.raw_json::text), jsonb_build_object( 'title',s.title, 'summary',s.summary, 'url',s.url, 'publishedAt',s.published_at, 'topics',s.topics, 'raw',s.raw_json ) FROM evidence e JOIN signals s ON s.id=e.signal_id WHERE e.snapshot_id IS NULL ON CONFLICT(id) DO NOTHING `); await pool.query(` UPDATE evidence SET snapshot_id='snapshot-' || id WHERE snapshot_id IS NULL AND EXISTS (SELECT 1 FROM source_snapshots ss WHERE ss.id='snapshot-' || evidence.id) `); await pool.query("ALTER TABLE signals ADD COLUMN IF NOT EXISTS change_kind TEXT NOT NULL DEFAULT 'new'"); await pool.query("ALTER TABLE agent_messages ADD COLUMN IF NOT EXISTS dedupe_key TEXT"); await pool.query("CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_messages_dedupe_key ON agent_messages(dedupe_key)"); const marker = await pool.query( `INSERT INTO app_meta(key,value) VALUES('noise_baseline_v1','{"applied":true}'::jsonb) ON CONFLICT(key) DO NOTHING RETURNING key` ); if (marker.rowCount) { await pool.query( `UPDATE opportunities SET status='closed', reasons = reasons || '["Archived during initial baseline cleanup"]'::jsonb, updated_at=NOW() WHERE status='new'` ); } } export async function seedSources(sources: SourceDefinition[]) { for (const source of sources) { await pool.query( `INSERT INTO sources( id,name,source_type,url,publisher,source_class,independence_group,authority_tier ) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(id) DO UPDATE SET name=EXCLUDED.name, source_type=EXCLUDED.source_type, url=EXCLUDED.url, publisher=EXCLUDED.publisher, source_class=EXCLUDED.source_class, independence_group=EXCLUDED.independence_group, authority_tier=EXCLUDED.authority_tier`, [ source.id, source.name, source.type, source.url, source.publisher, source.sourceClass, source.independenceGroup, source.authorityTier ] ); } await pool.query( `UPDATE sources s SET baseline_complete=TRUE WHERE baseline_complete=FALSE AND EXISTS (SELECT 1 FROM signals sig WHERE sig.source_id=s.id)` ); } function normaliseTrustDomain(value: string) { const raw = String(value ?? "").trim().toLowerCase(); if (!raw) throw new Error("Source domain is required"); let hostname = raw; try { const url = new URL(raw.includes("://") ? raw : `https://${raw}`); hostname = url.hostname.toLowerCase(); } catch { throw new Error("Invalid source domain or URL"); } hostname = hostname.replace(/^www\./, "").replace(/\.$/, ""); if (!hostname || !hostname.includes(".")) throw new Error("Invalid source domain"); return hostname; } function defaultTrustWeight(sourceClass: string, authorityTier: number) { const byClass: Record = { official_primary: 1.0, official_data: 0.98, official_registry: 0.97, official_notice: 0.95, official_analysis: 0.93, catalogue: 0.75 }; if (sourceClass in byClass) return byClass[sourceClass]; if (authorityTier === 1) return 0.92; if (authorityTier === 2) return 0.78; return 0.65; } export async function seedSourceTrustPolicy(sources: SourceDefinition[]) { for (const source of sources) { const domain = normaliseTrustDomain(source.url); const weight = defaultTrustWeight(source.sourceClass, source.authorityTier); await pool.query( `INSERT INTO source_trust_policy( id,domain,name,trust_weight,independence_group,source_class,notes, enabled,source_id,created_by ) VALUES($1,$2,$3,$4,$5,$6,$7,TRUE,$8,'system-seed') ON CONFLICT(domain) DO UPDATE SET source_id=COALESCE(source_trust_policy.source_id,EXCLUDED.source_id), name=CASE WHEN source_trust_policy.created_by='system-seed' THEN EXCLUDED.name ELSE source_trust_policy.name END, independence_group=CASE WHEN source_trust_policy.created_by='system-seed' THEN EXCLUDED.independence_group ELSE source_trust_policy.independence_group END, source_class=CASE WHEN source_trust_policy.created_by='system-seed' THEN EXCLUDED.source_class ELSE source_trust_policy.source_class END, updated_at=NOW()`, [ randomUUID(), domain, source.publisher || source.name, weight, source.independenceGroup || domain, source.sourceClass, "Seeded from configured ingestion source. Weight is an editorial prior, not a truth guarantee.", source.id ] ); } } export async function listSourceTrustPolicy(args: { enabledOnly?: boolean } = {}) { const result = await pool.query( `SELECT * FROM source_trust_policy ${args.enabledOnly ? "WHERE enabled=TRUE" : ""} ORDER BY trust_weight DESC, name ASC` ); return result.rows; } export async function upsertSourceTrustEntry( args: { domain: string; name: string; trustWeight: number; independenceGroup?: string; sourceClass?: string; notes?: string; enabled?: boolean; }, actorLabel: string ) { const domain = normaliseTrustDomain(args.domain); const name = String(args.name ?? "").trim(); const weight = Number(args.trustWeight); const independenceGroup = String(args.independenceGroup ?? domain).trim().toLowerCase(); const sourceClass = String(args.sourceClass ?? "independent").trim().toLowerCase(); const notes = String(args.notes ?? "").trim(); if (!name) throw new Error("Source name is required"); if (!Number.isFinite(weight) || weight < 0 || weight > 1) { throw new Error("Trust weight must be between 0 and 1"); } if (!independenceGroup) throw new Error("Independence group is required"); if (name.length > 180 || notes.length > 4000) throw new Error("Source trust entry is too long"); const existing = await pool.query( "SELECT id FROM source_trust_policy WHERE domain=$1", [domain] ); const id = existing.rows[0]?.id ?? randomUUID(); const result = await pool.query( `INSERT INTO source_trust_policy( id,domain,name,trust_weight,independence_group,source_class,notes, enabled,created_by ) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT(domain) DO UPDATE SET name=EXCLUDED.name, trust_weight=EXCLUDED.trust_weight, independence_group=EXCLUDED.independence_group, source_class=EXCLUDED.source_class, notes=EXCLUDED.notes, enabled=EXCLUDED.enabled, created_by=EXCLUDED.created_by, updated_at=NOW() RETURNING *`, [ id,domain,name,weight,independenceGroup,sourceClass,notes, args.enabled !== false,actorLabel ] ); await pool.query( `INSERT INTO audit_events(id,event_type,object_type,object_id,payload) VALUES($1,'source_trust.updated','source_trust',$2,$3::jsonb)`, [randomUUID(), result.rows[0].id, JSON.stringify({ domain, trustWeight: weight, independenceGroup, actor: actorLabel })] ); return result.rows[0]; } export async function evaluateSourceTrust( inputs: Array<{ url?: string; domain?: string; sourceId?: string }> ) { if (!Array.isArray(inputs) || !inputs.length) { throw new Error("At least one source is required"); } const policy = await listSourceTrustPolicy({ enabledOnly: true }); const bySourceId = new Map( policy.filter((p: any) => p.source_id).map((p: any) => [String(p.source_id), p]) ); const matched = inputs.slice(0, 50).map((input) => { let entry: any = null; let domain: string | null = null; if (input.sourceId && bySourceId.has(String(input.sourceId))) { entry = bySourceId.get(String(input.sourceId)); domain = String(entry.domain); } else { const value = input.url || input.domain; if (value) { try { domain = normaliseTrustDomain(value); } catch { domain = null; } } if (domain) { entry = policy .filter((p: any) => domain === p.domain || domain!.endsWith(`.${p.domain}`)) .sort((a: any, b: any) => String(b.domain).length - String(a.domain).length)[0] ?? null; } } return { input, matched: Boolean(entry), domain, policy_id: entry?.id ?? null, name: entry?.name ?? null, weight: entry ? Number(entry.trust_weight) : null, independence_group: entry?.independence_group ?? null, source_class: entry?.source_class ?? null }; }); const groups = new Map(); for (const item of matched.filter((x) => x.matched)) { const group = String(item.independence_group || item.domain); const current = groups.get(group); if (!current || Number(item.weight) > Number(current.weight)) groups.set(group, item); } const independent = Array.from(groups.values()).sort( (a, b) => Number(b.weight) - Number(a.weight) ); const strongest = independent.length ? Number(independent[0].weight) : 0; const corroborationBonus = independent .slice(1) .reduce((sum, item) => sum + Number(item.weight) * 0.05, 0); const effectiveScore = Math.min(1, strongest + Math.min(0.15, corroborationBonus)); const independentCount = independent.length; let confidenceBand = "unrated"; if (independentCount >= 2 && effectiveScore >= 0.9) confidenceBand = "strong"; else if (independentCount >= 2 && effectiveScore >= 0.75) confidenceBand = "moderate"; else if (independentCount === 1 && strongest >= 0.85) confidenceBand = "strong_single_source"; else if (independentCount >= 1) confidenceBand = "limited"; return { policy_version: "source-trust-v1", methodology: { description: "Use the strongest configured source from each independence group. Repeated sources from the same publisher/group do not add corroboration. Additional independent groups contribute a small capped corroboration bonus.", strongest_source_weight: strongest, corroboration_bonus: Number(Math.min(0.15, corroborationBonus).toFixed(3)), maximum_independence_bonus: 0.15 }, effective_score: Number(effectiveScore.toFixed(3)), confidence_band: confidenceBand, independent_source_count: independentCount, meets_independence_minimum: independentCount >= 2, policy_coverage: Number((matched.filter((x) => x.matched).length / matched.length).toFixed(3)), independent_groups: independent.map((x) => ({ group: x.independence_group || x.domain, representative: x.name, weight: x.weight, domain: x.domain })), sources: matched, guidance: independentCount >= 2 ? "Independent corroboration present. Evaluate whether the sources genuinely support the same claim and check for shared upstream evidence." : "Independent corroboration is not yet present. Seek at least one source from a different independence group before treating the claim as robust." }; } export function contentHash(item: NormalizedItem) { return createHash("sha256") .update(JSON.stringify({ title: item.title, summary: item.summary, url: item.url, publishedAt: item.publishedAt, raw: item.raw })) .digest("hex"); } export async function storeItem( item: NormalizedItem, scored: ScoredItem, options: { baseline?: boolean } = {} ): Promise<{ created: boolean; signalId?: string; opportunityId?: string; changeKind?: "baseline" | "new" | "updated"; }> { const hash = contentHash(item); const previous = await pool.query( `SELECT id,content_hash FROM signals WHERE source_id=$1 AND external_id=$2 ORDER BY detected_at DESC LIMIT 1`, [item.sourceId, item.externalId] ); if (previous.rowCount && previous.rows[0].content_hash === hash) { return { created: false }; } const changeKind: "baseline" | "new" | "updated" = options.baseline ? "baseline" : previous.rowCount ? "updated" : "new"; const signalId = randomUUID(); const opportunityId = randomUUID(); const evidenceId = randomUUID(); const snapshotId = randomUUID(); const minScore = Number(process.env.OPPORTUNITY_MIN_SCORE ?? 2); const maxAgeDays = Math.max(Number(process.env.MAX_OPPORTUNITY_AGE_DAYS ?? 14), 1); let recentEnough = true; if (item.publishedAt) { const published = new Date(item.publishedAt).getTime(); if (!Number.isNaN(published)) { recentEnough = Date.now() - published <= maxAgeDays * 86_400_000; } } const shouldCreateOpportunity = !options.baseline && recentEnough && scored.score >= minScore; const sourceMeta = await pool.query( "SELECT source_type,authority_tier FROM sources WHERE id=$1", [item.sourceId] ); const directPayload = Number(sourceMeta.rows[0]?.authority_tier) === 1 && new Set(["rss","geojson"]).has(String(sourceMeta.rows[0]?.source_type ?? "")); const snapshotKind = directPayload ? "source_payload" : "normalized"; const evidenceKind = directPayload ? "source_payload" : "normalized_excerpt"; const locator = directPayload ? "official feed/API item" : "title+summary"; await pool.query("BEGIN"); try { await pool.query( `INSERT INTO signals( id,source_id,external_id,title,summary,url,published_at,content_hash,change_kind,topics,raw_json ) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11::jsonb)`, [ signalId, item.sourceId, item.externalId, item.title, item.summary, item.url, item.publishedAt, hash, changeKind, JSON.stringify(item.topics), JSON.stringify(item.raw) ] ); const snapshotPayload = { title: item.title, summary: item.summary, url: item.url, publishedAt: item.publishedAt, topics: item.topics, metrics: item.metrics ?? null, raw: item.raw }; const snapshotText = JSON.stringify(snapshotPayload); await pool.query( `INSERT INTO source_snapshots( id,source_id,signal_id,source_url,published_at,content_hash, content_type,byte_length,snapshot_json,snapshot_kind ) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10)`, [ snapshotId, item.sourceId, signalId, item.url, item.publishedAt, hash, directPayload ? "application/vnd.media-intelligence.source-payload+json" : "application/vnd.media-intelligence.normalized+json", Buffer.byteLength(snapshotText), snapshotText, snapshotKind ] ); await pool.query( `INSERT INTO evidence( id,signal_id,source_url,content_hash,excerpt,snapshot_id,evidence_kind,locator ) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, [ evidenceId, signalId, item.url, hash, `${item.title}\n${item.summary}`.slice(0, 2000), snapshotId, evidenceKind, locator ] ); if (shouldCreateOpportunity) { await pool.query( `INSERT INTO opportunities(id,signal_id,status,score,reasons) VALUES($1,$2,'new',$3,$4::jsonb)`, [opportunityId, signalId, scored.score, JSON.stringify(scored.reasons)] ); } await pool.query( `INSERT INTO audit_events(id,event_type,object_type,object_id,payload) VALUES($1,'signal.ingested','signal',$2,$3::jsonb)`, [ randomUUID(), signalId, JSON.stringify({ sourceId: item.sourceId, externalId: item.externalId, score: scored.score, changeKind, opportunityCreated: shouldCreateOpportunity, recentEnough }) ] ); await pool.query("COMMIT"); return { created: true, signalId, opportunityId: shouldCreateOpportunity ? opportunityId : undefined, changeKind }; } catch (error) { await pool.query("ROLLBACK"); throw error; } } export async function listSources() { const result = await pool.query("SELECT * FROM sources ORDER BY id"); return result.rows; } export async function sourceStatus() { const result = await pool.query(` SELECT s.*, (SELECT COUNT(*)::int FROM signals sig WHERE sig.source_id=s.id) AS signal_count, (SELECT COUNT(*)::int FROM opportunities o JOIN signals sig ON sig.id=o.signal_id WHERE sig.source_id=s.id AND o.status <> 'closed') AS opportunity_count FROM sources s ORDER BY s.id `); return result.rows; } export async function listOpportunities(args: { status?: string; since?: string; limit?: number }) { const limit = Math.min(Math.max(Number(args.limit ?? 25), 1), 100); const base = `SELECT o.id,o.status,o.score,o.reasons,o.created_at,o.updated_at, s.id AS signal_id,s.source_id,s.title,s.summary,s.url,s.published_at,s.detected_at,s.topics FROM opportunities o JOIN signals s ON s.id=o.signal_id`; if (args.status && args.since) { const result = await pool.query( `${base} WHERE o.status=$1 AND o.created_at >= $2::timestamptz ORDER BY o.created_at DESC LIMIT $3`, [args.status, args.since, limit] ); return result.rows; } if (args.status) { const result = await pool.query( `${base} WHERE o.status=$1 ORDER BY o.created_at DESC LIMIT $2`, [args.status, limit] ); return result.rows; } if (args.since) { const result = await pool.query( `${base} WHERE o.status <> 'closed' AND o.created_at >= $1::timestamptz ORDER BY o.created_at DESC LIMIT $2`, [args.since, limit] ); return result.rows; } const result = await pool.query( `${base} WHERE o.status <> 'closed' ORDER BY o.created_at DESC LIMIT $1`, [limit] ); return result.rows; } export async function getOpportunity(id: string) { const result = await pool.query( `SELECT o.*, s.source_id,s.external_id,s.title,s.summary,s.url,s.published_at,s.detected_at,s.topics,s.raw_json FROM opportunities o JOIN signals s ON s.id=o.signal_id WHERE o.id=$1`, [id] ); if (!result.rowCount) return null; const row = result.rows[0]; const evidence = await pool.query("SELECT * FROM evidence WHERE signal_id=$1 ORDER BY retrieved_at DESC", [row.signal_id]); const notes = await pool.query("SELECT * FROM notes WHERE opportunity_id=$1 ORDER BY created_at ASC", [id]); return { ...row, evidence: evidence.rows, notes: notes.rows }; } export async function markOpportunity(id: string, status: string) { const allowed = new Set(["new", "watch", "investigate", "develop", "ignore", "closed"]); if (!allowed.has(status)) throw new Error(`Invalid status: ${status}`); const result = await pool.query( "UPDATE opportunities SET status=$2, updated_at=NOW() WHERE id=$1 RETURNING *", [id, status] ); if (!result.rowCount) return null; await pool.query( `INSERT INTO audit_events(id,event_type,object_type,object_id,payload) VALUES($1,'opportunity.status_changed','opportunity',$2,$3::jsonb)`, [randomUUID(), id, JSON.stringify({ status })] ); return result.rows[0]; } export async function getEvidence(id: string) { const result = await pool.query( `SELECT e.*, s.source_id,s.title,s.summary,s.url,s.published_at, ss.retrieved_at AS snapshot_retrieved_at, ss.published_at AS snapshot_published_at, ss.content_type AS snapshot_content_type, ss.byte_length AS snapshot_byte_length, src.publisher,src.source_class,src.independence_group,src.authority_tier FROM evidence e JOIN signals s ON s.id=e.signal_id LEFT JOIN source_snapshots ss ON ss.id=e.snapshot_id LEFT JOIN sources src ON src.id=s.source_id WHERE e.id=$1`, [id] ); return result.rows[0] ?? null; } export async function searchEvidence(query: string, limit = 20) { const result = await pool.query( `SELECT e.*, s.source_id,s.title,s.summary,s.url,s.published_at, src.publisher,src.source_class,src.independence_group,src.authority_tier, ss.retrieved_at AS snapshot_retrieved_at FROM evidence e JOIN signals s ON s.id=e.signal_id LEFT JOIN sources src ON src.id=s.source_id LEFT JOIN source_snapshots ss ON ss.id=e.snapshot_id WHERE e.excerpt ILIKE $1 OR s.title ILIKE $1 OR s.summary ILIKE $1 ORDER BY e.retrieved_at DESC LIMIT $2`, [`%${query}%`, Math.min(Math.max(limit, 1), 100)] ); return result.rows; } export interface StoryActor { sub: string; name: string; clientId: string; } const STORY_NODE_TYPES = new Set([ "signal", "evidence", "claim", "counterclaim", "trend", "forecast", "actual", "question", "context", "organisation", "person", "place", "metric" ]); const STORY_RELATIONSHIPS = new Set([ "supports", "contradicts", "qualifies", "explains", "correlates_with", "precedes", "updates", "supersedes", "same_subject", "regional_breakdown_of", "forecast_for", "actual_for", "investigates" ]); const STORY_STATUSES = new Set(["researching", "review", "develop", "closed"]); export async function createStoryThread( args: { title: string; question?: string }, actor: StoryActor ) { const title = String(args.title ?? "").trim(); const question = String(args.question ?? "").trim(); if (!title) throw new Error("Story thread title is required"); if (title.length > 220) throw new Error("Story thread title is too long"); if (question.length > 1200) throw new Error("Story thread question is too long"); const id = randomUUID(); const result = await pool.query( `INSERT INTO story_threads( id,title,question,status,created_by_sub,created_by_name,created_by_client_id ) VALUES($1,$2,$3,'researching',$4,$5,$6) RETURNING *`, [id, title, question, actor.sub, actor.name, actor.clientId] ); await pool.query( `INSERT INTO audit_events(id,event_type,object_type,object_id,payload) VALUES($1,'story_thread.created','story_thread',$2,$3::jsonb)`, [randomUUID(), id, JSON.stringify({ actor, title, question })] ); return result.rows[0]; } export async function listStoryThreads(args: { status?: string; limit?: number; } = {}) { const limit = Math.min(Math.max(Number(args.limit ?? 30), 1), 100); if (args.status) { if (!STORY_STATUSES.has(args.status)) throw new Error("Invalid story status"); const result = await pool.query( `SELECT t.*, (SELECT COUNT(*)::int FROM story_nodes n WHERE n.thread_id=t.id) AS node_count, (SELECT COUNT(*)::int FROM story_edges e WHERE e.thread_id=t.id) AS edge_count, (SELECT result FROM story_research_checks c WHERE c.thread_id=t.id AND c.check_type='counter_evidence' LIMIT 1) AS counter_evidence_result FROM story_threads t WHERE t.status=$1 ORDER BY t.updated_at DESC LIMIT $2`, [args.status, limit] ); return result.rows; } const result = await pool.query( `SELECT t.*, (SELECT COUNT(*)::int FROM story_nodes n WHERE n.thread_id=t.id) AS node_count, (SELECT COUNT(*)::int FROM story_edges e WHERE e.thread_id=t.id) AS edge_count, (SELECT result FROM story_research_checks c WHERE c.thread_id=t.id AND c.check_type='counter_evidence' LIMIT 1) AS counter_evidence_result FROM story_threads t ORDER BY t.updated_at DESC LIMIT $1`, [limit] ); return result.rows; } function buildStorySourceAudit(story: any) { const allNodes = Array.isArray(story.nodes) ? story.nodes : []; const nodes = allNodes.filter((n: any) => n.review_state !== "irrelevant"); const nodeIds = new Set(nodes.map((n: any) => n.id)); const edges = (Array.isArray(story.edges) ? story.edges : []) .filter((e: any) => nodeIds.has(e.from_node_id) && nodeIds.has(e.to_node_id)); const evidenceBacked = nodes.filter((n: any) => { if (!n.evidence_id || !n.snapshot_id || !n.provenance_source_id) return false; const documentBacked = n.snapshot_kind === "source_document" && n.evidence_kind === "source_document" && Number(n.snapshot_text_length ?? 0) >= 500; const payloadBacked = n.snapshot_kind === "source_payload" && n.evidence_kind === "source_payload" && Number(n.provenance_authority_tier) === 1 && Number(n.snapshot_payload_length ?? 0) >= 350 && Number(n.evidence_excerpt_length ?? 0) >= 160; return documentBacked || payloadBacked; }); const distinctSourceIds = Array.from(new Set( evidenceBacked.map((n: any) => String(n.provenance_source_id)).filter(Boolean) )); const distinctGroups = Array.from(new Set( evidenceBacked.map((n: any) => String(n.provenance_independence_group)).filter(Boolean) )); const publishers = Array.from(new Set( evidenceBacked.map((n: any) => String(n.provenance_publisher)).filter(Boolean) )); const tierOneCount = evidenceBacked.filter((n: any) => Number(n.provenance_authority_tier) === 1 ).length; const directEvidenceTypes = new Set(["evidence","actual","signal"]); const sourceTaggedButUnbacked = nodes .filter((n: any) => directEvidenceTypes.has(n.node_type) && n.source_id && !n.evidence_id) .map((n: any) => ({ id: n.id, title: n.title, node_type: n.node_type, source_id: n.source_id })); const assertionTypes = new Set(["claim","counterclaim","forecast","trend","metric"]); const evidenceNodeIds = new Set(evidenceBacked.map((n: any) => n.id)); const supportingRelationships = new Set([ "supports","contradicts","qualifies","forecast_for","actual_for","regional_breakdown_of" ]); const unsupportedClaims = nodes .filter((n: any) => assertionTypes.has(n.node_type)) .filter((claim: any) => !edges.some((edge: any) => edge.to_node_id === claim.id && evidenceNodeIds.has(edge.from_node_id) && supportingRelationships.has(edge.relationship) )) .map((n: any) => ({ id: n.id, title: n.title, node_type: n.node_type })); const staleCutoff = Date.now() - 45 * 86_400_000; const staleEvidence = evidenceBacked .filter((n: any) => { const value = n.snapshot_published_at ?? n.snapshot_retrieved_at; const ts = value ? new Date(value).getTime() : NaN; return Number.isFinite(ts) && ts < staleCutoff; }) .map((n: any) => ({ id: n.id, title: n.title, source_id: n.provenance_source_id })); const checks = { at_least_two_independent_sources: distinctGroups.length >= 2, at_least_two_evidence_backed_nodes: evidenceBacked.length >= 2, at_least_one_tier_one_source: tierOneCount >= 1, all_assertions_have_direct_evidence: unsupportedClaims.length === 0, no_source_tag_only_direct_evidence_nodes: sourceTaggedButUnbacked.length === 0 }; return { robust: Object.values(checks).every(Boolean), checks, counts: { evidence_backed_nodes: evidenceBacked.length, distinct_sources: distinctSourceIds.length, distinct_independence_groups: distinctGroups.length, publishers: publishers.length, tier_one_evidence_nodes: tierOneCount, unsupported_claims: unsupportedClaims.length, source_tag_only_nodes: sourceTaggedButUnbacked.length, stale_evidence: staleEvidence.length }, sources: evidenceBacked.map((n: any) => ({ node_id: n.id, node_type: n.node_type, title: n.title, source_id: n.provenance_source_id, publisher: n.provenance_publisher, source_class: n.provenance_source_class, independence_group: n.provenance_independence_group, authority_tier: n.provenance_authority_tier, evidence_id: n.evidence_id, snapshot_id: n.snapshot_id, snapshot_retrieved_at: n.snapshot_retrieved_at, snapshot_published_at: n.snapshot_published_at, evidence_kind: n.evidence_kind, snapshot_kind: n.snapshot_kind, snapshot_text_length: n.snapshot_text_length, snapshot_payload_length: n.snapshot_payload_length, evidence_excerpt_length: n.evidence_excerpt_length, url: n.evidence_url ?? n.signal_url })), unsupported_claims: unsupportedClaims, source_tag_only_nodes: sourceTaggedButUnbacked, stale_evidence: staleEvidence }; } function buildStoryResearchChecklist(story: any, sourceAudit: any) { const nodes = Array.isArray(story.nodes) ? story.nodes : []; const activeNodes = nodes.filter((n: any) => n.review_state !== "irrelevant"); const counterDone = (story.research_checks ?? []).some( (c: any) => c.check_type === "counter_evidence" && ["found","none_found"].includes(c.result) ); const unresolvedQuestions = activeNodes.filter( (n: any) => n.node_type === "question" && n.review_state !== "resolved" ); const items = [ { key: "primary_evidence", label: "Primary/substantive evidence captured", done: Number(sourceAudit?.counts?.evidence_backed_nodes ?? 0) >= 1 }, { key: "counter_evidence", label: "Counter-evidence pass completed", done: counterDone }, { key: "independent_corroboration", label: "At least two independent source groups", done: Number(sourceAudit?.counts?.distinct_independence_groups ?? 0) >= 2 }, { key: "assertions_supported", label: "All active assertions connected to substantive evidence", done: Number(sourceAudit?.counts?.unsupported_claims ?? 0) === 0 }, { key: "questions_resolved", label: "Open research questions resolved or explicitly parked", done: unresolvedQuestions.length === 0 } ]; return { ready_for_editorial: Boolean(sourceAudit?.robust && counterDone), completed: items.filter((item) => item.done).length, total: items.length, unresolved_questions: unresolvedQuestions.map((n: any) => ({ id: n.id, title: n.title, summary: n.summary })), items }; } export async function getStoryThread(id: string) { const threadResult = await pool.query( `SELECT t.*, (SELECT result FROM story_research_checks c WHERE c.thread_id=t.id AND c.check_type='counter_evidence' LIMIT 1) AS counter_evidence_result FROM story_threads t WHERE t.id=$1`, [id] ); if (!threadResult.rowCount) return null; const [nodes, edges, checks, requests] = await Promise.all([ pool.query( `SELECT n.*, COALESCE(n.source_id,s.source_id) AS provenance_source_id, e.source_url AS evidence_url, s.url AS signal_url, m.title AS message_title, src.publisher AS provenance_publisher, src.source_class AS provenance_source_class, src.independence_group AS provenance_independence_group, src.authority_tier AS provenance_authority_tier, e.evidence_kind AS evidence_kind, e.locator AS evidence_locator, e.excerpt AS evidence_excerpt, ss.id AS snapshot_id, ss.snapshot_kind AS snapshot_kind, ss.retrieved_at AS snapshot_retrieved_at, ss.published_at AS snapshot_published_at, LENGTH(COALESCE(ss.document_text,''))::int AS snapshot_text_length, LENGTH(COALESCE(ss.snapshot_json::text,''))::int AS snapshot_payload_length, LENGTH(COALESCE(e.excerpt,''))::int AS evidence_excerpt_length FROM story_nodes n LEFT JOIN evidence e ON e.id=n.evidence_id LEFT JOIN signals s ON s.id=COALESCE(n.signal_id,e.signal_id) LEFT JOIN agent_messages m ON m.id=n.message_id LEFT JOIN sources src ON src.id=COALESCE(n.source_id,s.source_id) LEFT JOIN source_snapshots ss ON ss.id=e.snapshot_id WHERE n.thread_id=$1 ORDER BY n.created_at ASC`, [id] ), pool.query( "SELECT * FROM story_edges WHERE thread_id=$1 ORDER BY created_at ASC", [id] ), pool.query( "SELECT * FROM story_research_checks WHERE thread_id=$1 ORDER BY completed_at DESC", [id] ), pool.query( "SELECT * FROM agent_messages WHERE related_type='story' AND related_id=$1 ORDER BY created_at DESC", [id] ) ]); const story = { ...threadResult.rows[0], nodes: nodes.rows, edges: edges.rows, research_checks: checks.rows, research_requests: requests.rows }; const sourceAudit = buildStorySourceAudit(story); return { ...story, source_audit: sourceAudit, research_checklist: buildStoryResearchChecklist(story, sourceAudit) }; } export async function addStoryNode( threadId: string, args: { nodeType: string; title: string; summary?: string; sourceId?: string | null; signalId?: string | null; evidenceId?: string | null; opportunityId?: string | null; messageId?: string | null; confidence?: number | null; }, actor: StoryActor ) { const nodeType = String(args.nodeType ?? ""); if (!STORY_NODE_TYPES.has(nodeType)) throw new Error("Invalid story node type"); const title = String(args.title ?? "").trim(); const summary = String(args.summary ?? "").trim(); if (!title) throw new Error("Story node title is required"); if (title.length > 240) throw new Error("Story node title is too long"); if (summary.length > 8000) throw new Error("Story node summary is too long"); const confidence = args.confidence == null ? null : Number(args.confidence); if (confidence != null && (!Number.isFinite(confidence) || confidence < 0 || confidence > 1)) { throw new Error("confidence must be between 0 and 1"); } const thread = await pool.query("SELECT id FROM story_threads WHERE id=$1", [threadId]); if (!thread.rowCount) throw new Error("Story thread not found"); if (args.evidenceId) { await enrichEvidenceDocument(args.evidenceId); } const id = randomUUID(); const result = await pool.query( `INSERT INTO story_nodes( id,thread_id,node_type,title,summary,source_id,signal_id,evidence_id, opportunity_id,message_id,confidence, created_by_sub,created_by_name,created_by_client_id ) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`, [ id, threadId, nodeType, title, summary, args.sourceId ?? null, args.signalId ?? null, args.evidenceId ?? null, args.opportunityId ?? null, args.messageId ?? null, confidence, actor.sub, actor.name, actor.clientId ] ); await pool.query("UPDATE story_threads SET updated_at=NOW() WHERE id=$1", [threadId]); await pool.query( `INSERT INTO audit_events(id,event_type,object_type,object_id,payload) VALUES($1,'story_node.added','story_node',$2,$3::jsonb)`, [randomUUID(), id, JSON.stringify({ threadId, nodeType, actor })] ); return result.rows[0]; } export async function setStoryNodeReviewState( threadId: string, nodeId: string, reviewState: string, actor: StoryActor ) { const allowed = new Set(["active","resolved","irrelevant"]); if (!allowed.has(reviewState)) throw new Error("Invalid story node review state"); const result = await pool.query( `UPDATE story_nodes SET review_state=$3 WHERE id=$2 AND thread_id=$1 RETURNING *`, [threadId,nodeId,reviewState] ); if (!result.rowCount) throw new Error("Story node not found"); await pool.query("UPDATE story_threads SET updated_at=NOW() WHERE id=$1", [threadId]); await pool.query( `INSERT INTO audit_events(id,event_type,object_type,object_id,payload) VALUES($1,'story_node.review_state_changed','story_node',$2,$3::jsonb)`, [randomUUID(), nodeId, JSON.stringify({ threadId, reviewState, actor })] ); return result.rows[0]; } export async function addStoryEdge( threadId: string, args: { fromNodeId: string; toNodeId: string; relationship: string; reason?: string; }, actor: StoryActor ) { const relationship = String(args.relationship ?? ""); if (!STORY_RELATIONSHIPS.has(relationship)) throw new Error("Invalid story relationship"); if (args.fromNodeId === args.toNodeId) throw new Error("Story edge cannot point to itself"); const nodeCheck = await pool.query( `SELECT id FROM story_nodes WHERE thread_id=$1 AND id = ANY($2::text[])`, [threadId, [args.fromNodeId, args.toNodeId]] ); if (nodeCheck.rowCount !== 2) { throw new Error("Both story nodes must belong to this thread"); } const id = randomUUID(); const result = await pool.query( `INSERT INTO story_edges( id,thread_id,from_node_id,to_node_id,relationship,reason, created_by_sub,created_by_name,created_by_client_id ) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT(thread_id,from_node_id,to_node_id,relationship) DO UPDATE SET reason=EXCLUDED.reason RETURNING *`, [ id, threadId, args.fromNodeId, args.toNodeId, relationship, String(args.reason ?? "").slice(0, 2000), actor.sub, actor.name, actor.clientId ] ); await pool.query("UPDATE story_threads SET updated_at=NOW() WHERE id=$1", [threadId]); return result.rows[0]; } export async function completeCounterEvidenceCheck( threadId: string, args: { result: string; sourcesChecked: string[]; summary: string }, actor: StoryActor ) { const resultValue = String(args.result ?? ""); if (!new Set(["found", "none_found"]).has(resultValue)) { throw new Error("Counter-evidence result must be found or none_found"); } const sources = Array.from( new Set((args.sourcesChecked ?? []).map((v) => String(v).trim()).filter(Boolean)) ).slice(0, 50); if (!sources.length) throw new Error("At least one checked source is required"); const summary = String(args.summary ?? "").trim(); if (!summary) throw new Error("Counter-evidence summary is required"); const id = randomUUID(); const result = await pool.query( `INSERT INTO story_research_checks( id,thread_id,check_type,result,sources_checked,summary, completed_by_sub,completed_by_name,completed_by_client_id ) VALUES($1,$2,'counter_evidence',$3,$4::jsonb,$5,$6,$7,$8) ON CONFLICT(thread_id,check_type) DO UPDATE SET result=EXCLUDED.result, sources_checked=EXCLUDED.sources_checked, summary=EXCLUDED.summary, completed_by_sub=EXCLUDED.completed_by_sub, completed_by_name=EXCLUDED.completed_by_name, completed_by_client_id=EXCLUDED.completed_by_client_id, completed_at=NOW() RETURNING *`, [ id, threadId, resultValue, JSON.stringify(sources), summary, actor.sub, actor.name, actor.clientId ] ); await pool.query("UPDATE story_threads SET updated_at=NOW() WHERE id=$1", [threadId]); return result.rows[0]; } export async function markStoryThreadStatus( id: string, status: string, actorLabel: string ) { if (!STORY_STATUSES.has(status)) throw new Error("Invalid story status"); if (status === "review" || status === "develop") { const check = await pool.query( `SELECT result FROM story_research_checks WHERE thread_id=$1 AND check_type='counter_evidence'`, [id] ); if (!check.rowCount || !new Set(["found", "none_found"]).has(check.rows[0].result)) { throw new Error("Counter-evidence research must be completed before review/develop"); } const sourceAudit = await auditStorySources(id); if (!sourceAudit.robust) { throw new Error("Story sourcing is not robust enough for review/develop; run story_source_audit for gaps"); } } const result = await pool.query( "UPDATE story_threads SET status=$2,updated_at=NOW() WHERE id=$1 RETURNING *", [id, status] ); if (!result.rowCount) return null; await pool.query( `INSERT INTO audit_events(id,event_type,object_type,object_id,payload) VALUES($1,'story_thread.status_changed','story_thread',$2,$3::jsonb)`, [randomUUID(), id, JSON.stringify({ status, actorLabel })] ); return result.rows[0]; } export async function auditStorySources(threadId: string) { const story = await getStoryThread(threadId); if (!story) throw new Error("Story thread not found"); return { thread_id: threadId, ...(story as any).source_audit }; } export async function enrichEvidenceDocument(evidenceId: string) { const current = await pool.query( `SELECT e.*,s.source_id,s.url,s.published_at FROM evidence e JOIN signals s ON s.id=e.signal_id WHERE e.id=$1`, [evidenceId] ); if (!current.rowCount) throw new Error("Evidence not found"); const row = current.rows[0]; if ( (row.evidence_kind === "source_document" || row.evidence_kind === "source_payload") && row.snapshot_id ) { return getEvidence(evidenceId); } const document = await fetchSourceDocument(row.url); const snapshotId = randomUUID(); await pool.query("BEGIN"); try { await pool.query( `INSERT INTO source_snapshots( id,source_id,signal_id,source_url,retrieved_at,published_at,content_hash, content_type,byte_length,snapshot_json,snapshot_kind,canonical_url, document_text,http_status ) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,'source_document',$11,$12,$13) ON CONFLICT(signal_id,content_hash) DO NOTHING`, [ snapshotId, row.source_id, row.signal_id, row.url, document.retrievedAt, row.published_at, document.contentHash, document.contentType, document.byteLength, JSON.stringify({ requestedUrl: document.requestedUrl, canonicalUrl: document.canonicalUrl, title: document.title, retrievedAt: document.retrievedAt, contentHash: document.contentHash }), document.canonicalUrl, document.text, document.httpStatus ] ); const snapshot = await pool.query( `SELECT id FROM source_snapshots WHERE signal_id=$1 AND content_hash=$2 ORDER BY retrieved_at DESC LIMIT 1`, [row.signal_id, document.contentHash] ); const resolvedSnapshotId = snapshot.rows[0]?.id; if (!resolvedSnapshotId) throw new Error("Failed to persist source document snapshot"); await pool.query( `UPDATE evidence SET snapshot_id=$2, content_hash=$3, excerpt=$4, evidence_kind='source_document', locator='main document body' WHERE id=$1`, [ evidenceId, resolvedSnapshotId, document.contentHash, document.text.slice(0, 8000) ] ); await pool.query( `INSERT INTO audit_events(id,event_type,object_type,object_id,payload) VALUES($1,'evidence.source_document_frozen','evidence',$2,$3::jsonb)`, [randomUUID(), evidenceId, JSON.stringify({ sourceId: row.source_id, url: document.canonicalUrl, snapshotId: resolvedSnapshotId, contentHash: document.contentHash })] ); await pool.query("COMMIT"); return getEvidence(evidenceId); } catch (error) { await pool.query("ROLLBACK"); throw error; } } export async function enrichStoryEvidenceDocuments(limit = 20) { const result = await pool.query( `SELECT DISTINCT n.evidence_id FROM story_nodes n JOIN evidence e ON e.id=n.evidence_id WHERE n.evidence_id IS NOT NULL AND e.evidence_kind <> 'source_document' ORDER BY n.evidence_id LIMIT $1`, [Math.min(Math.max(limit, 1), 100)] ); const enriched: any[] = []; for (const row of result.rows) { try { const evidence = await enrichEvidenceDocument(row.evidence_id); enriched.push({ evidence_id: row.evidence_id, ok: true, snapshot_id: evidence?.snapshot_id ?? null }); } catch (error) { const message = error instanceof Error ? error.message : String(error); const errorHash = createHash("sha256").update(message).digest("hex").slice(0, 16); await postSystemMessageOnce({ dedupeKey: `evidence-enrich-error:${row.evidence_id}:${errorHash}`, title: "Source document enrichment failed", body: `Evidence ${row.evidence_id} could not be upgraded from discovery metadata to a frozen source document: ${message}`, severity: "warning", kind: "warning", relatedType: "evidence", relatedId: row.evidence_id }); enriched.push({ evidence_id: row.evidence_id, ok: false, error: message }); } } return enriched; } export async function seedInitialStoryThread() { const existing = await pool.query( "SELECT id FROM story_threads WHERE title=$1 LIMIT 1", ["NZ labour market — August 2026"] ); if (existing.rowCount) return existing.rows[0]; const evidence = await pool.query( `SELECT e.id AS evidence_id,e.signal_id,s.source_id,s.title,s.summary FROM evidence e JOIN signals s ON s.id=e.signal_id WHERE s.url ILIKE '%employment-indicators-august-2026%' ORDER BY e.retrieved_at DESC LIMIT 1` ); if (!evidence.rowCount) return null; const message = await pool.query( `SELECT id,title,body FROM agent_messages WHERE title='Counter-evidence pass: August employment lead' ORDER BY created_at DESC LIMIT 1` ); const actor: StoryActor = { sub: "system:story-bootstrap", name: "Story Bootstrap", clientId: "media-intelligence-worker" }; await pool.query("BEGIN"); try { const thread = await createStoryThread({ title: "NZ labour market — August 2026", question: "How should rising filled jobs be interpreted alongside unemployment, underutilisation and economic forecasts?" }, actor); const primary = await addStoryNode(thread.id, { nodeType: "evidence", title: "Stats NZ: Employment indicators August 2026", summary: "Primary stored employment signal for this research thread.", sourceId: evidence.rows[0].source_id, signalId: evidence.rows[0].signal_id, evidenceId: evidence.rows[0].evidence_id }, actor); const claim = await addStoryNode(thread.id, { nodeType: "claim", title: "Filled jobs are increasing", summary: "Candidate interpretation from the August filled-jobs signal; this is not a final editorial conclusion." }, actor); const counter = await addStoryNode(thread.id, { nodeType: "counterclaim", title: "Filled jobs alone do not establish labour-market strengthening", summary: "Counter-hypothesis: unemployment, underutilisation, participation, population and forward-looking indicators may qualify the headline signal." }, actor); const question = await addStoryNode(thread.id, { nodeType: "question", title: "What evidence contradicts the apparent improvement?", summary: "Required 360° research branch before this thread can move to editorial review." }, actor); await addStoryEdge(thread.id, { fromNodeId: primary.id, toNodeId: claim.id, relationship: "supports", reason: "The Stats NZ filled-jobs signal is the primary evidence behind the candidate claim." }, actor); await addStoryEdge(thread.id, { fromNodeId: counter.id, toNodeId: claim.id, relationship: "qualifies", reason: "Filled-jobs growth needs to be interpreted alongside wider labour-market measures." }, actor); await addStoryEdge(thread.id, { fromNodeId: question.id, toNodeId: counter.id, relationship: "investigates", reason: "The thread explicitly requires a counter-evidence search." }, actor); if (message.rowCount) { const context = await addStoryNode(thread.id, { nodeType: "context", title: "Counter-evidence research pass", summary: message.rows[0].body, messageId: message.rows[0].id }, actor); await addStoryEdge(thread.id, { fromNodeId: context.id, toNodeId: counter.id, relationship: "supports", reason: "Prior research identified wider labour-market measures that qualify the filled-jobs interpretation." }, actor); await completeCounterEvidenceCheck(thread.id, { result: "found", sourcesChecked: ["Stats NZ", "Treasury", "BNZ"], summary: "Material qualifying evidence was identified. Broader RBNZ and MBIE cross-checking remains desirable when those upstream sources are reachable." }, actor); } await pool.query("COMMIT"); await postSystemMessageOnce({ dedupeKey: `story-thread-bootstrap:${thread.id}`, title: "Story graph initialized", body: `Created the first 360° research thread: ${thread.title}. The graph includes the primary Stats NZ evidence, candidate claim, counterclaim, explicit counter-evidence question and persisted research check.`, severity: "notice", kind: "update", relatedType: "general", relatedId: thread.id }); return thread; } catch (error) { await pool.query("ROLLBACK"); throw error; } } export interface AgentMessageActor { sub: string; name: string; clientId: string; } export async function postAgentMessage( args: { title: string; body: string; severity?: string; kind?: string; relatedType?: string | null; relatedId?: string | null; }, actor: AgentMessageActor ) { const title = String(args.title ?? "").trim(); const body = String(args.body ?? "").trim(); if (!title || !body) throw new Error("Message title and body are required"); if (title.length > 180) throw new Error("Message title is too long"); if (body.length > 8000) throw new Error("Message body is too long"); const severity = String(args.severity ?? "info"); const kind = String(args.kind ?? "update"); const allowedSeverities = new Set(["info", "notice", "warning", "error"]); const allowedKinds = new Set(["update", "finding", "request", "warning", "error"]); const allowedRelated = new Set(["opportunity", "source", "evidence", "story", "general"]); if (!allowedSeverities.has(severity)) throw new Error("Invalid message severity"); if (!allowedKinds.has(kind)) throw new Error("Invalid message kind"); const relatedType = args.relatedType ? String(args.relatedType) : null; if (relatedType && !allowedRelated.has(relatedType)) throw new Error("Invalid related_type"); const id = randomUUID(); const result = await pool.query( `INSERT INTO agent_messages( id,actor_sub,actor_name,actor_client_id,severity,kind,title,body,related_type,related_id ) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`, [ id, actor.sub, actor.name, actor.clientId, severity, kind, title, body, relatedType, args.relatedId ? String(args.relatedId) : null ] ); await pool.query( `INSERT INTO audit_events(id,event_type,object_type,object_id,payload) VALUES($1,'agent_message.posted','agent_message',$2,$3::jsonb)`, [randomUUID(), id, JSON.stringify({ actorSub: actor.sub, actorName: actor.name, actorClientId: actor.clientId, severity, kind, relatedType, relatedId: args.relatedId ?? null })] ); return result.rows[0]; } export async function listAgentMessages(args: { acknowledged?: boolean; severity?: string; since?: string; limit?: number; } = {}) { const values: unknown[] = []; const where: string[] = []; if (typeof args.acknowledged === "boolean") { where.push(args.acknowledged ? "acknowledged_at IS NOT NULL" : "acknowledged_at IS NULL"); } if (args.severity) { values.push(args.severity); where.push(`severity=$${values.length}`); } if (args.since) { values.push(args.since); where.push(`created_at >= $${values.length}::timestamptz`); } const limit = Math.min(Math.max(Number(args.limit ?? 25), 1), 100); values.push(limit); const result = await pool.query( `SELECT * FROM agent_messages ${where.length ? "WHERE " + where.join(" AND ") : ""} ORDER BY created_at DESC LIMIT $${values.length}`, values ); return result.rows; } export async function getAgentMessage(id: string) { const result = await pool.query( "SELECT * FROM agent_messages WHERE id=$1", [id] ); return result.rows[0] ?? null; } export async function acknowledgeAgentMessage(id: string, acknowledgedBy: string) { const result = await pool.query( `UPDATE agent_messages SET acknowledged_at=COALESCE(acknowledged_at,NOW()), acknowledged_by=COALESCE(acknowledged_by,$2) WHERE id=$1 RETURNING *`, [id, acknowledgedBy] ); if (!result.rowCount) return null; await pool.query( `INSERT INTO audit_events(id,event_type,object_type,object_id,payload) VALUES($1,'agent_message.acknowledged','agent_message',$2,$3::jsonb)`, [randomUUID(), id, JSON.stringify({ acknowledgedBy })] ); return result.rows[0]; } export async function postSystemMessageOnce(args: { dedupeKey: string; title: string; body: string; severity?: string; kind?: string; relatedType?: string | null; relatedId?: string | null; }) { const id = randomUUID(); const result = await pool.query( `INSERT INTO agent_messages( id,actor_sub,actor_name,actor_client_id,severity,kind,title,body, related_type,related_id,dedupe_key ) VALUES($1,'system:source-monitor','Source Monitor','media-intelligence-worker', $2,$3,$4,$5,$6,$7,$8) ON CONFLICT(dedupe_key) DO NOTHING RETURNING *`, [ id, args.severity ?? "warning", args.kind ?? "warning", args.title, args.body, args.relatedType ?? null, args.relatedId ?? null, args.dedupeKey ] ); return result.rows[0] ?? null; }