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()
|
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;
|
||||||
|
|||||||
165
src/index.ts
165
src/index.ts
@ -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", () => {
|
||||||
|
|||||||
125
src/mcp.ts
125
src/mcp.ts
@ -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({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user