Sync 360-degree story research graph

This commit is contained in:
Admin 2026-09-29 07:34:39 +00:00
parent 78235ec5d1
commit 7fa7605343
3 changed files with 793 additions and 4 deletions

507
src/db.ts
View File

@ -88,6 +88,74 @@ export async function migrate() {
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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),
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 ( CREATE TABLE IF NOT EXISTS agent_messages (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
actor_sub TEXT NOT NULL, actor_sub TEXT NOT NULL,
@ -396,6 +464,445 @@ export async function searchEvidence(query: string, limit = 20) {
return result.rows; 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 values: unknown[] = [];
const where: string[] = [];
if (args.status) {
if (!STORY_STATUSES.has(args.status)) throw new Error("Invalid story status");
values.push(args.status);
where.push(`t.status=${values.length}`);
}
const limit = Math.min(Math.max(Number(args.limit ?? 30), 1), 100);
values.push(limit);
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.length ? "WHERE " + where.join(" AND ") : ""}
ORDER BY t.updated_at DESC
LIMIT ${values.length}`,
values
);
return result.rows;
}
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] = 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
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
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]
)
]);
return {
...threadResult.rows[0],
nodes: nodes.rows,
edges: edges.rows,
research_checks: checks.rows
};
}
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");
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 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 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 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 { export interface AgentMessageActor {
sub: string; sub: string;
name: string; name: string;

View File

@ -2,11 +2,15 @@ import express from "express";
import { import {
acknowledgeAgentMessage, acknowledgeAgentMessage,
getOpportunity, getOpportunity,
getStoryThread,
listAgentMessages, listAgentMessages,
listOpportunities, listOpportunities,
listStoryThreads,
markOpportunity, markOpportunity,
markStoryThreadStatus,
migrate, migrate,
pool, pool,
seedInitialStoryThread,
seedSources seedSources
} from "./db.js"; } from "./db.js";
import { import {
@ -48,6 +52,64 @@ function nzTime(value: unknown) {
} }
} }
function storyGraphSvg(story: any) {
const nodes = Array.isArray(story.nodes) ? story.nodes : [];
const edges = Array.isArray(story.edges) ? story.edges : [];
const width = 1000;
const height = 640;
const cx = width / 2;
const cy = height / 2 + 20;
const rx = 390;
const ry = 235;
const positions = new Map<string, { x: number; y: number }>();
nodes.forEach((node: any, index: number) => {
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / Math.max(nodes.length, 1);
positions.set(node.id, {
x: cx + Math.cos(angle) * rx,
y: cy + Math.sin(angle) * ry
});
});
const edgeSvg = edges.map((edge: any) => {
const from = positions.get(edge.from_node_id);
const to = positions.get(edge.to_node_id);
if (!from || !to) return "";
const mx = (from.x + to.x) / 2;
const my = (from.y + to.y) / 2;
return `<g class="edge">
<line x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}" marker-end="url(#arrow)"></line>
<text x="${mx}" y="${my - 5}" text-anchor="middle">${escapeHtml(edge.relationship)}</text>
</g>`;
}).join("");
const nodeSvg = nodes.map((node: any) => {
const p = positions.get(node.id)!;
const title = String(node.title ?? "");
const short = title.length > 36 ? title.slice(0, 33) + "…" : title;
return `<a href="#node-${encodeURIComponent(node.id)}">
<g class="graph-node ${escapeHtml(node.node_type)}">
<title>${escapeHtml(node.title)} — ${escapeHtml(node.node_type)}</title>
<rect x="${p.x - 100}" y="${p.y - 38}" width="200" height="76" rx="14"></rect>
<text class="node-type" x="${p.x}" y="${p.y - 13}" text-anchor="middle">${escapeHtml(node.node_type)}</text>
<text class="node-title" x="${p.x}" y="${p.y + 10}" text-anchor="middle">${escapeHtml(short)}</text>
</g>
</a>`;
}).join("");
return `<svg class="story-graph" viewBox="0 0 ${width} ${height}" role="img" aria-label="Evidence network for ${escapeHtml(story.title)}">
<defs><marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto"><path d="M0,0 L0,6 L9,3 z"></path></marker></defs>
<g class="thread-centre">
<rect x="${cx - 125}" y="${cy - 48}" width="250" height="96" rx="18"></rect>
<text x="${cx}" y="${cy - 8}" text-anchor="middle">STORY THREAD</text>
<text x="${cx}" y="${cy + 18}" text-anchor="middle">${escapeHtml(String(story.title).slice(0, 38))}</text>
</g>
${edgeSvg}
${nodeSvg}
</svg>`;
}
app.get("/health", async (_req, res) => { app.get("/health", async (_req, res) => {
try { try {
await pool.query("SELECT 1"); await pool.query("SELECT 1");
@ -106,6 +168,100 @@ app.get("/api/mcp", requireMcpAuth, (_req, res) => {
}); });
}); });
app.post("/stories/:id/status", requireWebAuth, async (req, res) => {
const user = (req as any).user ?? {};
const identity = String(user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user");
try {
await markStoryThreadStatus(String(req.params.id), String(req.body.status ?? ""), `web:${identity}`);
return res.redirect(`/stories/${encodeURIComponent(String(req.params.id))}`);
} catch (error) {
return res.status(400).type("text").send(error instanceof Error ? error.message : String(error));
}
});
app.get("/stories/:id", requireWebAuth, async (req, res) => {
const story = await getStoryThread(String(req.params.id));
if (!story) return res.status(404).send("Story thread not found");
const user = (req as any).user ?? {};
const identity = user.name || user.preferred_username || user.email || "Signed-in user";
const nodes = Array.isArray(story.nodes) ? story.nodes : [];
const edges = Array.isArray(story.edges) ? story.edges : [];
const checks = Array.isArray(story.research_checks) ? story.research_checks : [];
const counter = checks.find((c:any)=>c.check_type === "counter_evidence");
res.type("html").send(`<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapeHtml(story.title)} — Story Research</title>
<style>
body{font-family:system-ui,-apple-system,sans-serif;max-width:1180px;margin:32px auto;padding:0 20px;color:#1f2937}
header{display:flex;justify-content:space-between;gap:18px;align-items:flex-start}.card{border:1px solid #d1d5db;border-radius:12px;padding:16px;margin:12px 0}
.meta{font-size:.86rem;color:#6b7280}.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin:16px 0}
button{padding:8px 11px;border:1px solid #9ca3af;border-radius:7px;background:#fff;cursor:pointer}a{color:inherit}
.graph-wrap{overflow:auto;border:1px solid #d1d5db;border-radius:14px;background:#fafafa;margin:18px 0}
.story-graph{display:block;min-width:900px;width:100%;height:auto}.edge line{stroke:#9ca3af;stroke-width:2}.edge text{font-size:12px;fill:#4b5563;paint-order:stroke;stroke:#fafafa;stroke-width:5px}
#arrow path{fill:#9ca3af}.thread-centre rect{fill:#111827}.thread-centre text{fill:#fff;font-size:13px}.graph-node rect{fill:#fff;stroke:#9ca3af;stroke-width:2}
.graph-node.evidence rect,.graph-node.actual rect{fill:#eff6ff;stroke:#2563eb}.graph-node.claim rect{fill:#ecfdf5;stroke:#059669}.graph-node.counterclaim rect{fill:#fef2f2;stroke:#dc2626}
.graph-node.question rect{fill:#fff7ed;stroke:#ea580c}.graph-node.forecast rect,.graph-node.trend rect{fill:#faf5ff;stroke:#9333ea}.graph-node.context rect{fill:#f8fafc;stroke:#64748b}
.node-type{font-size:10px;text-transform:uppercase;fill:#6b7280}.node-title{font-size:12px;font-weight:650;fill:#111827}
.node-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px}.node-card{scroll-margin-top:20px}
.badge{display:inline-block;padding:3px 7px;border-radius:999px;background:#f3f4f6;font-size:.76rem}
.check-ok{border-left:5px solid #059669}.check-pending{border-left:5px solid #d97706}
</style></head><body>
<header><div><a href="/stories">← Story Threads</a><h1>${escapeHtml(story.title)}</h1><p>${escapeHtml(story.question)}</p></div>
<div class="meta">${escapeHtml(identity)} · <a href="/auth/logout">Sign out</a></div></header>
<div class="card">
<strong>Status:</strong> ${escapeHtml(story.status)} ·
<strong>Nodes:</strong> ${nodes.length} ·
<strong>Edges:</strong> ${edges.length}
<div class="toolbar">
${["researching","review","develop","closed"].map(s=>`<form method="post" action="/stories/${encodeURIComponent(story.id)}/status"><input type="hidden" name="status" value="${s}"><button type="submit">${s}</button></form>`).join("")}
</div>
</div>
<div class="card ${counter ? 'check-ok' : 'check-pending'}">
<strong>Mandatory counter-evidence pass:</strong>
${counter ? `${escapeHtml(counter.result)} · sources checked: ${escapeHtml((counter.sources_checked ?? []).join(", "))}<p>${escapeHtml(counter.summary)}</p>` : "not completed"}
</div>
<div class="graph-wrap">${storyGraphSvg(story)}</div>
<h2>Research nodes</h2>
<div class="node-list">
${nodes.map((n:any)=>`<div class="card node-card" id="node-${escapeHtml(n.id)}">
<span class="badge">${escapeHtml(n.node_type)}</span>
<h3>${escapeHtml(n.title)}</h3>
<p>${escapeHtml(n.summary)}</p>
<div class="meta">${n.provenance_source_id ? `source ${escapeHtml(n.provenance_source_id)} · ` : ""}${n.confidence != null ? `confidence ${escapeHtml(n.confidence)} · ` : ""}${escapeHtml(n.created_by_name)}</div>
${n.evidence_url ? `<p><a href="${escapeHtml(n.evidence_url)}" target="_blank" rel="noreferrer">Open evidence ↗</a></p>` : ""}
</div>`).join("")}
</div>
<h2>Relationships</h2>
${edges.length ? edges.map((e:any)=>`<div class="card"><strong>${escapeHtml(e.relationship)}</strong><br><code>${escapeHtml(e.from_node_id.slice(0,8))}</code> → <code>${escapeHtml(e.to_node_id.slice(0,8))}</code><p>${escapeHtml(e.reason)}</p></div>`).join("") : '<div class="card meta">No graph edges yet.</div>'}
</body></html>`);
});
app.get("/stories", requireWebAuth, async (req, res) => {
const stories = await listStoryThreads({ limit: 100 });
const user = (req as any).user ?? {};
const identity = user.name || user.preferred_username || user.email || "Signed-in user";
res.type("html").send(`<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Story Threads — Media Intelligence</title>
<style>
body{font-family:system-ui,-apple-system,sans-serif;max-width:1040px;margin:36px auto;padding:0 20px;color:#1f2937}
header{display:flex;justify-content:space-between;gap:20px}.card{border:1px solid #d1d5db;border-radius:12px;padding:16px;margin:12px 0}.meta{font-size:.86rem;color:#6b7280}
.title{font-size:1.08rem;font-weight:700;text-decoration:none}a{color:inherit}.badge{display:inline-block;padding:3px 7px;border-radius:999px;background:#f3f4f6;font-size:.76rem}
</style></head><body>
<header><div><a href="/">← Dashboard</a><h1>360° Story Threads</h1><p>Evidence networks that require an explicit counter-evidence pass before editorial review.</p></div>
<div class="meta">${escapeHtml(identity)} · <a href="/auth/logout">Sign out</a></div></header>
${stories.length ? stories.map((s:any)=>`<div class="card">
<a class="title" href="/stories/${encodeURIComponent(s.id)}">${escapeHtml(s.title)}</a>
<p>${escapeHtml(s.question)}</p>
<div class="meta"><span class="badge">${escapeHtml(s.status)}</span> · ${s.node_count} nodes · ${s.edge_count} edges · counter-evidence: ${escapeHtml(s.counter_evidence_result ?? "pending")}</div>
</div>`).join("") : '<div class="card meta">No story threads yet.</div>'}
</body></html>`);
});
app.post("/messages/:id/ack", requireWebAuth, async (req, res) => { app.post("/messages/:id/ack", requireWebAuth, async (req, res) => {
const user = (req as any).user ?? {}; const user = (req as any).user ?? {};
const identity = String( const identity = String(
@ -204,7 +360,7 @@ app.get("/", requireWebAuth, async (req, res) => {
COUNT(sig.id)::int AS signal_count COUNT(sig.id)::int AS signal_count
FROM sources s FROM sources s
LEFT JOIN signals sig ON sig.source_id=s.id LEFT JOIN signals sig ON sig.source_id=s.id
GROUP BY s.id,s.name,s.last_synced_at,s.last_error GROUP BY s.id,s.name,s.last_synced_at,s.last_error,s.baseline_complete
ORDER BY s.id` ORDER BY s.id`
); );
const counts = await pool.query(` const counts = await pool.query(`
@ -212,7 +368,8 @@ app.get("/", requireWebAuth, async (req, res) => {
(SELECT COUNT(*) FROM signals) AS signals, (SELECT COUNT(*) FROM signals) AS signals,
(SELECT COUNT(*) FROM opportunities WHERE status <> 'closed') AS opportunities, (SELECT COUNT(*) FROM opportunities WHERE status <> 'closed') AS opportunities,
(SELECT COUNT(*) FROM opportunities WHERE status='new') AS new_opportunities, (SELECT COUNT(*) FROM opportunities WHERE status='new') AS new_opportunities,
(SELECT COUNT(*) FROM agent_messages WHERE acknowledged_at IS NULL) AS unread_messages (SELECT COUNT(*) FROM agent_messages WHERE acknowledged_at IS NULL) AS unread_messages,
(SELECT COUNT(*) FROM story_threads WHERE status <> 'closed') AS story_threads
`); `);
const messages = await listAgentMessages({ acknowledged: false, limit: 10 }); const messages = await listAgentMessages({ acknowledged: false, limit: 10 });
const c = counts.rows[0] ?? {}; const c = counts.rows[0] ?? {};
@ -239,7 +396,7 @@ a{color:inherit}
button{margin-top:12px;padding:7px 11px;border:1px solid #9ca3af;border-radius:7px;background:#fff;cursor:pointer} button{margin-top:12px;padding:7px 11px;border:1px solid #9ca3af;border-radius:7px;background:#fff;cursor:pointer}
</style></head><body> </style></head><body>
<header> <header>
<div><h1>Media Intelligence MVP</h1><p>Human-led NZ public-source monitoring behind a dedicated MCP.</p><p><a href="/opportunities">Open Opportunities →</a></p></div> <div><h1>Media Intelligence MVP</h1><p>Human-led NZ public-source monitoring behind a dedicated MCP.</p><p><a href="/opportunities">Open Opportunities →</a> · <a href="/stories">Open 360° Story Threads →</a></p></div>
<div class="user"><strong>${escapeHtml(identity)}</strong><br><a href="/auth/logout">Sign out</a></div> <div class="user"><strong>${escapeHtml(identity)}</strong><br><a href="/auth/logout">Sign out</a></div>
</header> </header>
<div class="grid"> <div class="grid">
@ -247,6 +404,7 @@ button{margin-top:12px;padding:7px 11px;border:1px solid #9ca3af;border-radius:7
<div class="card"><strong class="big">${c.opportunities ?? 0}</strong>opportunities</div> <div class="card"><strong class="big">${c.opportunities ?? 0}</strong>opportunities</div>
<div class="card"><strong class="big">${c.new_opportunities ?? 0}</strong>new</div> <div class="card"><strong class="big">${c.new_opportunities ?? 0}</strong>new</div>
<div class="card"><strong class="big">${c.unread_messages ?? 0}</strong>agent messages</div> <div class="card"><strong class="big">${c.unread_messages ?? 0}</strong>agent messages</div>
<div class="card"><strong class="big">${c.story_threads ?? 0}</strong>story threads</div>
</div> </div>
<h2>Agent Activity</h2> <h2>Agent Activity</h2>
${messages.length ${messages.length
@ -269,6 +427,7 @@ ${sourceRows.rows.map((s:any)=>`<div class="card"><strong>${s.name}</strong><br>
async function start() { async function start() {
await migrate(); await migrate();
await seedSources(SOURCES); await seedSources(SOURCES);
await seedInitialStoryThread();
const port = Number(process.env.PORT ?? 3000); const port = Number(process.env.PORT ?? 3000);
app.listen(port, "0.0.0.0", () => { app.listen(port, "0.0.0.0", () => {

View File

@ -1,12 +1,18 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { import {
acknowledgeAgentMessage, acknowledgeAgentMessage,
addStoryEdge,
addStoryNode,
completeCounterEvidenceCheck,
createStoryThread,
getAgentMessage, getAgentMessage,
getEvidence, getEvidence,
getOpportunity, getOpportunity,
getStoryThread,
listAgentMessages, listAgentMessages,
listOpportunities, listOpportunities,
listSources, listSources,
listStoryThreads,
markOpportunity, markOpportunity,
postAgentMessage, postAgentMessage,
searchEvidence, searchEvidence,
@ -144,6 +150,93 @@ const tools = [
required: ["id"], required: ["id"],
additionalProperties: false additionalProperties: false
} }
},
{
name: "story_threads_list",
description: "List 360-degree research story threads and their graph/research status.",
inputSchema: {
type: "object",
properties: {
status: { type: "string", enum: ["researching", "review", "develop", "closed"] },
limit: { type: "integer", minimum: 1, maximum: 100 }
},
additionalProperties: false
}
},
{
name: "story_thread_get",
description: "Get one story thread with all graph nodes, labelled edges and research checks.",
inputSchema: {
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
additionalProperties: false
}
},
{
name: "story_thread_create",
description: "Create a candidate 360-degree research thread. This does not approve, draft or publish a story.",
inputSchema: {
type: "object",
properties: {
title: { type: "string", maxLength: 220 },
question: { type: "string", maxLength: 1200 }
},
required: ["title"],
additionalProperties: false
}
},
{
name: "story_node_add",
description: "Add a research node to a story graph with optional provenance links to an existing signal, evidence record, opportunity or agent message.",
inputSchema: {
type: "object",
properties: {
thread_id: { type: "string" },
node_type: { type: "string", enum: ["signal","evidence","claim","counterclaim","trend","forecast","actual","question","context","organisation","person","place","metric"] },
title: { type: "string", maxLength: 240 },
summary: { type: "string", maxLength: 8000 },
source_id: { type: "string" },
signal_id: { type: "string" },
evidence_id: { type: "string" },
opportunity_id: { type: "string" },
message_id: { type: "string" },
confidence: { type: "number", minimum: 0, maximum: 1 }
},
required: ["thread_id","node_type","title"],
additionalProperties: false
}
},
{
name: "story_edge_add",
description: "Connect two nodes in a story graph with an explicit evidentiary relationship.",
inputSchema: {
type: "object",
properties: {
thread_id: { type: "string" },
from_node_id: { type: "string" },
to_node_id: { type: "string" },
relationship: { type: "string", enum: ["supports","contradicts","qualifies","explains","correlates_with","precedes","updates","supersedes","same_subject","regional_breakdown_of","forecast_for","actual_for","investigates"] },
reason: { type: "string", maxLength: 2000 }
},
required: ["thread_id","from_node_id","to_node_id","relationship"],
additionalProperties: false
}
},
{
name: "story_counter_check_complete",
description: "Record the mandatory counter-evidence pass for a story thread, including sources checked and whether material counter-evidence was found.",
inputSchema: {
type: "object",
properties: {
thread_id: { type: "string" },
result: { type: "string", enum: ["found","none_found"] },
sources_checked: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 50 },
summary: { type: "string", maxLength: 8000 }
},
required: ["thread_id","result","sources_checked","summary"],
additionalProperties: false
}
} }
]; ];
@ -196,6 +289,34 @@ async function callTool(name: string, args: any, actor: ReturnType<typeof actorF
args.id, args.id,
`${actor.clientId}:${actor.name}` `${actor.clientId}:${actor.name}`
)); ));
if (name === "story_threads_list") return result(await listStoryThreads(args ?? {}));
if (name === "story_thread_get") return result(await getStoryThread(args.id));
if (name === "story_thread_create") return result(await createStoryThread({
title: args.title,
question: args.question
}, actor));
if (name === "story_node_add") return result(await addStoryNode(args.thread_id, {
nodeType: args.node_type,
title: args.title,
summary: args.summary,
sourceId: args.source_id,
signalId: args.signal_id,
evidenceId: args.evidence_id,
opportunityId: args.opportunity_id,
messageId: args.message_id,
confidence: args.confidence
}, actor));
if (name === "story_edge_add") return result(await addStoryEdge(args.thread_id, {
fromNodeId: args.from_node_id,
toNodeId: args.to_node_id,
relationship: args.relationship,
reason: args.reason
}, actor));
if (name === "story_counter_check_complete") return result(await completeCounterEvidenceCheck(args.thread_id, {
result: args.result,
sourcesChecked: args.sources_checked,
summary: args.summary
}, actor));
throw new Error(`Unknown tool: ${name}`); throw new Error(`Unknown tool: ${name}`);
} }
@ -241,7 +362,8 @@ export async function handleMcp(req: Request, res: Response) {
resourceTemplates: [ resourceTemplates: [
{ uriTemplate: "media://opportunities/{id}", name: "Media opportunity", mimeType: "application/json" }, { uriTemplate: "media://opportunities/{id}", name: "Media opportunity", mimeType: "application/json" },
{ uriTemplate: "media://evidence/{id}", name: "Source evidence", mimeType: "application/json" }, { uriTemplate: "media://evidence/{id}", name: "Source evidence", mimeType: "application/json" },
{ uriTemplate: "media://messages/{id}", name: "Agent activity message", mimeType: "application/json" } { uriTemplate: "media://messages/{id}", name: "Agent activity message", mimeType: "application/json" },
{ uriTemplate: "media://stories/{id}", name: "360-degree story research graph", mimeType: "application/json" }
] ]
}; };
} else if (method === "resources/read") { } else if (method === "resources/read") {
@ -250,6 +372,7 @@ export async function handleMcp(req: Request, res: Response) {
else if (uri.startsWith("media://opportunities/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getOpportunity(uri.split("/").pop()!), null, 2) }] }; else if (uri.startsWith("media://opportunities/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getOpportunity(uri.split("/").pop()!), null, 2) }] };
else if (uri.startsWith("media://evidence/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getEvidence(uri.split("/").pop()!), null, 2) }] }; else if (uri.startsWith("media://evidence/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getEvidence(uri.split("/").pop()!), null, 2) }] };
else if (uri.startsWith("media://messages/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getAgentMessage(uri.split("/").pop()!), null, 2) }] }; else if (uri.startsWith("media://messages/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getAgentMessage(uri.split("/").pop()!), null, 2) }] };
else if (uri.startsWith("media://stories/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getStoryThread(uri.split("/").pop()!), null, 2) }] };
else throw new Error("Unknown resource URI"); else throw new Error("Unknown resource URI");
} else { } else {
return res.status(404).json({ return res.status(404).json({