Sync 360-degree story research graph
This commit is contained in:
parent
78235ec5d1
commit
7fa7605343
507
src/db.ts
507
src/db.ts
@ -88,6 +88,74 @@ export async function migrate() {
|
||||
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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor_sub TEXT NOT NULL,
|
||||
@ -396,6 +464,445 @@ export async function searchEvidence(query: string, limit = 20) {
|
||||
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 {
|
||||
sub: string;
|
||||
name: string;
|
||||
|
||||
165
src/index.ts
165
src/index.ts
@ -2,11 +2,15 @@ import express from "express";
|
||||
import {
|
||||
acknowledgeAgentMessage,
|
||||
getOpportunity,
|
||||
getStoryThread,
|
||||
listAgentMessages,
|
||||
listOpportunities,
|
||||
listStoryThreads,
|
||||
markOpportunity,
|
||||
markStoryThreadStatus,
|
||||
migrate,
|
||||
pool,
|
||||
seedInitialStoryThread,
|
||||
seedSources
|
||||
} from "./db.js";
|
||||
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) => {
|
||||
try {
|
||||
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) => {
|
||||
const user = (req as any).user ?? {};
|
||||
const identity = String(
|
||||
@ -204,7 +360,7 @@ app.get("/", requireWebAuth, async (req, res) => {
|
||||
COUNT(sig.id)::int AS signal_count
|
||||
FROM sources s
|
||||
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`
|
||||
);
|
||||
const counts = await pool.query(`
|
||||
@ -212,7 +368,8 @@ app.get("/", requireWebAuth, async (req, res) => {
|
||||
(SELECT COUNT(*) FROM signals) AS signals,
|
||||
(SELECT COUNT(*) FROM opportunities WHERE status <> 'closed') AS 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 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}
|
||||
</style></head><body>
|
||||
<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>
|
||||
</header>
|
||||
<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.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.story_threads ?? 0}</strong>story threads</div>
|
||||
</div>
|
||||
<h2>Agent Activity</h2>
|
||||
${messages.length
|
||||
@ -269,6 +427,7 @@ ${sourceRows.rows.map((s:any)=>`<div class="card"><strong>${s.name}</strong><br>
|
||||
async function start() {
|
||||
await migrate();
|
||||
await seedSources(SOURCES);
|
||||
await seedInitialStoryThread();
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
app.listen(port, "0.0.0.0", () => {
|
||||
|
||||
125
src/mcp.ts
125
src/mcp.ts
@ -1,12 +1,18 @@
|
||||
import type { Request, Response } from "express";
|
||||
import {
|
||||
acknowledgeAgentMessage,
|
||||
addStoryEdge,
|
||||
addStoryNode,
|
||||
completeCounterEvidenceCheck,
|
||||
createStoryThread,
|
||||
getAgentMessage,
|
||||
getEvidence,
|
||||
getOpportunity,
|
||||
getStoryThread,
|
||||
listAgentMessages,
|
||||
listOpportunities,
|
||||
listSources,
|
||||
listStoryThreads,
|
||||
markOpportunity,
|
||||
postAgentMessage,
|
||||
searchEvidence,
|
||||
@ -144,6 +150,93 @@ const tools = [
|
||||
required: ["id"],
|
||||
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,
|
||||
`${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}`);
|
||||
}
|
||||
|
||||
@ -241,7 +362,8 @@ export async function handleMcp(req: Request, res: Response) {
|
||||
resourceTemplates: [
|
||||
{ uriTemplate: "media://opportunities/{id}", name: "Media opportunity", 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") {
|
||||
@ -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://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://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 {
|
||||
return res.status(404).json({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user