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 : "/";
|
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) {
|
function webConfig(req: Request) {
|
||||||
const issuer = process.env.WEB_OIDC_ISSUER || process.env.KEYCLOAK_ISSUER;
|
const issuer = process.env.WEB_OIDC_ISSUER || process.env.KEYCLOAK_ISSUER;
|
||||||
const clientId = process.env.WEB_OIDC_CLIENT_ID;
|
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)) {
|
if (payload.azp !== clientId && !aud.includes(clientId)) {
|
||||||
return res.status(403).json({ error: "OAuth token was not issued for this MCP client" });
|
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();
|
return next();
|
||||||
} catch {
|
} catch {
|
||||||
return res.status(401).json({ error: "Invalid OAuth access token" });
|
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.");
|
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({
|
const session = await createSession({
|
||||||
sub: payload.sub,
|
sub: payload.sub,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
preferred_username: payload.preferred_username,
|
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));
|
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,
|
opportunity_id TEXT REFERENCES opportunities(id) ON DELETE SET NULL,
|
||||||
message_id TEXT,
|
message_id TEXT,
|
||||||
confidence NUMERIC(4,3),
|
confidence NUMERIC(4,3),
|
||||||
|
review_state TEXT NOT NULL DEFAULT 'active',
|
||||||
created_by_sub TEXT NOT NULL,
|
created_by_sub TEXT NOT NULL,
|
||||||
created_by_name TEXT NOT NULL,
|
created_by_name TEXT NOT NULL,
|
||||||
created_by_client_id 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 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 document_text TEXT");
|
||||||
await pool.query("ALTER TABLE source_snapshots ADD COLUMN IF NOT EXISTS http_status INTEGER");
|
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(
|
const payloadMarker = await pool.query(
|
||||||
`INSERT INTO app_meta(key,value)
|
`INSERT INTO app_meta(key,value)
|
||||||
@ -1002,8 +1004,11 @@ export async function listStoryThreads(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildStorySourceAudit(story: any) {
|
function buildStorySourceAudit(story: any) {
|
||||||
const nodes = Array.isArray(story.nodes) ? story.nodes : [];
|
const allNodes = Array.isArray(story.nodes) ? story.nodes : [];
|
||||||
const edges = Array.isArray(story.edges) ? story.edges : [];
|
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) => {
|
const evidenceBacked = nodes.filter((n: any) => {
|
||||||
if (!n.evidence_id || !n.snapshot_id || !n.provenance_source_id) return false;
|
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) {
|
export async function getStoryThread(id: string) {
|
||||||
const threadResult = await pool.query(
|
const threadResult = await pool.query(
|
||||||
`SELECT t.*,
|
`SELECT t.*,
|
||||||
@ -1124,7 +1180,7 @@ export async function getStoryThread(id: string) {
|
|||||||
);
|
);
|
||||||
if (!threadResult.rowCount) return null;
|
if (!threadResult.rowCount) return null;
|
||||||
|
|
||||||
const [nodes, edges, checks] = await Promise.all([
|
const [nodes, edges, checks, requests] = await Promise.all([
|
||||||
pool.query(
|
pool.query(
|
||||||
`SELECT n.*,
|
`SELECT n.*,
|
||||||
COALESCE(n.source_id,s.source_id) AS provenance_source_id,
|
COALESCE(n.source_id,s.source_id) AS provenance_source_id,
|
||||||
@ -1162,6 +1218,10 @@ export async function getStoryThread(id: string) {
|
|||||||
pool.query(
|
pool.query(
|
||||||
"SELECT * FROM story_research_checks WHERE thread_id=$1 ORDER BY completed_at DESC",
|
"SELECT * FROM story_research_checks WHERE thread_id=$1 ORDER BY completed_at DESC",
|
||||||
[id]
|
[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],
|
...threadResult.rows[0],
|
||||||
nodes: nodes.rows,
|
nodes: nodes.rows,
|
||||||
edges: edges.rows,
|
edges: edges.rows,
|
||||||
research_checks: checks.rows
|
research_checks: checks.rows,
|
||||||
|
research_requests: requests.rows
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sourceAudit = buildStorySourceAudit(story);
|
||||||
return {
|
return {
|
||||||
...story,
|
...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];
|
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(
|
export async function addStoryEdge(
|
||||||
threadId: string,
|
threadId: string,
|
||||||
args: {
|
args: {
|
||||||
@ -1673,7 +1763,7 @@ export async function postAgentMessage(
|
|||||||
const kind = String(args.kind ?? "update");
|
const kind = String(args.kind ?? "update");
|
||||||
const allowedSeverities = new Set(["info", "notice", "warning", "error"]);
|
const allowedSeverities = new Set(["info", "notice", "warning", "error"]);
|
||||||
const allowedKinds = new Set(["update", "finding", "request", "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 (!allowedSeverities.has(severity)) throw new Error("Invalid message severity");
|
||||||
if (!allowedKinds.has(kind)) throw new Error("Invalid message kind");
|
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 express from "express";
|
||||||
import {
|
import {
|
||||||
acknowledgeAgentMessage,
|
acknowledgeAgentMessage,
|
||||||
|
addStoryNode,
|
||||||
enrichStoryEvidenceDocuments,
|
enrichStoryEvidenceDocuments,
|
||||||
getOpportunity,
|
getOpportunity,
|
||||||
getStoryThread,
|
getStoryThread,
|
||||||
@ -9,6 +10,8 @@ import {
|
|||||||
listStoryThreads,
|
listStoryThreads,
|
||||||
markOpportunity,
|
markOpportunity,
|
||||||
markStoryThreadStatus,
|
markStoryThreadStatus,
|
||||||
|
postAgentMessage,
|
||||||
|
setStoryNodeReviewState,
|
||||||
migrate,
|
migrate,
|
||||||
pool,
|
pool,
|
||||||
seedInitialStoryThread,
|
seedInitialStoryThread,
|
||||||
@ -23,7 +26,9 @@ import {
|
|||||||
logoutWeb,
|
logoutWeb,
|
||||||
protectedResourceMetadata,
|
protectedResourceMetadata,
|
||||||
requireMcpAuth,
|
requireMcpAuth,
|
||||||
requireWebAuth
|
requireWebAuth,
|
||||||
|
requireWebRole,
|
||||||
|
webMediaRole
|
||||||
} from "./auth.js";
|
} from "./auth.js";
|
||||||
import { handleMcp } from "./mcp.js";
|
import { handleMcp } from "./mcp.js";
|
||||||
import { SOURCES } from "./sources.js";
|
import { SOURCES } from "./sources.js";
|
||||||
@ -35,6 +40,16 @@ app.set("trust proxy", true);
|
|||||||
app.use(express.json({ limit: "2mb" }));
|
app.use(express.json({ limit: "2mb" }));
|
||||||
app.use(express.urlencoded({ extended: false }));
|
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) {
|
function escapeHtml(value: unknown) {
|
||||||
return String(value ?? "")
|
return String(value ?? "")
|
||||||
.replace(/&/g, "&")
|
.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 user = (req as any).user ?? {};
|
||||||
const identity = String(user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user");
|
const identity = String(user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user");
|
||||||
try {
|
try {
|
||||||
@ -191,7 +252,8 @@ app.get("/stories/:id", requireWebAuth, async (req, res) => {
|
|||||||
|
|
||||||
const user = (req as any).user ?? {};
|
const user = (req as any).user ?? {};
|
||||||
const identity = String(user.name || user.preferred_username || user.email || "Signed-in 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) => {
|
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 user = (req as any).user ?? {};
|
||||||
const identity = String(user.name || user.preferred_username || user.email || "Signed-in 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 user = (req as any).user ?? {};
|
||||||
const identity = String(
|
const identity = String(
|
||||||
user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user"
|
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("/");
|
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 id = String(req.params.id);
|
||||||
const status = String(req.body.status ?? "");
|
const status = String(req.body.status ?? "");
|
||||||
await markOpportunity(id, status);
|
await markOpportunity(id, status);
|
||||||
@ -225,10 +288,11 @@ app.get("/trust", requireWebAuth, async (req, res) => {
|
|||||||
const entries = await listSourceTrustPolicy();
|
const entries = await listSourceTrustPolicy();
|
||||||
const user = (req as any).user ?? {};
|
const user = (req as any).user ?? {};
|
||||||
const identity = String(user.name || user.preferred_username || user.email || "Signed-in 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 user = (req as any).user ?? {};
|
||||||
const identity = String(user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user");
|
const identity = String(user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user");
|
||||||
try {
|
try {
|
||||||
@ -253,7 +317,8 @@ app.get("/opportunities/:id", requireWebAuth, async (req, res) => {
|
|||||||
|
|
||||||
const user = (req as any).user ?? {};
|
const user = (req as any).user ?? {};
|
||||||
const identity = String(user.name || user.preferred_username || user.email || "Signed-in 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) => {
|
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 user = (req as any).user ?? {};
|
||||||
const identity = String(user.name || user.preferred_username || user.email || "Signed-in 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) => {
|
app.get("/", requireWebAuth, async (req, res) => {
|
||||||
@ -294,9 +360,11 @@ app.get("/", requireWebAuth, async (req, res) => {
|
|||||||
|
|
||||||
const user = (req as any).user ?? {};
|
const user = (req as any).user ?? {};
|
||||||
const identity = String(user.name || user.preferred_username || user.email || "Signed-in user");
|
const identity = String(user.name || user.preferred_username || user.email || "Signed-in user");
|
||||||
|
const role = webMediaRole(req);
|
||||||
|
|
||||||
return res.type("html").send(renderDashboard({
|
return res.type("html").send(renderDashboard({
|
||||||
identity,
|
identity,
|
||||||
|
role,
|
||||||
counts: counts.rows[0] ?? {},
|
counts: counts.rows[0] ?? {},
|
||||||
messages,
|
messages,
|
||||||
sources: sourceRows.rows,
|
sources: sourceRows.rows,
|
||||||
|
|||||||
47
src/mcp.ts
47
src/mcp.ts
@ -18,12 +18,14 @@ import {
|
|||||||
markOpportunity,
|
markOpportunity,
|
||||||
postAgentMessage,
|
postAgentMessage,
|
||||||
searchEvidence,
|
searchEvidence,
|
||||||
|
setStoryNodeReviewState,
|
||||||
sourceStatus,
|
sourceStatus,
|
||||||
evaluateSourceTrust,
|
evaluateSourceTrust,
|
||||||
listSourceTrustPolicy
|
listSourceTrustPolicy
|
||||||
} from "./db.js";
|
} from "./db.js";
|
||||||
import { syncAll } from "./sync.js";
|
import { syncAll } from "./sync.js";
|
||||||
import type { SourceId } from "./types.js";
|
import type { SourceId } from "./types.js";
|
||||||
|
import { mediaRoleAtLeast, type MediaRole } from "./auth.js";
|
||||||
|
|
||||||
const tools = [
|
const tools = [
|
||||||
{
|
{
|
||||||
@ -178,7 +180,7 @@ const tools = [
|
|||||||
body: { type: "string", maxLength: 8000 },
|
body: { type: "string", maxLength: 8000 },
|
||||||
severity: { type: "string", enum: ["info", "notice", "warning", "error"] },
|
severity: { type: "string", enum: ["info", "notice", "warning", "error"] },
|
||||||
kind: { type: "string", enum: ["update", "finding", "request", "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" }
|
related_id: { type: "string" }
|
||||||
},
|
},
|
||||||
required: ["title", "body"],
|
required: ["title", "body"],
|
||||||
@ -251,6 +253,20 @@ const tools = [
|
|||||||
additionalProperties: false
|
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",
|
name: "story_edge_add",
|
||||||
description: "Connect two nodes in a story graph with an explicit evidentiary relationship.",
|
description: "Connect two nodes in a story graph with an explicit evidentiary relationship.",
|
||||||
@ -311,11 +327,32 @@ function actorFromRequest(req: Request) {
|
|||||||
return {
|
return {
|
||||||
sub: String(auth.sub ?? "unknown"),
|
sub: String(auth.sub ?? "unknown"),
|
||||||
name: String(auth.preferred_username ?? auth.email ?? auth.name ?? auth.sub ?? "OAuth agent"),
|
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>) {
|
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_list") return result(await listSources());
|
||||||
if (name === "sources_status") return result(await sourceStatus());
|
if (name === "sources_status") return result(await sourceStatus());
|
||||||
if (name === "source_trust_policy") return result(await listSourceTrustPolicy({ enabledOnly: true }));
|
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,
|
messageId: args.message_id,
|
||||||
confidence: args.confidence
|
confidence: args.confidence
|
||||||
}, actor));
|
}, 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, {
|
if (name === "story_edge_add") return result(await addStoryEdge(args.thread_id, {
|
||||||
fromNodeId: args.from_node_id,
|
fromNodeId: args.from_node_id,
|
||||||
toNodeId: args.to_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";
|
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: {
|
function shell(args: {
|
||||||
title: string;
|
title: string;
|
||||||
active: "dashboard" | "stories" | "opportunities" | "trust";
|
active: "dashboard" | "stories" | "opportunities" | "trust";
|
||||||
identity: string;
|
identity: string;
|
||||||
|
role?: string;
|
||||||
body: string;
|
body: string;
|
||||||
}) {
|
}) {
|
||||||
const nav = [
|
const nav = [
|
||||||
@ -181,6 +188,17 @@ button,input,select{font:inherit}
|
|||||||
.empty{text-align:center;padding:30px;color:var(--muted)}
|
.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)}
|
.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}
|
.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{display:grid;grid-template-columns:1.2fr 1.2fr .6fr 1fr 1fr;gap:10px;align-items:end}
|
||||||
.trust-form .wide{grid-column:1/-1}
|
.trust-form .wide{grid-column:1/-1}
|
||||||
.field label{display:block;font-size:.76rem;color:var(--muted);font-weight:700;margin-bottom:5px}
|
.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">
|
<div class="topbar"><div class="topbar-inner">
|
||||||
<a class="brand" href="/"><span class="brand-mark">MI</span><span>Media Intelligence</span></a>
|
<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>
|
<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>
|
</div></div>
|
||||||
<main class="page">${args.body}</main>
|
<main class="page">${args.body}</main>
|
||||||
<div id="toast" class="toast">Link copied</div>
|
<div id="toast" class="toast">Link copied</div>
|
||||||
@ -234,7 +252,7 @@ function graphSvg(story: AnyRow) {
|
|||||||
const p = positions.get(node.id)!;
|
const p = positions.get(node.id)!;
|
||||||
const raw = String(node.title ?? "");
|
const raw = String(node.title ?? "");
|
||||||
const label = raw.length > 39 ? `${raw.slice(0,36)}…` : raw;
|
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("");
|
}).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>`;
|
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>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function evidenceCard(node: AnyRow) {
|
function evidenceCard(node: AnyRow, role = "viewer", storyId?: string) {
|
||||||
const kind = node.evidence_kind ?? "discovery_metadata";
|
const kind = node.evidence_kind ?? "discovery_metadata";
|
||||||
const publisher = node.provenance_publisher ?? node.provenance_source_id ?? "Unknown publisher";
|
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 quality = kind === "source_document" ? "Source document" : kind === "source_payload" ? "Official source payload" : "Discovery metadata";
|
||||||
const excerpt = String(node.evidence_excerpt ?? node.summary ?? "").trim();
|
const excerpt = String(node.evidence_excerpt ?? node.summary ?? "").trim();
|
||||||
const sourceDate = node.snapshot_published_at ?? node.snapshot_retrieved_at ?? node.created_at;
|
const sourceDate = node.snapshot_published_at ?? node.snapshot_retrieved_at ?? node.created_at;
|
||||||
return `<article class="panel evidence-card" id="node-${esc(node.id)}">
|
const state = String(node.review_state ?? "active");
|
||||||
<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>
|
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>
|
<h3>${esc(node.title)}</h3><p>${esc(node.summary)}</p>
|
||||||
${excerpt ? `<div class="evidence-excerpt">${esc(excerpt.slice(0,1500))}${excerpt.length > 1500 ? "…" : ""}</div>` : ""}
|
${excerpt ? `<div class="evidence-excerpt">${esc(excerpt.slice(0,1500))}${excerpt.length > 1500 ? "…" : ""}</div>` : ""}
|
||||||
<dl class="provenance">
|
<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>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>
|
<dt>Captured</dt><dd>${esc(nz(node.snapshot_retrieved_at))}</dd>
|
||||||
</dl>
|
</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>`;
|
</article>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderStory(args: {story: AnyRow; identity: string}) {
|
|
||||||
|
export function renderStory(args: {story: AnyRow; identity: string; role?: string}) {
|
||||||
const {story, identity} = args;
|
const {story, identity} = args;
|
||||||
|
const role = args.role ?? "viewer";
|
||||||
const nodes = Array.isArray(story.nodes) ? story.nodes : [];
|
const nodes = Array.isArray(story.nodes) ? story.nodes : [];
|
||||||
const edges = Array.isArray(story.edges) ? story.edges : [];
|
const edges = Array.isArray(story.edges) ? story.edges : [];
|
||||||
const evidenceNodes = nodes.filter((n: AnyRow) => n.evidence_id);
|
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 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 byId = new Map(nodes.map((n: AnyRow) => [n.id,n]));
|
||||||
const counter = (story.research_checks ?? []).find((c:AnyRow)=>c.check_type==="counter_evidence");
|
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 = `
|
const body = `
|
||||||
<div class="thread-hero"><div><div class="eyebrow">360° Story Thread</div>
|
<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>
|
<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="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>
|
<div class="actions"><button class="btn" type="button" onclick="copyCurrentLink()">Copy link</button><a class="btn" href="/stories">All threads</a></div></div>
|
||||||
@ -313,11 +340,36 @@ export function renderStory(args: {story: AnyRow; identity: string}) {
|
|||||||
<div class="section">${sourceAuditPanel(story)}</div>
|
<div class="section">${sourceAuditPanel(story)}</div>
|
||||||
|
|
||||||
<div class="section grid grid-2">
|
<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="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="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="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>
|
||||||
<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>
|
${(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 style="font-size:.87rem;color:#536075">${counter ? esc(counter.summary) : "No completed counter-evidence check is stored for this thread."}</div></div>
|
|
||||||
</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>
|
<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-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>
|
||||||
@ -327,13 +379,21 @@ export function renderStory(args: {story: AnyRow; identity: string}) {
|
|||||||
<div class="assertion-grid">${assertions.length ? assertions.map((node: AnyRow) => {
|
<div class="assertion-grid">${assertions.length ? assertions.map((node: AnyRow) => {
|
||||||
const state = assertionState(node, story);
|
const state = assertionState(node, story);
|
||||||
const incoming = edges.filter((e:AnyRow)=>e.to_node_id===node.id);
|
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>
|
}).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>
|
<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>
|
<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)=>{
|
<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>`;
|
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>`;
|
}).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>
|
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)=>{
|
<div class="panel">${args.stories.length ? args.stories.map((s:AnyRow)=>{
|
||||||
const audit=s.source_audit ?? {};
|
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>`;
|
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>`;
|
}).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 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>
|
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>
|
<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>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>`;
|
<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 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>
|
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="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>`;
|
<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 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>
|
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">
|
<div class="grid grid-metrics">
|
||||||
@ -391,12 +452,12 @@ export function renderDashboard(args:{identity:string;counts:AnyRow;messages:Any
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<section class="section"><div class="section-head"><div><h2>Agent Activity</h2><p>Operational findings and source-monitor updates.</p></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>`;
|
<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,body});
|
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=`
|
const body=`
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div><div class="eyebrow">Editorial policy</div><h1>Source Trust</h1>
|
<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>
|
</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>
|
<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">
|
<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>
|
<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="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>
|
<div class="actions"><button class="btn btn-primary" type="submit">Save source</button></div>
|
||||||
</form>
|
</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">
|
<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>
|
<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 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><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>
|
<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-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-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>`).join("")}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -459,5 +520,5 @@ export function renderTrustPolicy(args:{identity:string;entries:AnyRow[]}) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>`;
|
</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