Sync role-aware collaboration and Story Thread workbench
This commit is contained in:
parent
2658cf89cb
commit
7cf1c0123a
107
src/auth.ts
107
src/auth.ts
@ -59,6 +59,77 @@ function safeReturnTo(value: unknown) {
|
||||
return text.startsWith("/") && !text.startsWith("//") ? text : "/";
|
||||
}
|
||||
|
||||
export type MediaRole = "viewer" | "editor" | "admin";
|
||||
|
||||
const MEDIA_ROLE_RANK: Record<MediaRole, number> = {
|
||||
viewer: 1,
|
||||
editor: 2,
|
||||
admin: 3
|
||||
};
|
||||
|
||||
function roleNamesFromClaims(claims: Record<string, any>) {
|
||||
const roles = new Set<string>();
|
||||
|
||||
const realmRoles = claims?.realm_access?.roles;
|
||||
if (Array.isArray(realmRoles)) {
|
||||
for (const role of realmRoles) roles.add(String(role));
|
||||
}
|
||||
|
||||
const resourceAccess = claims?.resource_access;
|
||||
if (resourceAccess && typeof resourceAccess === "object") {
|
||||
for (const value of Object.values(resourceAccess as Record<string, any>)) {
|
||||
if (Array.isArray(value?.roles)) {
|
||||
for (const role of value.roles) roles.add(String(role));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const directRoles = claims?.roles;
|
||||
if (Array.isArray(directRoles)) {
|
||||
for (const role of directRoles) roles.add(String(role));
|
||||
}
|
||||
|
||||
return Array.from(roles);
|
||||
}
|
||||
|
||||
export function mediaRoleFromClaims(claims: Record<string, any>): MediaRole {
|
||||
const roles = roleNamesFromClaims(claims).map((role) => role.toLowerCase());
|
||||
|
||||
if (roles.includes("media-intelligence-admin")) return "admin";
|
||||
if (roles.includes("media-intelligence-editor")) return "editor";
|
||||
if (roles.includes("media-intelligence-viewer")) return "viewer";
|
||||
|
||||
if (roles.some((role) => /(^|[-_])(owner|admin)$/.test(role))) return "admin";
|
||||
if (roles.some((role) => /(^|[-_])(operator|developer)$/.test(role))) return "editor";
|
||||
if (roles.some((role) => /(^|[-_])viewer$/.test(role))) return "viewer";
|
||||
|
||||
// Fail safe: authenticated users with no recognised mapping can read but not mutate.
|
||||
return "viewer";
|
||||
}
|
||||
|
||||
export function mediaRoleAtLeast(role: MediaRole, required: MediaRole) {
|
||||
return MEDIA_ROLE_RANK[role] >= MEDIA_ROLE_RANK[required];
|
||||
}
|
||||
|
||||
export function requireWebRole(required: MediaRole) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = ((req as any).user ?? {}) as Record<string, any>;
|
||||
const role = (user.media_role as MediaRole | undefined) ?? mediaRoleFromClaims(user);
|
||||
if (!mediaRoleAtLeast(role, required)) {
|
||||
return res.status(403).type("text").send(
|
||||
`This action requires the ${required} role. Your current Media Intelligence role is ${role}.`
|
||||
);
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
export function webMediaRole(req: Request): MediaRole {
|
||||
const user = ((req as any).user ?? {}) as Record<string, any>;
|
||||
return (user.media_role as MediaRole | undefined) ?? mediaRoleFromClaims(user);
|
||||
}
|
||||
|
||||
|
||||
function webConfig(req: Request) {
|
||||
const issuer = process.env.WEB_OIDC_ISSUER || process.env.KEYCLOAK_ISSUER;
|
||||
const clientId = process.env.WEB_OIDC_CLIENT_ID;
|
||||
@ -141,7 +212,11 @@ export async function requireMcpAuth(req: Request, res: Response, next: NextFunc
|
||||
if (payload.azp !== clientId && !aud.includes(clientId)) {
|
||||
return res.status(403).json({ error: "OAuth token was not issued for this MCP client" });
|
||||
}
|
||||
(req as any).auth = payload;
|
||||
(req as any).auth = {
|
||||
...payload,
|
||||
media_role: mediaRoleFromClaims(payload as Record<string, any>),
|
||||
role_names: roleNamesFromClaims(payload as Record<string, any>)
|
||||
};
|
||||
return next();
|
||||
} catch {
|
||||
return res.status(401).json({ error: "Invalid OAuth access token" });
|
||||
@ -238,11 +313,39 @@ export async function finishWebLogin(req: Request, res: Response) {
|
||||
return res.status(401).send("OIDC nonce validation failed.");
|
||||
}
|
||||
|
||||
let accessPayload: Record<string, any> | null = null;
|
||||
if (tokens.access_token) {
|
||||
try {
|
||||
const verified = await jwtVerify(tokens.access_token, webJwks, {
|
||||
issuer: cfg.issuer
|
||||
});
|
||||
accessPayload = verified.payload as Record<string, any>;
|
||||
} catch {
|
||||
accessPayload = null;
|
||||
}
|
||||
}
|
||||
|
||||
const idClaims = payload as Record<string, any>;
|
||||
const mergedRoleNames = Array.from(new Set([
|
||||
...roleNamesFromClaims(idClaims),
|
||||
...(accessPayload ? roleNamesFromClaims(accessPayload) : [])
|
||||
]));
|
||||
const mediaRole = accessPayload
|
||||
? mediaRoleFromClaims({
|
||||
...idClaims,
|
||||
realm_access: accessPayload.realm_access ?? idClaims.realm_access,
|
||||
resource_access: accessPayload.resource_access ?? idClaims.resource_access,
|
||||
roles: mergedRoleNames
|
||||
})
|
||||
: mediaRoleFromClaims(idClaims);
|
||||
|
||||
const session = await createSession({
|
||||
sub: payload.sub,
|
||||
email: payload.email,
|
||||
preferred_username: payload.preferred_username,
|
||||
name: payload.name
|
||||
name: payload.name,
|
||||
media_role: mediaRole,
|
||||
role_names: mergedRoleNames
|
||||
});
|
||||
|
||||
res.append("Set-Cookie", cookie(SESSION_COOKIE, session, 8 * 60 * 60));
|
||||
|
||||
102
src/db.ts
102
src/db.ts
@ -145,6 +145,7 @@ export async function migrate() {
|
||||
opportunity_id TEXT REFERENCES opportunities(id) ON DELETE SET NULL,
|
||||
message_id TEXT,
|
||||
confidence NUMERIC(4,3),
|
||||
review_state TEXT NOT NULL DEFAULT 'active',
|
||||
created_by_sub TEXT NOT NULL,
|
||||
created_by_name TEXT NOT NULL,
|
||||
created_by_client_id TEXT NOT NULL,
|
||||
@ -253,6 +254,7 @@ export async function migrate() {
|
||||
await pool.query("ALTER TABLE source_snapshots ADD COLUMN IF NOT EXISTS canonical_url TEXT");
|
||||
await pool.query("ALTER TABLE source_snapshots ADD COLUMN IF NOT EXISTS document_text TEXT");
|
||||
await pool.query("ALTER TABLE source_snapshots ADD COLUMN IF NOT EXISTS http_status INTEGER");
|
||||
await pool.query("ALTER TABLE story_nodes ADD COLUMN IF NOT EXISTS review_state TEXT NOT NULL DEFAULT 'active'");
|
||||
|
||||
const payloadMarker = await pool.query(
|
||||
`INSERT INTO app_meta(key,value)
|
||||
@ -1002,8 +1004,11 @@ export async function listStoryThreads(args: {
|
||||
}
|
||||
|
||||
function buildStorySourceAudit(story: any) {
|
||||
const nodes = Array.isArray(story.nodes) ? story.nodes : [];
|
||||
const edges = Array.isArray(story.edges) ? story.edges : [];
|
||||
const allNodes = Array.isArray(story.nodes) ? story.nodes : [];
|
||||
const nodes = allNodes.filter((n: any) => n.review_state !== "irrelevant");
|
||||
const nodeIds = new Set(nodes.map((n: any) => n.id));
|
||||
const edges = (Array.isArray(story.edges) ? story.edges : [])
|
||||
.filter((e: any) => nodeIds.has(e.from_node_id) && nodeIds.has(e.to_node_id));
|
||||
|
||||
const evidenceBacked = nodes.filter((n: any) => {
|
||||
if (!n.evidence_id || !n.snapshot_id || !n.provenance_source_id) return false;
|
||||
@ -1112,6 +1117,57 @@ function buildStorySourceAudit(story: any) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildStoryResearchChecklist(story: any, sourceAudit: any) {
|
||||
const nodes = Array.isArray(story.nodes) ? story.nodes : [];
|
||||
const activeNodes = nodes.filter((n: any) => n.review_state !== "irrelevant");
|
||||
const counterDone = (story.research_checks ?? []).some(
|
||||
(c: any) => c.check_type === "counter_evidence" && ["found","none_found"].includes(c.result)
|
||||
);
|
||||
const unresolvedQuestions = activeNodes.filter(
|
||||
(n: any) => n.node_type === "question" && n.review_state !== "resolved"
|
||||
);
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: "primary_evidence",
|
||||
label: "Primary/substantive evidence captured",
|
||||
done: Number(sourceAudit?.counts?.evidence_backed_nodes ?? 0) >= 1
|
||||
},
|
||||
{
|
||||
key: "counter_evidence",
|
||||
label: "Counter-evidence pass completed",
|
||||
done: counterDone
|
||||
},
|
||||
{
|
||||
key: "independent_corroboration",
|
||||
label: "At least two independent source groups",
|
||||
done: Number(sourceAudit?.counts?.distinct_independence_groups ?? 0) >= 2
|
||||
},
|
||||
{
|
||||
key: "assertions_supported",
|
||||
label: "All active assertions connected to substantive evidence",
|
||||
done: Number(sourceAudit?.counts?.unsupported_claims ?? 0) === 0
|
||||
},
|
||||
{
|
||||
key: "questions_resolved",
|
||||
label: "Open research questions resolved or explicitly parked",
|
||||
done: unresolvedQuestions.length === 0
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
ready_for_editorial: Boolean(sourceAudit?.robust && counterDone),
|
||||
completed: items.filter((item) => item.done).length,
|
||||
total: items.length,
|
||||
unresolved_questions: unresolvedQuestions.map((n: any) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
summary: n.summary
|
||||
})),
|
||||
items
|
||||
};
|
||||
}
|
||||
|
||||
export async function getStoryThread(id: string) {
|
||||
const threadResult = await pool.query(
|
||||
`SELECT t.*,
|
||||
@ -1124,7 +1180,7 @@ export async function getStoryThread(id: string) {
|
||||
);
|
||||
if (!threadResult.rowCount) return null;
|
||||
|
||||
const [nodes, edges, checks] = await Promise.all([
|
||||
const [nodes, edges, checks, requests] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT n.*,
|
||||
COALESCE(n.source_id,s.source_id) AS provenance_source_id,
|
||||
@ -1162,6 +1218,10 @@ export async function getStoryThread(id: string) {
|
||||
pool.query(
|
||||
"SELECT * FROM story_research_checks WHERE thread_id=$1 ORDER BY completed_at DESC",
|
||||
[id]
|
||||
),
|
||||
pool.query(
|
||||
"SELECT * FROM agent_messages WHERE related_type='story' AND related_id=$1 ORDER BY created_at DESC",
|
||||
[id]
|
||||
)
|
||||
]);
|
||||
|
||||
@ -1169,12 +1229,15 @@ export async function getStoryThread(id: string) {
|
||||
...threadResult.rows[0],
|
||||
nodes: nodes.rows,
|
||||
edges: edges.rows,
|
||||
research_checks: checks.rows
|
||||
research_checks: checks.rows,
|
||||
research_requests: requests.rows
|
||||
};
|
||||
|
||||
const sourceAudit = buildStorySourceAudit(story);
|
||||
return {
|
||||
...story,
|
||||
source_audit: buildStorySourceAudit(story)
|
||||
source_audit: sourceAudit,
|
||||
research_checklist: buildStoryResearchChecklist(story, sourceAudit)
|
||||
};
|
||||
}
|
||||
|
||||
@ -1246,6 +1309,33 @@ export async function addStoryNode(
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export async function setStoryNodeReviewState(
|
||||
threadId: string,
|
||||
nodeId: string,
|
||||
reviewState: string,
|
||||
actor: StoryActor
|
||||
) {
|
||||
const allowed = new Set(["active","resolved","irrelevant"]);
|
||||
if (!allowed.has(reviewState)) throw new Error("Invalid story node review state");
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE story_nodes
|
||||
SET review_state=$3
|
||||
WHERE id=$2 AND thread_id=$1
|
||||
RETURNING *`,
|
||||
[threadId,nodeId,reviewState]
|
||||
);
|
||||
if (!result.rowCount) throw new Error("Story node not found");
|
||||
|
||||
await pool.query("UPDATE story_threads SET updated_at=NOW() WHERE id=$1", [threadId]);
|
||||
await pool.query(
|
||||
`INSERT INTO audit_events(id,event_type,object_type,object_id,payload)
|
||||
VALUES($1,'story_node.review_state_changed','story_node',$2,$3::jsonb)`,
|
||||
[randomUUID(), nodeId, JSON.stringify({ threadId, reviewState, actor })]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export async function addStoryEdge(
|
||||
threadId: string,
|
||||
args: {
|
||||
@ -1673,7 +1763,7 @@ export async function postAgentMessage(
|
||||
const kind = String(args.kind ?? "update");
|
||||
const allowedSeverities = new Set(["info", "notice", "warning", "error"]);
|
||||
const allowedKinds = new Set(["update", "finding", "request", "warning", "error"]);
|
||||
const allowedRelated = new Set(["opportunity", "source", "evidence", "general"]);
|
||||
const allowedRelated = new Set(["opportunity", "source", "evidence", "story", "general"]);
|
||||
|
||||
if (!allowedSeverities.has(severity)) throw new Error("Invalid message severity");
|
||||
if (!allowedKinds.has(kind)) throw new Error("Invalid message kind");
|
||||
|
||||
88
src/index.ts
88
src/index.ts
@ -1,6 +1,7 @@
|
||||
import express from "express";
|
||||
import {
|
||||
acknowledgeAgentMessage,
|
||||
addStoryNode,
|
||||
enrichStoryEvidenceDocuments,
|
||||
getOpportunity,
|
||||
getStoryThread,
|
||||
@ -9,6 +10,8 @@ import {
|
||||
listStoryThreads,
|
||||
markOpportunity,
|
||||
markStoryThreadStatus,
|
||||
postAgentMessage,
|
||||
setStoryNodeReviewState,
|
||||
migrate,
|
||||
pool,
|
||||
seedInitialStoryThread,
|
||||
@ -23,7 +26,9 @@ import {
|
||||
logoutWeb,
|
||||
protectedResourceMetadata,
|
||||
requireMcpAuth,
|
||||
requireWebAuth
|
||||
requireWebAuth,
|
||||
requireWebRole,
|
||||
webMediaRole
|
||||
} from "./auth.js";
|
||||
import { handleMcp } from "./mcp.js";
|
||||
import { SOURCES } from "./sources.js";
|
||||
@ -35,6 +40,16 @@ app.set("trust proxy", true);
|
||||
app.use(express.json({ limit: "2mb" }));
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
|
||||
function webActor(req: express.Request) {
|
||||
const user = (req as any).user ?? {};
|
||||
return {
|
||||
sub: String(user.sub ?? "web-user"),
|
||||
name: String(user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user"),
|
||||
clientId: "media-intelligence-web"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function escapeHtml(value: unknown) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
@ -174,7 +189,53 @@ app.get("/api/mcp", requireMcpAuth, (_req, res) => {
|
||||
});
|
||||
|
||||
|
||||
app.post("/stories/:id/status", requireWebAuth, async (req, res) => {
|
||||
app.post("/stories/:id/nodes", requireWebAuth, requireWebRole("editor"), async (req, res) => {
|
||||
const threadId = String(req.params.id);
|
||||
try {
|
||||
await addStoryNode(threadId, {
|
||||
nodeType: String(req.body.node_type ?? "context"),
|
||||
title: String(req.body.title ?? ""),
|
||||
summary: String(req.body.summary ?? "")
|
||||
}, webActor(req));
|
||||
return res.redirect(`/stories/${encodeURIComponent(threadId)}`);
|
||||
} catch (error) {
|
||||
return res.status(400).type("text").send(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/stories/:id/nodes/:nodeId/review", requireWebAuth, requireWebRole("editor"), async (req, res) => {
|
||||
const threadId = String(req.params.id);
|
||||
try {
|
||||
await setStoryNodeReviewState(
|
||||
threadId,
|
||||
String(req.params.nodeId),
|
||||
String(req.body.review_state ?? "active"),
|
||||
webActor(req)
|
||||
);
|
||||
return res.redirect(`/stories/${encodeURIComponent(threadId)}`);
|
||||
} catch (error) {
|
||||
return res.status(400).type("text").send(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/stories/:id/request-research", requireWebAuth, requireWebRole("editor"), async (req, res) => {
|
||||
const threadId = String(req.params.id);
|
||||
try {
|
||||
await postAgentMessage({
|
||||
title: `Research request: ${String(req.body.title ?? "").trim()}`,
|
||||
body: String(req.body.body ?? "").trim(),
|
||||
severity: "notice",
|
||||
kind: "request",
|
||||
relatedType: "story",
|
||||
relatedId: threadId
|
||||
}, webActor(req));
|
||||
return res.redirect(`/stories/${encodeURIComponent(threadId)}`);
|
||||
} catch (error) {
|
||||
return res.status(400).type("text").send(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/stories/:id/status", requireWebAuth, requireWebRole("editor"), async (req, res) => {
|
||||
const user = (req as any).user ?? {};
|
||||
const identity = String(user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user");
|
||||
try {
|
||||
@ -191,7 +252,8 @@ app.get("/stories/:id", requireWebAuth, async (req, res) => {
|
||||
|
||||
const user = (req as any).user ?? {};
|
||||
const identity = String(user.name || user.preferred_username || user.email || "Signed-in user");
|
||||
return res.type("html").send(renderStory({ story, identity }));
|
||||
const role = webMediaRole(req);
|
||||
return res.type("html").send(renderStory({ story, identity, role }));
|
||||
});
|
||||
|
||||
app.get("/stories", requireWebAuth, async (req, res) => {
|
||||
@ -202,10 +264,11 @@ app.get("/stories", requireWebAuth, async (req, res) => {
|
||||
|
||||
const user = (req as any).user ?? {};
|
||||
const identity = String(user.name || user.preferred_username || user.email || "Signed-in user");
|
||||
return res.type("html").send(renderStories({ stories, identity }));
|
||||
const role = webMediaRole(req);
|
||||
return res.type("html").send(renderStories({ stories, identity, role }));
|
||||
});
|
||||
|
||||
app.post("/messages/:id/ack", requireWebAuth, async (req, res) => {
|
||||
app.post("/messages/:id/ack", requireWebAuth, requireWebRole("editor"), async (req, res) => {
|
||||
const user = (req as any).user ?? {};
|
||||
const identity = String(
|
||||
user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user"
|
||||
@ -214,7 +277,7 @@ app.post("/messages/:id/ack", requireWebAuth, async (req, res) => {
|
||||
return res.redirect("/");
|
||||
});
|
||||
|
||||
app.post("/opportunities/:id/status", requireWebAuth, async (req, res) => {
|
||||
app.post("/opportunities/:id/status", requireWebAuth, requireWebRole("editor"), async (req, res) => {
|
||||
const id = String(req.params.id);
|
||||
const status = String(req.body.status ?? "");
|
||||
await markOpportunity(id, status);
|
||||
@ -225,10 +288,11 @@ app.get("/trust", requireWebAuth, async (req, res) => {
|
||||
const entries = await listSourceTrustPolicy();
|
||||
const user = (req as any).user ?? {};
|
||||
const identity = String(user.name || user.preferred_username || user.email || "Signed-in user");
|
||||
return res.type("html").send(renderTrustPolicy({ entries, identity }));
|
||||
const role = webMediaRole(req);
|
||||
return res.type("html").send(renderTrustPolicy({ entries, identity, role }));
|
||||
});
|
||||
|
||||
app.post("/trust", requireWebAuth, async (req, res) => {
|
||||
app.post("/trust", requireWebAuth, requireWebRole("admin"), async (req, res) => {
|
||||
const user = (req as any).user ?? {};
|
||||
const identity = String(user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user");
|
||||
try {
|
||||
@ -253,7 +317,8 @@ app.get("/opportunities/:id", requireWebAuth, async (req, res) => {
|
||||
|
||||
const user = (req as any).user ?? {};
|
||||
const identity = String(user.name || user.preferred_username || user.email || "Signed-in user");
|
||||
return res.type("html").send(renderOpportunity({ opportunity, identity }));
|
||||
const role = webMediaRole(req);
|
||||
return res.type("html").send(renderOpportunity({ opportunity, identity, role }));
|
||||
});
|
||||
|
||||
app.get("/opportunities", requireWebAuth, async (req, res) => {
|
||||
@ -264,7 +329,8 @@ app.get("/opportunities", requireWebAuth, async (req, res) => {
|
||||
|
||||
const user = (req as any).user ?? {};
|
||||
const identity = String(user.name || user.preferred_username || user.email || "Signed-in user");
|
||||
return res.type("html").send(renderOpportunities({ opportunities, identity, status }));
|
||||
const role = webMediaRole(req);
|
||||
return res.type("html").send(renderOpportunities({ opportunities, identity, status, role }));
|
||||
});
|
||||
|
||||
app.get("/", requireWebAuth, async (req, res) => {
|
||||
@ -294,9 +360,11 @@ app.get("/", requireWebAuth, async (req, res) => {
|
||||
|
||||
const user = (req as any).user ?? {};
|
||||
const identity = String(user.name || user.preferred_username || user.email || "Signed-in user");
|
||||
const role = webMediaRole(req);
|
||||
|
||||
return res.type("html").send(renderDashboard({
|
||||
identity,
|
||||
role,
|
||||
counts: counts.rows[0] ?? {},
|
||||
messages,
|
||||
sources: sourceRows.rows,
|
||||
|
||||
47
src/mcp.ts
47
src/mcp.ts
@ -18,12 +18,14 @@ import {
|
||||
markOpportunity,
|
||||
postAgentMessage,
|
||||
searchEvidence,
|
||||
setStoryNodeReviewState,
|
||||
sourceStatus,
|
||||
evaluateSourceTrust,
|
||||
listSourceTrustPolicy
|
||||
} from "./db.js";
|
||||
import { syncAll } from "./sync.js";
|
||||
import type { SourceId } from "./types.js";
|
||||
import { mediaRoleAtLeast, type MediaRole } from "./auth.js";
|
||||
|
||||
const tools = [
|
||||
{
|
||||
@ -178,7 +180,7 @@ const tools = [
|
||||
body: { type: "string", maxLength: 8000 },
|
||||
severity: { type: "string", enum: ["info", "notice", "warning", "error"] },
|
||||
kind: { type: "string", enum: ["update", "finding", "request", "warning", "error"] },
|
||||
related_type: { type: "string", enum: ["opportunity", "source", "evidence", "general"] },
|
||||
related_type: { type: "string", enum: ["opportunity", "source", "evidence", "story", "general"] },
|
||||
related_id: { type: "string" }
|
||||
},
|
||||
required: ["title", "body"],
|
||||
@ -251,6 +253,20 @@ const tools = [
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "story_node_review",
|
||||
description: "Human/editorial review action: mark a Story Thread node active, resolved, or irrelevant. Irrelevant nodes stop contributing to the sourcing audit.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
thread_id: { type: "string" },
|
||||
node_id: { type: "string" },
|
||||
review_state: { type: "string", enum: ["active","resolved","irrelevant"] }
|
||||
},
|
||||
required: ["thread_id","node_id","review_state"],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "story_edge_add",
|
||||
description: "Connect two nodes in a story graph with an explicit evidentiary relationship.",
|
||||
@ -311,11 +327,32 @@ function actorFromRequest(req: Request) {
|
||||
return {
|
||||
sub: String(auth.sub ?? "unknown"),
|
||||
name: String(auth.preferred_username ?? auth.email ?? auth.name ?? auth.sub ?? "OAuth agent"),
|
||||
clientId: String(auth.azp ?? auth.client_id ?? process.env.KEYCLOAK_CLIENT_ID ?? "unknown")
|
||||
clientId: String(auth.azp ?? auth.client_id ?? process.env.KEYCLOAK_CLIENT_ID ?? "unknown"),
|
||||
role: String(auth.media_role ?? "viewer") as MediaRole
|
||||
};
|
||||
}
|
||||
|
||||
const EDITOR_TOOLS = new Set([
|
||||
"sources_sync",
|
||||
"opportunity_mark",
|
||||
"evidence_enrich",
|
||||
"message_post",
|
||||
"message_acknowledge",
|
||||
"story_thread_create",
|
||||
"story_node_add",
|
||||
"story_node_review",
|
||||
"story_edge_add",
|
||||
"story_counter_check_complete"
|
||||
]);
|
||||
|
||||
function requireToolRole(name: string, role: MediaRole) {
|
||||
if (EDITOR_TOOLS.has(name) && !mediaRoleAtLeast(role, "editor")) {
|
||||
throw new Error(`The ${name} action requires the Media Intelligence editor role. Current role: ${role}.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function callTool(name: string, args: any, actor: ReturnType<typeof actorFromRequest>) {
|
||||
requireToolRole(name, actor.role);
|
||||
if (name === "sources_list") return result(await listSources());
|
||||
if (name === "sources_status") return result(await sourceStatus());
|
||||
if (name === "source_trust_policy") return result(await listSourceTrustPolicy({ enabledOnly: true }));
|
||||
@ -369,6 +406,12 @@ async function callTool(name: string, args: any, actor: ReturnType<typeof actorF
|
||||
messageId: args.message_id,
|
||||
confidence: args.confidence
|
||||
}, actor));
|
||||
if (name === "story_node_review") return result(await setStoryNodeReviewState(
|
||||
args.thread_id,
|
||||
args.node_id,
|
||||
args.review_state,
|
||||
actor
|
||||
));
|
||||
if (name === "story_edge_add") return result(await addStoryEdge(args.thread_id, {
|
||||
fromNodeId: args.from_node_id,
|
||||
toNodeId: args.to_node_id,
|
||||
|
||||
125
src/web.ts
125
src/web.ts
@ -54,10 +54,17 @@ function evidenceTone(kind: unknown) {
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function canRole(role: string | undefined, required: "viewer" | "editor" | "admin") {
|
||||
const rank: Record<string, number> = { viewer: 1, editor: 2, admin: 3 };
|
||||
return (rank[String(role ?? "viewer")] ?? 1) >= rank[required];
|
||||
}
|
||||
|
||||
|
||||
function shell(args: {
|
||||
title: string;
|
||||
active: "dashboard" | "stories" | "opportunities" | "trust";
|
||||
identity: string;
|
||||
role?: string;
|
||||
body: string;
|
||||
}) {
|
||||
const nav = [
|
||||
@ -181,6 +188,17 @@ button,input,select{font:inherit}
|
||||
.empty{text-align:center;padding:30px;color:var(--muted)}
|
||||
.toast{position:fixed;right:18px;bottom:18px;background:#172033;color:#fff;border-radius:9px;padding:10px 13px;font-size:.84rem;box-shadow:var(--shadow);opacity:0;transform:translateY(8px);pointer-events:none;transition:.18s}.toast.show{opacity:1;transform:translateY(0)}
|
||||
.mobile-only{display:none}
|
||||
.research-checklist{display:grid;gap:8px}
|
||||
.research-check{display:flex;gap:10px;align-items:flex-start;padding:9px 0}
|
||||
.research-check+.research-check{border-top:1px solid #edf0f4}
|
||||
.research-check .mark{width:24px;height:24px;border-radius:50%;display:grid;place-items:center;flex:0 0 auto;background:#f1f3f6;color:#7a8494;font-weight:850;font-size:.78rem}
|
||||
.research-check.done .mark{background:#eaf7f1;color:#116445}
|
||||
.workbench{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
.node-review{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px}
|
||||
.review-irrelevant{opacity:.52}
|
||||
.review-resolved{border-style:dashed}
|
||||
.readonly-note{padding:11px 13px;background:#f7f9fb;border:1px solid #e4e9ef;border-radius:9px;color:#5d6879;font-size:.84rem}
|
||||
@media(max-width:850px){.workbench{grid-template-columns:1fr}}
|
||||
.trust-form{display:grid;grid-template-columns:1.2fr 1.2fr .6fr 1fr 1fr;gap:10px;align-items:end}
|
||||
.trust-form .wide{grid-column:1/-1}
|
||||
.field label{display:block;font-size:.76rem;color:var(--muted);font-weight:700;margin-bottom:5px}
|
||||
@ -198,7 +216,7 @@ button,input,select{font:inherit}
|
||||
<div class="topbar"><div class="topbar-inner">
|
||||
<a class="brand" href="/"><span class="brand-mark">MI</span><span>Media Intelligence</span></a>
|
||||
<nav class="nav">${nav.map(([key, href, label]) => `<a href="${href}" class="${args.active === key ? "active" : ""}">${label}</a>`).join("")}</nav>
|
||||
<div class="userbox"><div class="avatar">${esc(initials(args.identity))}</div><div><div class="user-name">${esc(args.identity)}</div><a class="signout" href="/auth/logout">Sign out</a></div></div>
|
||||
<div class="userbox"><div class="avatar">${esc(initials(args.identity))}</div><div><div class="user-name">${esc(args.identity)} · ${esc(args.role ?? "viewer")}</div><a class="signout" href="/auth/logout">Sign out</a></div></div>
|
||||
</div></div>
|
||||
<main class="page">${args.body}</main>
|
||||
<div id="toast" class="toast">Link copied</div>
|
||||
@ -234,7 +252,7 @@ function graphSvg(story: AnyRow) {
|
||||
const p = positions.get(node.id)!;
|
||||
const raw = String(node.title ?? "");
|
||||
const label = raw.length > 39 ? `${raw.slice(0,36)}…` : raw;
|
||||
return `<a href="#node-${encodeURIComponent(node.id)}"><g class="graph-node ${esc(node.node_type)}"><title>${esc(node.title)} — ${esc(node.node_type)}</title><rect x="${p.x-108}" y="${p.y-39}" width="216" height="78" rx="13"></rect><text class="node-type" x="${p.x}" y="${p.y-12}" text-anchor="middle">${esc(node.node_type)}</text><text class="node-title" x="${p.x}" y="${p.y+11}" text-anchor="middle">${esc(label)}</text></g></a>`;
|
||||
return `<a href="#node-${encodeURIComponent(node.id)}"><g class="graph-node ${esc(node.node_type)} review-${esc(node.review_state ?? "active")}"><title>${esc(node.title)} — ${esc(node.node_type)}</title><rect x="${p.x-108}" y="${p.y-39}" width="216" height="78" rx="13"></rect><text class="node-type" x="${p.x}" y="${p.y-12}" text-anchor="middle">${esc(node.node_type)}</text><text class="node-title" x="${p.x}" y="${p.y+11}" text-anchor="middle">${esc(label)}</text></g></a>`;
|
||||
}).join("");
|
||||
|
||||
return `<svg class="story-graph" viewBox="0 0 ${width} ${height}" role="img" aria-label="Research graph for ${esc(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-135}" y="${cy-49}" width="270" height="98" rx="17"></rect><text x="${cx}" y="${cy-10}" text-anchor="middle">STORY THREAD</text><text x="${cx}" y="${cy+18}" text-anchor="middle">${esc(String(story.title).slice(0,42))}</text></g>${edgeSvg}${nodeSvg}</svg>`;
|
||||
@ -273,14 +291,15 @@ function sourceAuditPanel(story: AnyRow) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function evidenceCard(node: AnyRow) {
|
||||
function evidenceCard(node: AnyRow, role = "viewer", storyId?: string) {
|
||||
const kind = node.evidence_kind ?? "discovery_metadata";
|
||||
const publisher = node.provenance_publisher ?? node.provenance_source_id ?? "Unknown publisher";
|
||||
const quality = kind === "source_document" ? "Source document" : kind === "source_payload" ? "Official source payload" : "Discovery metadata";
|
||||
const excerpt = String(node.evidence_excerpt ?? node.summary ?? "").trim();
|
||||
const sourceDate = node.snapshot_published_at ?? node.snapshot_retrieved_at ?? node.created_at;
|
||||
return `<article class="panel evidence-card" id="node-${esc(node.id)}">
|
||||
<div class="evidence-head"><div>${badge(quality,evidenceTone(kind))}${node.provenance_authority_tier ? badge(`Tier ${node.provenance_authority_tier}`,"neutral") : ""}</div><div class="meta">${esc(nz(sourceDate))}</div></div>
|
||||
const state = String(node.review_state ?? "active");
|
||||
return `<article class="panel evidence-card review-${esc(state)}" id="node-${esc(node.id)}">
|
||||
<div class="evidence-head"><div>${badge(quality,evidenceTone(kind))}${node.provenance_authority_tier ? badge(`Tier ${node.provenance_authority_tier}`,"neutral") : ""}${state!=="active"?badge(state,state==="irrelevant"?"neutral":"green"):""}</div><div class="meta">${esc(nz(sourceDate))}</div></div>
|
||||
<h3>${esc(node.title)}</h3><p>${esc(node.summary)}</p>
|
||||
${excerpt ? `<div class="evidence-excerpt">${esc(excerpt.slice(0,1500))}${excerpt.length > 1500 ? "…" : ""}</div>` : ""}
|
||||
<dl class="provenance">
|
||||
@ -289,12 +308,17 @@ function evidenceCard(node: AnyRow) {
|
||||
<dt>Snapshot</dt><dd>${esc(node.snapshot_kind ?? "—")}${node.snapshot_id ? ` · <span class="mono">${esc(String(node.snapshot_id).slice(0,12))}</span>` : ""}</dd>
|
||||
<dt>Captured</dt><dd>${esc(nz(node.snapshot_retrieved_at))}</dd>
|
||||
</dl>
|
||||
${node.evidence_url ? `<div style="margin-top:12px"><a class="btn btn-sm" href="${esc(node.evidence_url)}" target="_blank" rel="noreferrer">Open original ↗</a></div>` : ""}
|
||||
<div style="margin-top:12px" class="actions">
|
||||
${node.evidence_url ? `<a class="btn btn-sm" href="${esc(node.evidence_url)}" target="_blank" rel="noreferrer">Open original ↗</a>` : ""}
|
||||
${storyId && canRole(role,"editor") ? `<form method="post" action="/stories/${encodeURIComponent(storyId)}/nodes/${encodeURIComponent(node.id)}/review"><input type="hidden" name="review_state" value="${state==="irrelevant"?"active":"irrelevant"}"><button class="btn btn-sm" type="submit">${state==="irrelevant"?"Restore evidence":"Mark irrelevant"}</button></form>` : ""}
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
export function renderStory(args: {story: AnyRow; identity: string}) {
|
||||
|
||||
export function renderStory(args: {story: AnyRow; identity: string; role?: string}) {
|
||||
const {story, identity} = args;
|
||||
const role = args.role ?? "viewer";
|
||||
const nodes = Array.isArray(story.nodes) ? story.nodes : [];
|
||||
const edges = Array.isArray(story.edges) ? story.edges : [];
|
||||
const evidenceNodes = nodes.filter((n: AnyRow) => n.evidence_id);
|
||||
@ -302,10 +326,13 @@ export function renderStory(args: {story: AnyRow; identity: string}) {
|
||||
const researchNodes = nodes.filter((n: AnyRow) => ["question","context","organisation","person","place"].includes(n.node_type));
|
||||
const byId = new Map(nodes.map((n: AnyRow) => [n.id,n]));
|
||||
const counter = (story.research_checks ?? []).find((c:AnyRow)=>c.check_type==="counter_evidence");
|
||||
const checklist = story.research_checklist ?? {items:[],completed:0,total:0,unresolved_questions:[]};
|
||||
const requests = Array.isArray(story.research_requests) ? story.research_requests : [];
|
||||
const editable = canRole(role,"editor");
|
||||
|
||||
const body = `
|
||||
<div class="thread-hero"><div><div class="eyebrow">360° Story Thread</div>
|
||||
<div class="row-meta">${badge(story.status,statusTone(story.status))}${story.source_audit?.robust ? badge("sourcing ready","green") : badge("sourcing gaps","amber")}${counter ? badge(`counter-evidence: ${counter.result}`,"blue") : badge("counter-evidence pending","amber")}</div>
|
||||
<div class="row-meta">${badge(story.status,statusTone(story.status))}${story.source_audit?.robust ? badge("sourcing ready","green") : badge("sourcing gaps","amber")}${counter ? badge(`counter-evidence: ${counter.result}`,"blue") : badge("counter-evidence pending","amber")}${badge(role,"neutral")}</div>
|
||||
<h1 class="thread-title">${esc(story.title)}</h1><div class="thread-question">${esc(story.question)}</div>
|
||||
<div class="meta" style="margin-top:11px">Created by ${esc(story.created_by_name)} · updated ${esc(nz(story.updated_at))}</div></div>
|
||||
<div class="actions"><button class="btn" type="button" onclick="copyCurrentLink()">Copy link</button><a class="btn" href="/stories">All threads</a></div></div>
|
||||
@ -313,12 +340,37 @@ export function renderStory(args: {story: AnyRow; identity: string}) {
|
||||
<div class="section">${sourceAuditPanel(story)}</div>
|
||||
|
||||
<div class="section grid grid-2">
|
||||
<div class="panel"><div class="section-head"><div><h2>Editorial state</h2><p>Human-controlled progression. Research gates apply before review/develop.</p></div></div>
|
||||
<div class="status-actions">${["researching","review","develop","closed"].map((s) => `<form method="post" action="/stories/${encodeURIComponent(story.id)}/status"><input type="hidden" name="status" value="${s}"><button class="btn btn-sm ${story.status===s ? "btn-primary" : ""}" type="submit">${esc(s)}</button></form>`).join("")}</div></div>
|
||||
<div class="panel ${counter ? "callout success" : "callout warning"}"><div class="section-head"><div><h2>Counter-evidence pass</h2><p>${counter ? `${esc(counter.result)} · ${esc((counter.sources_checked ?? []).join(", "))}` : "Required before editorial progression"}</p></div></div>
|
||||
<div style="font-size:.87rem;color:#536075">${counter ? esc(counter.summary) : "No completed counter-evidence check is stored for this thread."}</div></div>
|
||||
<div class="panel"><div class="section-head"><div><h2>Research checklist</h2><p>${compact(checklist.completed)} of ${compact(checklist.total)} checks complete.</p></div></div>
|
||||
<div class="research-checklist">${(checklist.items??[]).map((item:AnyRow)=>`<div class="research-check ${item.done?"done":""}"><div class="mark">${item.done?"✓":"○"}</div><div><strong>${esc(item.label)}</strong></div></div>`).join("")}</div>
|
||||
${(checklist.unresolved_questions??[]).length ? `<div class="meta" style="margin-top:10px">${checklist.unresolved_questions.length} unresolved research question${checklist.unresolved_questions.length===1?"":"s"}.</div>` : ""}
|
||||
</div>
|
||||
<div class="panel"><div class="section-head"><div><h2>Editorial state</h2><p>Research gates apply before review/develop.</p></div></div>
|
||||
${editable ? `<div class="status-actions">${["researching","review","develop","closed"].map((st) => `<form method="post" action="/stories/${encodeURIComponent(story.id)}/status"><input type="hidden" name="status" value="${st}"><button class="btn btn-sm ${story.status===st ? "btn-primary" : ""}" type="submit">${esc(st)}</button></form>`).join("")}</div>` : `<div class="readonly-note">Viewer access: editorial status changes are hidden.</div>`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section panel ${counter ? "callout success" : "callout warning"}"><div class="section-head"><div><h2>Counter-evidence pass</h2><p>${counter ? `${esc(counter.result)} · ${esc((counter.sources_checked ?? []).join(", "))}` : "Required before editorial progression"}</p></div></div>
|
||||
<div style="font-size:.87rem;color:#536075">${counter ? esc(counter.summary) : "No completed counter-evidence check is stored for this thread."}</div></div>
|
||||
|
||||
${editable ? `<section class="section"><div class="section-head"><div><h2>Research workbench</h2><p>Add human research context or send a focused request to MCP agents.</p></div></div>
|
||||
<div class="workbench">
|
||||
<form class="panel" method="post" action="/stories/${encodeURIComponent(story.id)}/nodes">
|
||||
<div class="section-head"><div><h2>Add research item</h2><p>Claims, questions and editorial context become graph nodes.</p></div></div>
|
||||
<div class="field"><label>Type</label><select name="node_type"><option value="question">question</option><option value="context">editorial note / context</option><option value="claim">claim</option><option value="counterclaim">counterclaim</option><option value="trend">trend</option><option value="forecast">forecast</option></select></div>
|
||||
<div class="field" style="margin-top:9px"><label>Title</label><input name="title" required maxlength="240"></div>
|
||||
<div class="field" style="margin-top:9px"><label>Detail</label><textarea name="summary" maxlength="8000"></textarea></div>
|
||||
<div class="actions" style="margin-top:10px"><button class="btn btn-primary" type="submit">Add to thread</button></div>
|
||||
</form>
|
||||
<form class="panel" method="post" action="/stories/${encodeURIComponent(story.id)}/request-research">
|
||||
<div class="section-head"><div><h2>Request more research</h2><p>Posts a structured request into Agent Activity for MCP agents.</p></div></div>
|
||||
<div class="field"><label>Request</label><input name="title" placeholder="e.g. Find independent insolvency trend data" required maxlength="150"></div>
|
||||
<div class="field" style="margin-top:9px"><label>What should be checked?</label><textarea name="body" required maxlength="8000"></textarea></div>
|
||||
<div class="actions" style="margin-top:10px"><button class="btn btn-primary" type="submit">Send research request</button></div>
|
||||
</form>
|
||||
</div></section>` : ""}
|
||||
|
||||
${requests.length ? `<section class="section"><div class="section-head"><div><h2>Research requests</h2><p>Requests attached to this thread.</p></div></div><div class="panel">${requests.map((r:AnyRow)=>`<div class="message-row"><div class="list-row"><div><div class="row-title">${esc(r.title)}</div><div class="row-sub">${esc(r.body)}</div><div class="row-meta">${badge(r.kind,"blue")}<span class="meta">${esc(r.actor_name)}</span></div></div><div class="right-meta meta">${esc(nz(r.created_at))}</div></div></div>`).join("")}</div></section>` : ""}
|
||||
|
||||
<section class="section"><div class="section-head"><div><h2>Evidence network</h2><p>${compact(nodes.length)} nodes · ${compact(edges.length)} relationships</p></div></div>
|
||||
<div class="graph-shell"><div class="graph-toolbar"><div class="legend"><span class="evidence">evidence</span><span class="claim">claim</span><span class="counter">counterclaim</span><span class="question">question</span><span class="forecast">forecast / trend</span></div><div class="meta">Tap a node to jump to its card</div></div>
|
||||
<div class="graph-scroll">${graphSvg(story)}</div><div class="mobile-only" style="padding:12px 14px;color:#687387;font-size:.84rem">Graph view is condensed on mobile. Use the evidence and assertion cards below for the full research trail.</div></div></section>
|
||||
@ -327,13 +379,21 @@ export function renderStory(args: {story: AnyRow; identity: string}) {
|
||||
<div class="assertion-grid">${assertions.length ? assertions.map((node: AnyRow) => {
|
||||
const state = assertionState(node, story);
|
||||
const incoming = edges.filter((e:AnyRow)=>e.to_node_id===node.id);
|
||||
return `<article class="panel assertion ${state}" id="node-${esc(node.id)}"><div class="row-meta">${badge(node.node_type,"neutral")}${badge(state,state==="supported"?"green":state==="contradicted"?"red":state==="qualified"?"amber":"neutral")}</div><h3>${esc(node.title)}</h3><p>${esc(node.summary)}</p><div class="meta">${incoming.length ? `${incoming.length} incoming relationship${incoming.length===1?"":"s"}` : "No evidence relationship yet"}${node.confidence != null ? ` · confidence ${esc(node.confidence)}` : ""}</div></article>`;
|
||||
const reviewState=String(node.review_state??"active");
|
||||
return `<article class="panel assertion ${state} review-${esc(reviewState)}" id="node-${esc(node.id)}"><div class="row-meta">${badge(node.node_type,"neutral")}${badge(state,state==="supported"?"green":state==="contradicted"?"red":state==="qualified"?"amber":"neutral")}${reviewState!=="active"?badge(reviewState,"neutral"):""}</div><h3>${esc(node.title)}</h3><p>${esc(node.summary)}</p><div class="meta">${incoming.length ? `${incoming.length} incoming relationship${incoming.length===1?"":"s"}` : "No evidence relationship yet"}${node.confidence != null ? ` · confidence ${esc(node.confidence)}` : ""}</div>
|
||||
${editable ? `<div class="node-review"><form method="post" action="/stories/${encodeURIComponent(story.id)}/nodes/${encodeURIComponent(node.id)}/review"><input type="hidden" name="review_state" value="${reviewState==="irrelevant"?"active":"irrelevant"}"><button class="btn btn-sm" type="submit">${reviewState==="irrelevant"?"Restore":"Mark irrelevant"}</button></form></div>` : ""}
|
||||
</article>`;
|
||||
}).join("") : `<div class="panel empty">No assertions in this thread yet.</div>`}</div></section>
|
||||
|
||||
<section class="section"><div class="section-head"><div><h2>Evidence & provenance</h2><p>Exact publisher, evidence class, snapshot and original source.</p></div></div>
|
||||
<div class="evidence-grid">${evidenceNodes.length ? evidenceNodes.map(evidenceCard).join("") : `<div class="panel empty">No evidence nodes yet.</div>`}</div></section>
|
||||
<div class="evidence-grid">${evidenceNodes.length ? evidenceNodes.map((node:AnyRow)=>evidenceCard(node,role,story.id)).join("") : `<div class="panel empty">No evidence nodes yet.</div>`}</div></section>
|
||||
|
||||
${researchNodes.length ? `<section class="section"><div class="section-head"><div><h2>Research context</h2><p>Questions, context and entities that shape the investigation.</p></div></div><div class="grid grid-cards">${researchNodes.map((n:AnyRow)=>`<article class="panel" id="node-${esc(n.id)}"><div class="row-meta">${badge(n.node_type,"neutral")}</div><h3 style="margin:8px 0 5px;font-size:.98rem">${esc(n.title)}</h3><div class="muted" style="font-size:.87rem">${esc(n.summary)}</div><div class="meta" style="margin-top:10px">Added by ${esc(n.created_by_name)}</div></article>`).join("")}</div></section>` : ""}
|
||||
${researchNodes.length ? `<section class="section"><div class="section-head"><div><h2>Research context</h2><p>Questions, context and entities that shape the investigation.</p></div></div><div class="grid grid-cards">${researchNodes.map((n:AnyRow)=>{
|
||||
const reviewState=String(n.review_state??"active");
|
||||
return `<article class="panel review-${esc(reviewState)}" id="node-${esc(n.id)}"><div class="row-meta">${badge(n.node_type,"neutral")}${reviewState!=="active"?badge(reviewState,reviewState==="resolved"?"green":"neutral"):""}</div><h3 style="margin:8px 0 5px;font-size:.98rem">${esc(n.title)}</h3><div class="muted" style="font-size:.87rem">${esc(n.summary)}</div><div class="meta" style="margin-top:10px">Added by ${esc(n.created_by_name)}</div>
|
||||
${editable ? `<div class="node-review">${n.node_type==="question" ? `<form method="post" action="/stories/${encodeURIComponent(story.id)}/nodes/${encodeURIComponent(n.id)}/review"><input type="hidden" name="review_state" value="${reviewState==="resolved"?"active":"resolved"}"><button class="btn btn-sm" type="submit">${reviewState==="resolved"?"Reopen":"Resolve question"}</button></form>` : ""}<form method="post" action="/stories/${encodeURIComponent(story.id)}/nodes/${encodeURIComponent(n.id)}/review"><input type="hidden" name="review_state" value="${reviewState==="irrelevant"?"active":"irrelevant"}"><button class="btn btn-sm" type="submit">${reviewState==="irrelevant"?"Restore":"Mark irrelevant"}</button></form></div>` : ""}
|
||||
</article>`;
|
||||
}).join("")}</div></section>` : ""}
|
||||
|
||||
<section class="section"><div class="section-head"><div><h2>Relationships</h2><p>Human-readable graph edges with rationale.</p></div></div>
|
||||
<div class="panel">${edges.length ? edges.map((edge:AnyRow)=>{
|
||||
@ -341,37 +401,38 @@ export function renderStory(args: {story: AnyRow; identity: string}) {
|
||||
return `<div class="relationship"><div><div class="meta">FROM</div><div class="relationship-title">${esc(from?.title ?? edge.from_node_id)}</div></div><div class="relationship-mid">${badge(edge.relationship,"neutral")}</div><div><div class="meta">TO</div><div class="relationship-title">${esc(to?.title ?? edge.to_node_id)}</div><div class="meta" style="margin-top:4px">${esc(edge.reason)}</div></div></div>`;
|
||||
}).join("") : `<div class="empty">No relationships stored.</div>`}</div></section>`;
|
||||
|
||||
return shell({title:story.title,active:"stories",identity,body});
|
||||
return shell({title:story.title,active:"stories",identity,role,body});
|
||||
}
|
||||
|
||||
export function renderStories(args: {stories: AnyRow[]; identity: string}) {
|
||||
|
||||
export function renderStories(args: {stories: AnyRow[]; identity: string; role?: string}) {
|
||||
const body = `<div class="page-head"><div><div class="eyebrow">Research workspace</div><h1>Story Threads</h1><p>Evidence networks built around a reporting question, with explicit counter-evidence and sourcing gates before editorial review.</p></div></div>
|
||||
<div class="panel">${args.stories.length ? args.stories.map((s:AnyRow)=>{
|
||||
const audit=s.source_audit ?? {};
|
||||
return `<a class="story-row" href="/stories/${encodeURIComponent(s.id)}"><div class="list-row"><div><div class="row-title">${esc(s.title)}</div><div class="row-sub">${esc(s.question)}</div><div class="row-meta">${badge(s.status,statusTone(s.status))}${audit.robust ? badge("sourcing ready","green") : badge("sourcing gaps","amber")}${s.counter_evidence_result ? badge(`counter: ${s.counter_evidence_result}`,"blue") : badge("counter pending","neutral")}</div></div><div class="right-meta meta">${compact(s.nodes?.length ?? s.node_count)} nodes<br>${compact(s.edges?.length ?? s.edge_count)} edges<br>updated ${esc(nz(s.updated_at))}</div></div></a>`;
|
||||
}).join("") : `<div class="empty">No Story Threads yet.</div>`}</div>`;
|
||||
return shell({title:"Story Threads",active:"stories",identity:args.identity,body});
|
||||
return shell({title:"Story Threads",active:"stories",identity:args.identity,role:args.role,body});
|
||||
}
|
||||
|
||||
export function renderOpportunity(args:{opportunity:AnyRow;identity:string}) {
|
||||
export function renderOpportunity(args:{opportunity:AnyRow;identity:string;role?:string}) {
|
||||
const o=args.opportunity, evidence=Array.isArray(o.evidence)?o.evidence:[], notes=Array.isArray(o.notes)?o.notes:[];
|
||||
const body=`<div class="page-head"><div><div class="eyebrow">Opportunity</div><div class="row-meta">${badge(o.status,statusTone(o.status))}${badge(`score ${o.score}`,"neutral")}</div><h1 style="margin-top:9px">${esc(o.title)}</h1><p>${esc(o.summary)}</p><div class="meta">${esc(o.source_id)} · ${esc(nz(o.published_at || o.detected_at))}</div></div><div class="actions"><button class="btn" type="button" onclick="copyCurrentLink()">Copy link</button><a class="btn" href="${esc(o.url)}" target="_blank" rel="noreferrer">Original source ↗</a></div></div>
|
||||
<div class="grid grid-2"><div class="panel"><div class="section-head"><div><h2>Triage</h2><p>${esc((o.reasons??[]).join(" · "))}</p></div></div><div class="status-actions">${["watch","investigate","develop","ignore","closed"].map((s)=>`<form method="post" action="/opportunities/${encodeURIComponent(o.id)}/status"><input type="hidden" name="status" value="${s}"><input type="hidden" name="returnTo" value="detail"><button class="btn btn-sm ${o.status===s?"btn-primary":""}" type="submit">${esc(s)}</button></form>`).join("")}</div></div>
|
||||
<div class="grid grid-2"><div class="panel"><div class="section-head"><div><h2>Triage</h2><p>${esc((o.reasons??[]).join(" · "))}</p></div></div>${canRole(args.role,"editor")?`<div class="status-actions">${["watch","investigate","develop","ignore","closed"].map((st)=>`<form method="post" action="/opportunities/${encodeURIComponent(o.id)}/status"><input type="hidden" name="status" value="${st}"><input type="hidden" name="returnTo" value="detail"><button class="btn btn-sm ${o.status===st?"btn-primary":""}" type="submit">${esc(st)}</button></form>`).join("")}</div>`:`<div class="readonly-note">Viewer access: triage controls are hidden.</div>`}</div>
|
||||
<div class="panel"><div class="section-head"><div><h2>Why it surfaced</h2></div></div><div class="muted" style="font-size:.88rem">${esc((o.reasons??[]).join(" · ")||"No scoring reasons stored.")}</div></div></div>
|
||||
<section class="section"><div class="section-head"><div><h2>Stored evidence</h2><p>${evidence.length} record${evidence.length===1?"":"s"}</p></div></div><div class="evidence-grid">${evidence.length?evidence.map((e:AnyRow)=>`<article class="panel evidence-card"><div class="evidence-head"><div>${badge(e.evidence_kind??"stored evidence",evidenceTone(e.evidence_kind))}</div><div class="meta">${esc(nz(e.retrieved_at))}</div></div><div class="evidence-excerpt" style="margin-top:10px">${esc(e.excerpt)}</div><div style="margin-top:10px"><a class="btn btn-sm" href="${esc(e.source_url)}" target="_blank" rel="noreferrer">Open source ↗</a></div></article>`).join(""):`<div class="panel empty">No evidence stored.</div>`}</div></section>
|
||||
<section class="section"><div class="section-head"><div><h2>Notes</h2></div></div>${notes.length?notes.map((n:AnyRow)=>`<div class="panel"><div class="meta">${esc(nz(n.created_at))}</div><div style="margin-top:5px">${esc(n.note)}</div></div>`).join(""):`<div class="panel empty">No notes yet.</div>`}</section>`;
|
||||
return shell({title:o.title,active:"opportunities",identity:args.identity,body});
|
||||
return shell({title:o.title,active:"opportunities",identity:args.identity,role:args.role,body});
|
||||
}
|
||||
|
||||
export function renderOpportunities(args:{opportunities:AnyRow[];identity:string;status?:string}) {
|
||||
export function renderOpportunities(args:{opportunities:AnyRow[];identity:string;status?:string;role?:string}) {
|
||||
const states=["new","watch","investigate","develop","ignore","closed"];
|
||||
const body=`<div class="page-head"><div><div class="eyebrow">Signal triage</div><h1>Opportunities</h1><p>Potential stories surfaced from monitored sources. Humans decide what to watch, investigate or develop.</p></div></div>
|
||||
<div class="filterbar"><a class="${!args.status?"active":""}" href="/opportunities">Active</a>${states.map((s)=>`<a class="${args.status===s?"active":""}" href="/opportunities?status=${s}">${esc(s)}</a>`).join("")}</div>
|
||||
<div class="panel">${args.opportunities.length?args.opportunities.map((o:AnyRow)=>`<a class="opportunity-row" href="/opportunities/${encodeURIComponent(o.id)}"><div class="list-row"><div><div class="row-title">${esc(o.title)}</div><div class="row-sub">${esc(o.summary)}</div><div class="row-meta">${badge(o.status,statusTone(o.status))}${badge(o.source_id,"neutral")}<span class="meta">${esc((o.reasons??[]).join(" · "))}</span></div></div><div class="right-meta meta">score ${esc(o.score)}<br>${esc(nz(o.published_at||o.detected_at))}</div></div></a>`).join(""):`<div class="empty">No opportunities in this view.</div>`}</div>`;
|
||||
return shell({title:"Opportunities",active:"opportunities",identity:args.identity,body});
|
||||
return shell({title:"Opportunities",active:"opportunities",identity:args.identity,role:args.role,body});
|
||||
}
|
||||
|
||||
export function renderDashboard(args:{identity:string;counts:AnyRow;messages:AnyRow[];sources:AnyRow[];stories:AnyRow[]}) {
|
||||
export function renderDashboard(args:{identity:string;role?:string;counts:AnyRow;messages:AnyRow[];sources:AnyRow[];stories:AnyRow[]}) {
|
||||
const c=args.counts??{}, sourceErrors=args.sources.filter((s)=>s.last_error), storyAttention=args.stories.filter((s)=>!s.source_audit?.robust && s.status!=="closed");
|
||||
const body=`<div class="page-head"><div><div class="eyebrow">Newsroom desk</div><h1>Media Intelligence</h1><p>Public-source monitoring, evidence research and human editorial control in one shared workspace.</p></div><div class="actions"><a class="btn btn-primary" href="/stories">Open research desk</a></div></div>
|
||||
<div class="grid grid-metrics">
|
||||
@ -391,12 +452,12 @@ export function renderDashboard(args:{identity:string;counts:AnyRow;messages:Any
|
||||
</div>
|
||||
</div>
|
||||
<section class="section"><div class="section-head"><div><h2>Agent Activity</h2><p>Operational findings and source-monitor updates.</p></div></div>
|
||||
<div class="panel">${args.messages.length?args.messages.map((m:AnyRow)=>`<div class="message-row"><div class="list-row"><div><div class="row-title">${esc(m.title)}</div><div class="row-sub">${esc(m.body)}</div><div class="row-meta">${badge(m.severity,m.severity==="warning"?"amber":m.severity==="error"?"red":"blue")}<span class="meta">${esc(m.actor_name)} via <span class="mono">${esc(m.actor_client_id)}</span></span></div><form method="post" action="/messages/${encodeURIComponent(m.id)}/ack" style="margin-top:9px"><button class="btn btn-sm" type="submit">Acknowledge</button></form></div><div class="right-meta meta">${esc(nz(m.created_at))}</div></div></div>`).join(""):`<div class="empty">No unread Agent Activity.</div>`}</div></section>`;
|
||||
return shell({title:"Desk",active:"dashboard",identity:args.identity,body});
|
||||
<div class="panel">${args.messages.length?args.messages.map((m:AnyRow)=>`<div class="message-row"><div class="list-row"><div><div class="row-title">${esc(m.title)}</div><div class="row-sub">${esc(m.body)}</div><div class="row-meta">${badge(m.severity,m.severity==="warning"?"amber":m.severity==="error"?"red":"blue")}<span class="meta">${esc(m.actor_name)} via <span class="mono">${esc(m.actor_client_id)}</span></span></div>${canRole(args.role,"editor")?`<form method="post" action="/messages/${encodeURIComponent(m.id)}/ack" style="margin-top:9px"><button class="btn btn-sm" type="submit">Acknowledge</button></form>`:""}</div><div class="right-meta meta">${esc(nz(m.created_at))}</div></div></div>`).join(""):`<div class="empty">No unread Agent Activity.</div>`}</div></section>`;
|
||||
return shell({title:"Desk",active:"dashboard",identity:args.identity,role:args.role,body});
|
||||
}
|
||||
|
||||
|
||||
export function renderTrustPolicy(args:{identity:string;entries:AnyRow[]}) {
|
||||
export function renderTrustPolicy(args:{identity:string;entries:AnyRow[];role?:string}) {
|
||||
const body=`
|
||||
<div class="page-head">
|
||||
<div><div class="eyebrow">Editorial policy</div><h1>Source Trust</h1>
|
||||
@ -404,7 +465,7 @@ export function renderTrustPolicy(args:{identity:string;entries:AnyRow[]}) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
${canRole(args.role,"admin") ? ` <div class="panel">
|
||||
<div class="section-head"><div><h2>Add or update a source</h2><p>Submitting an existing domain updates its policy entry.</p></div></div>
|
||||
<form class="trust-form" method="post" action="/trust">
|
||||
<div class="field"><label>Domain</label><input id="trust-domain" name="domain" placeholder="example.org" required></div>
|
||||
@ -427,7 +488,7 @@ export function renderTrustPolicy(args:{identity:string;entries:AnyRow[]}) {
|
||||
<div class="field"><label>Enabled</label><select id="trust-enabled" name="enabled"><option value="true">enabled</option><option value="false">disabled</option></select></div>
|
||||
<div class="actions"><button class="btn btn-primary" type="submit">Save source</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>` : `<div class="panel readonly-note"><strong>Read-only policy view.</strong> Source Trust changes require the admin role.</div>`}
|
||||
|
||||
<section class="section">
|
||||
<div class="section-head"><div><h2>Current policy</h2><p>${args.entries.length} configured domains. Agent scoring deduplicates by independence group.</p></div></div>
|
||||
@ -437,10 +498,10 @@ export function renderTrustPolicy(args:{identity:string;entries:AnyRow[]}) {
|
||||
<div class="optional-col"><div class="meta">Independence group</div><div>${esc(e.independence_group)}</div></div>
|
||||
<div><div class="weight">${Number(e.trust_weight).toFixed(2)}</div><div class="weightbar"><span style="width:${Math.round(Number(e.trust_weight)*100)}%"></span></div></div>
|
||||
<div class="optional-col meta">${esc(e.notes||"—")}</div>
|
||||
<button class="btn btn-sm" type="button" data-edit-trust
|
||||
${canRole(args.role,"admin")?`<button class="btn btn-sm" type="button" data-edit-trust
|
||||
data-domain="${esc(e.domain)}" data-name="${esc(e.name)}" data-weight="${esc(e.trust_weight)}"
|
||||
data-group="${esc(e.independence_group)}" data-class="${esc(e.source_class)}"
|
||||
data-notes="${esc(e.notes)}" data-enabled="${e.enabled?"true":"false"}">Edit</button>
|
||||
data-notes="${esc(e.notes)}" data-enabled="${e.enabled?"true":"false"}">Edit</button>`:""}
|
||||
</div>`).join("")}
|
||||
</div>
|
||||
</section>
|
||||
@ -459,5 +520,5 @@ export function renderTrustPolicy(args:{identity:string;entries:AnyRow[]}) {
|
||||
});
|
||||
});
|
||||
</script>`;
|
||||
return shell({title:"Source Trust",active:"trust",identity:args.identity,body});
|
||||
return shell({title:"Source Trust",active:"trust",identity:args.identity,role:args.role,body});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user