Sync source trust policy and independence scoring
This commit is contained in:
parent
e42c8f0caa
commit
d3ef7c6ed2
247
src/db.ts
247
src/db.ts
@ -221,6 +221,24 @@ export async function migrate() {
|
|||||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS source_trust_policy (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
domain TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
trust_weight NUMERIC(4,3) NOT NULL CHECK (trust_weight >= 0 AND trust_weight <= 1),
|
||||||
|
independence_group TEXT NOT NULL,
|
||||||
|
source_class TEXT NOT NULL DEFAULT 'independent',
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
source_id TEXT REFERENCES sources(id) ON DELETE SET NULL,
|
||||||
|
created_by TEXT NOT NULL DEFAULT 'system',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_source_trust_policy_enabled_weight
|
||||||
|
ON source_trust_policy(enabled, trust_weight DESC);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await pool.query("ALTER TABLE sources ADD COLUMN IF NOT EXISTS baseline_complete BOOLEAN NOT NULL DEFAULT FALSE");
|
await pool.query("ALTER TABLE sources ADD COLUMN IF NOT EXISTS baseline_complete BOOLEAN NOT NULL DEFAULT FALSE");
|
||||||
@ -352,6 +370,235 @@ export async function seedSources(sources: SourceDefinition[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function normaliseTrustDomain(value: string) {
|
||||||
|
const raw = String(value ?? "").trim().toLowerCase();
|
||||||
|
if (!raw) throw new Error("Source domain is required");
|
||||||
|
let hostname = raw;
|
||||||
|
try {
|
||||||
|
const url = new URL(raw.includes("://") ? raw : `https://${raw}`);
|
||||||
|
hostname = url.hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid source domain or URL");
|
||||||
|
}
|
||||||
|
hostname = hostname.replace(/^www\./, "").replace(/\.$/, "");
|
||||||
|
if (!hostname || !hostname.includes(".")) throw new Error("Invalid source domain");
|
||||||
|
return hostname;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultTrustWeight(sourceClass: string, authorityTier: number) {
|
||||||
|
const byClass: Record<string, number> = {
|
||||||
|
official_primary: 1.0,
|
||||||
|
official_data: 0.98,
|
||||||
|
official_registry: 0.97,
|
||||||
|
official_notice: 0.95,
|
||||||
|
official_analysis: 0.93,
|
||||||
|
catalogue: 0.75
|
||||||
|
};
|
||||||
|
if (sourceClass in byClass) return byClass[sourceClass];
|
||||||
|
if (authorityTier === 1) return 0.92;
|
||||||
|
if (authorityTier === 2) return 0.78;
|
||||||
|
return 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedSourceTrustPolicy(sources: SourceDefinition[]) {
|
||||||
|
for (const source of sources) {
|
||||||
|
const domain = normaliseTrustDomain(source.url);
|
||||||
|
const weight = defaultTrustWeight(source.sourceClass, source.authorityTier);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO source_trust_policy(
|
||||||
|
id,domain,name,trust_weight,independence_group,source_class,notes,
|
||||||
|
enabled,source_id,created_by
|
||||||
|
)
|
||||||
|
VALUES($1,$2,$3,$4,$5,$6,$7,TRUE,$8,'system-seed')
|
||||||
|
ON CONFLICT(domain) DO UPDATE SET
|
||||||
|
source_id=COALESCE(source_trust_policy.source_id,EXCLUDED.source_id),
|
||||||
|
name=CASE WHEN source_trust_policy.created_by='system-seed' THEN EXCLUDED.name ELSE source_trust_policy.name END,
|
||||||
|
independence_group=CASE WHEN source_trust_policy.created_by='system-seed' THEN EXCLUDED.independence_group ELSE source_trust_policy.independence_group END,
|
||||||
|
source_class=CASE WHEN source_trust_policy.created_by='system-seed' THEN EXCLUDED.source_class ELSE source_trust_policy.source_class END,
|
||||||
|
updated_at=NOW()`,
|
||||||
|
[
|
||||||
|
randomUUID(),
|
||||||
|
domain,
|
||||||
|
source.publisher || source.name,
|
||||||
|
weight,
|
||||||
|
source.independenceGroup || domain,
|
||||||
|
source.sourceClass,
|
||||||
|
"Seeded from configured ingestion source. Weight is an editorial prior, not a truth guarantee.",
|
||||||
|
source.id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSourceTrustPolicy(args: { enabledOnly?: boolean } = {}) {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT *
|
||||||
|
FROM source_trust_policy
|
||||||
|
${args.enabledOnly ? "WHERE enabled=TRUE" : ""}
|
||||||
|
ORDER BY trust_weight DESC, name ASC`
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertSourceTrustEntry(
|
||||||
|
args: {
|
||||||
|
domain: string;
|
||||||
|
name: string;
|
||||||
|
trustWeight: number;
|
||||||
|
independenceGroup?: string;
|
||||||
|
sourceClass?: string;
|
||||||
|
notes?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
},
|
||||||
|
actorLabel: string
|
||||||
|
) {
|
||||||
|
const domain = normaliseTrustDomain(args.domain);
|
||||||
|
const name = String(args.name ?? "").trim();
|
||||||
|
const weight = Number(args.trustWeight);
|
||||||
|
const independenceGroup = String(args.independenceGroup ?? domain).trim().toLowerCase();
|
||||||
|
const sourceClass = String(args.sourceClass ?? "independent").trim().toLowerCase();
|
||||||
|
const notes = String(args.notes ?? "").trim();
|
||||||
|
|
||||||
|
if (!name) throw new Error("Source name is required");
|
||||||
|
if (!Number.isFinite(weight) || weight < 0 || weight > 1) {
|
||||||
|
throw new Error("Trust weight must be between 0 and 1");
|
||||||
|
}
|
||||||
|
if (!independenceGroup) throw new Error("Independence group is required");
|
||||||
|
if (name.length > 180 || notes.length > 4000) throw new Error("Source trust entry is too long");
|
||||||
|
|
||||||
|
const existing = await pool.query(
|
||||||
|
"SELECT id FROM source_trust_policy WHERE domain=$1",
|
||||||
|
[domain]
|
||||||
|
);
|
||||||
|
const id = existing.rows[0]?.id ?? randomUUID();
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO source_trust_policy(
|
||||||
|
id,domain,name,trust_weight,independence_group,source_class,notes,
|
||||||
|
enabled,created_by
|
||||||
|
)
|
||||||
|
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||||
|
ON CONFLICT(domain) DO UPDATE SET
|
||||||
|
name=EXCLUDED.name,
|
||||||
|
trust_weight=EXCLUDED.trust_weight,
|
||||||
|
independence_group=EXCLUDED.independence_group,
|
||||||
|
source_class=EXCLUDED.source_class,
|
||||||
|
notes=EXCLUDED.notes,
|
||||||
|
enabled=EXCLUDED.enabled,
|
||||||
|
created_by=EXCLUDED.created_by,
|
||||||
|
updated_at=NOW()
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
id,domain,name,weight,independenceGroup,sourceClass,notes,
|
||||||
|
args.enabled !== false,actorLabel
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO audit_events(id,event_type,object_type,object_id,payload)
|
||||||
|
VALUES($1,'source_trust.updated','source_trust',$2,$3::jsonb)`,
|
||||||
|
[randomUUID(), result.rows[0].id, JSON.stringify({
|
||||||
|
domain, trustWeight: weight, independenceGroup, actor: actorLabel
|
||||||
|
})]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evaluateSourceTrust(
|
||||||
|
inputs: Array<{ url?: string; domain?: string; sourceId?: string }>
|
||||||
|
) {
|
||||||
|
if (!Array.isArray(inputs) || !inputs.length) {
|
||||||
|
throw new Error("At least one source is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = await listSourceTrustPolicy({ enabledOnly: true });
|
||||||
|
const bySourceId = new Map(
|
||||||
|
policy.filter((p: any) => p.source_id).map((p: any) => [String(p.source_id), p])
|
||||||
|
);
|
||||||
|
|
||||||
|
const matched = inputs.slice(0, 50).map((input) => {
|
||||||
|
let entry: any = null;
|
||||||
|
let domain: string | null = null;
|
||||||
|
|
||||||
|
if (input.sourceId && bySourceId.has(String(input.sourceId))) {
|
||||||
|
entry = bySourceId.get(String(input.sourceId));
|
||||||
|
domain = String(entry.domain);
|
||||||
|
} else {
|
||||||
|
const value = input.url || input.domain;
|
||||||
|
if (value) {
|
||||||
|
try { domain = normaliseTrustDomain(value); } catch { domain = null; }
|
||||||
|
}
|
||||||
|
if (domain) {
|
||||||
|
entry = policy
|
||||||
|
.filter((p: any) => domain === p.domain || domain!.endsWith(`.${p.domain}`))
|
||||||
|
.sort((a: any, b: any) => String(b.domain).length - String(a.domain).length)[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
input,
|
||||||
|
matched: Boolean(entry),
|
||||||
|
domain,
|
||||||
|
policy_id: entry?.id ?? null,
|
||||||
|
name: entry?.name ?? null,
|
||||||
|
weight: entry ? Number(entry.trust_weight) : null,
|
||||||
|
independence_group: entry?.independence_group ?? null,
|
||||||
|
source_class: entry?.source_class ?? null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = new Map<string, any>();
|
||||||
|
for (const item of matched.filter((x) => x.matched)) {
|
||||||
|
const group = String(item.independence_group || item.domain);
|
||||||
|
const current = groups.get(group);
|
||||||
|
if (!current || Number(item.weight) > Number(current.weight)) groups.set(group, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const independent = Array.from(groups.values()).sort(
|
||||||
|
(a, b) => Number(b.weight) - Number(a.weight)
|
||||||
|
);
|
||||||
|
const strongest = independent.length ? Number(independent[0].weight) : 0;
|
||||||
|
const corroborationBonus = independent
|
||||||
|
.slice(1)
|
||||||
|
.reduce((sum, item) => sum + Number(item.weight) * 0.05, 0);
|
||||||
|
const effectiveScore = Math.min(1, strongest + Math.min(0.15, corroborationBonus));
|
||||||
|
const independentCount = independent.length;
|
||||||
|
|
||||||
|
let confidenceBand = "unrated";
|
||||||
|
if (independentCount >= 2 && effectiveScore >= 0.9) confidenceBand = "strong";
|
||||||
|
else if (independentCount >= 2 && effectiveScore >= 0.75) confidenceBand = "moderate";
|
||||||
|
else if (independentCount === 1 && strongest >= 0.85) confidenceBand = "strong_single_source";
|
||||||
|
else if (independentCount >= 1) confidenceBand = "limited";
|
||||||
|
|
||||||
|
return {
|
||||||
|
policy_version: "source-trust-v1",
|
||||||
|
methodology: {
|
||||||
|
description: "Use the strongest configured source from each independence group. Repeated sources from the same publisher/group do not add corroboration. Additional independent groups contribute a small capped corroboration bonus.",
|
||||||
|
strongest_source_weight: strongest,
|
||||||
|
corroboration_bonus: Number(Math.min(0.15, corroborationBonus).toFixed(3)),
|
||||||
|
maximum_independence_bonus: 0.15
|
||||||
|
},
|
||||||
|
effective_score: Number(effectiveScore.toFixed(3)),
|
||||||
|
confidence_band: confidenceBand,
|
||||||
|
independent_source_count: independentCount,
|
||||||
|
meets_independence_minimum: independentCount >= 2,
|
||||||
|
policy_coverage: Number((matched.filter((x) => x.matched).length / matched.length).toFixed(3)),
|
||||||
|
independent_groups: independent.map((x) => ({
|
||||||
|
group: x.independence_group || x.domain,
|
||||||
|
representative: x.name,
|
||||||
|
weight: x.weight,
|
||||||
|
domain: x.domain
|
||||||
|
})),
|
||||||
|
sources: matched,
|
||||||
|
guidance: independentCount >= 2
|
||||||
|
? "Independent corroboration present. Evaluate whether the sources genuinely support the same claim and check for shared upstream evidence."
|
||||||
|
: "Independent corroboration is not yet present. Seek at least one source from a different independence group before treating the claim as robust."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function contentHash(item: NormalizedItem) {
|
export function contentHash(item: NormalizedItem) {
|
||||||
return createHash("sha256")
|
return createHash("sha256")
|
||||||
.update(JSON.stringify({
|
.update(JSON.stringify({
|
||||||
|
|||||||
34
src/index.ts
34
src/index.ts
@ -12,7 +12,10 @@ import {
|
|||||||
migrate,
|
migrate,
|
||||||
pool,
|
pool,
|
||||||
seedInitialStoryThread,
|
seedInitialStoryThread,
|
||||||
seedSources
|
seedSources,
|
||||||
|
seedSourceTrustPolicy,
|
||||||
|
listSourceTrustPolicy,
|
||||||
|
upsertSourceTrustEntry
|
||||||
} from "./db.js";
|
} from "./db.js";
|
||||||
import {
|
import {
|
||||||
beginWebLogin,
|
beginWebLogin,
|
||||||
@ -25,7 +28,7 @@ import {
|
|||||||
import { handleMcp } from "./mcp.js";
|
import { handleMcp } from "./mcp.js";
|
||||||
import { SOURCES } from "./sources.js";
|
import { SOURCES } from "./sources.js";
|
||||||
import { syncAll } from "./sync.js";
|
import { syncAll } from "./sync.js";
|
||||||
import { renderDashboard, renderOpportunities, renderOpportunity, renderStories, renderStory } from "./web.js";
|
import { renderDashboard, renderOpportunities, renderOpportunity, renderStories, renderStory, renderTrustPolicy } from "./web.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.set("trust proxy", true);
|
app.set("trust proxy", true);
|
||||||
@ -218,6 +221,32 @@ app.post("/opportunities/:id/status", requireWebAuth, async (req, res) => {
|
|||||||
return res.redirect(req.body.returnTo === "detail" ? `/opportunities/${encodeURIComponent(id)}` : "/opportunities");
|
return res.redirect(req.body.returnTo === "detail" ? `/opportunities/${encodeURIComponent(id)}` : "/opportunities");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/trust", requireWebAuth, async (req, res) => {
|
||||||
|
const user = (req as any).user ?? {};
|
||||||
|
const identity = String(user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user");
|
||||||
|
try {
|
||||||
|
await upsertSourceTrustEntry({
|
||||||
|
domain: String(req.body.domain ?? ""),
|
||||||
|
name: String(req.body.name ?? ""),
|
||||||
|
trustWeight: Number(req.body.trust_weight),
|
||||||
|
independenceGroup: String(req.body.independence_group ?? ""),
|
||||||
|
sourceClass: String(req.body.source_class ?? "other"),
|
||||||
|
notes: String(req.body.notes ?? ""),
|
||||||
|
enabled: String(req.body.enabled ?? "true") === "true"
|
||||||
|
}, `web:${identity}`);
|
||||||
|
return res.redirect("/trust");
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).type("text").send(error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/opportunities/:id", requireWebAuth, async (req, res) => {
|
app.get("/opportunities/:id", requireWebAuth, async (req, res) => {
|
||||||
const opportunity = await getOpportunity(String(req.params.id));
|
const opportunity = await getOpportunity(String(req.params.id));
|
||||||
if (!opportunity) return res.status(404).send("Opportunity not found");
|
if (!opportunity) return res.status(404).send("Opportunity not found");
|
||||||
@ -278,6 +307,7 @@ app.get("/", requireWebAuth, async (req, res) => {
|
|||||||
async function start() {
|
async function start() {
|
||||||
await migrate();
|
await migrate();
|
||||||
await seedSources(SOURCES);
|
await seedSources(SOURCES);
|
||||||
|
await seedSourceTrustPolicy(SOURCES);
|
||||||
await seedInitialStoryThread();
|
await seedInitialStoryThread();
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
|
|||||||
48
src/mcp.ts
48
src/mcp.ts
@ -18,7 +18,9 @@ import {
|
|||||||
markOpportunity,
|
markOpportunity,
|
||||||
postAgentMessage,
|
postAgentMessage,
|
||||||
searchEvidence,
|
searchEvidence,
|
||||||
sourceStatus
|
sourceStatus,
|
||||||
|
evaluateSourceTrust,
|
||||||
|
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";
|
||||||
@ -34,6 +36,36 @@ const tools = [
|
|||||||
description: "Show sync health and counts for each source.",
|
description: "Show sync health and counts for each source.",
|
||||||
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "source_trust_policy",
|
||||||
|
description: "Return the current editorial source-trust policy: domains, weights, source classes and independence groups. Weights are configurable priors, not guarantees that a claim is true.",
|
||||||
|
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "source_trust_evaluate",
|
||||||
|
description: "Evaluate a set of source URLs/domains/source IDs against the editorial trust policy. Sources in the same independence group are deduplicated; genuinely independent groups add a small corroboration bonus.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
sources: {
|
||||||
|
type: "array",
|
||||||
|
minItems: 1,
|
||||||
|
maxItems: 50,
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
url: { type: "string" },
|
||||||
|
domain: { type: "string" },
|
||||||
|
source_id: { type: "string" }
|
||||||
|
},
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["sources"],
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "sources_sync",
|
name: "sources_sync",
|
||||||
description: "Run ingestion now for one source or all sources.",
|
description: "Run ingestion now for one source or all sources.",
|
||||||
@ -286,6 +318,14 @@ function actorFromRequest(req: Request) {
|
|||||||
async function callTool(name: string, args: any, actor: ReturnType<typeof actorFromRequest>) {
|
async function callTool(name: string, args: any, actor: ReturnType<typeof actorFromRequest>) {
|
||||||
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_evaluate") return result(await evaluateSourceTrust(
|
||||||
|
(args.sources ?? []).map((item: any) => ({
|
||||||
|
url: item.url,
|
||||||
|
domain: item.domain,
|
||||||
|
sourceId: item.source_id
|
||||||
|
}))
|
||||||
|
));
|
||||||
if (name === "sources_sync") return result(await syncAll(args?.source_id as SourceId | undefined));
|
if (name === "sources_sync") return result(await syncAll(args?.source_id as SourceId | undefined));
|
||||||
if (name === "opportunities_list") return result(await listOpportunities(args ?? {}));
|
if (name === "opportunities_list") return result(await listOpportunities(args ?? {}));
|
||||||
if (name === "opportunity_get") return result(await getOpportunity(args.id));
|
if (name === "opportunity_get") return result(await getOpportunity(args.id));
|
||||||
@ -378,6 +418,11 @@ export async function handleMcp(req: Request, res: Response) {
|
|||||||
uri: "media://sources",
|
uri: "media://sources",
|
||||||
name: "Configured media intelligence sources",
|
name: "Configured media intelligence sources",
|
||||||
mimeType: "application/json"
|
mimeType: "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: "media://source-trust-policy",
|
||||||
|
name: "Editorial source trust policy",
|
||||||
|
mimeType: "application/json"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@ -393,6 +438,7 @@ export async function handleMcp(req: Request, res: Response) {
|
|||||||
} else if (method === "resources/read") {
|
} else if (method === "resources/read") {
|
||||||
const uri = String(body.params?.uri ?? "");
|
const uri = String(body.params?.uri ?? "");
|
||||||
if (uri === "media://sources") response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await listSources(), null, 2) }] };
|
if (uri === "media://sources") response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await listSources(), null, 2) }] };
|
||||||
|
else if (uri === "media://source-trust-policy") response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await listSourceTrustPolicy({ enabledOnly: true }), null, 2) }] };
|
||||||
else if (uri.startsWith("media://opportunities/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getOpportunity(uri.split("/").pop()!), null, 2) }] };
|
else if (uri.startsWith("media://opportunities/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getOpportunity(uri.split("/").pop()!), null, 2) }] };
|
||||||
else if (uri.startsWith("media://evidence/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getEvidence(uri.split("/").pop()!), null, 2) }] };
|
else if (uri.startsWith("media://evidence/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getEvidence(uri.split("/").pop()!), null, 2) }] };
|
||||||
else if (uri.startsWith("media://messages/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getAgentMessage(uri.split("/").pop()!), null, 2) }] };
|
else if (uri.startsWith("media://messages/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getAgentMessage(uri.split("/").pop()!), null, 2) }] };
|
||||||
|
|||||||
85
src/web.ts
85
src/web.ts
@ -56,14 +56,15 @@ function evidenceTone(kind: unknown) {
|
|||||||
|
|
||||||
function shell(args: {
|
function shell(args: {
|
||||||
title: string;
|
title: string;
|
||||||
active: "dashboard" | "stories" | "opportunities";
|
active: "dashboard" | "stories" | "opportunities" | "trust";
|
||||||
identity: string;
|
identity: string;
|
||||||
body: string;
|
body: string;
|
||||||
}) {
|
}) {
|
||||||
const nav = [
|
const nav = [
|
||||||
["dashboard", "/", "Desk"],
|
["dashboard", "/", "Desk"],
|
||||||
["stories", "/stories", "Story Threads"],
|
["stories", "/stories", "Story Threads"],
|
||||||
["opportunities", "/opportunities", "Opportunities"]
|
["opportunities", "/opportunities", "Opportunities"],
|
||||||
|
["trust", "/trust", "Source Trust"]
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
@ -180,8 +181,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}
|
||||||
@media(max-width:980px){.grid-metrics{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-2{grid-template-columns:1fr}.thread-hero{grid-template-columns:1fr}}
|
.trust-form{display:grid;grid-template-columns:1.2fr 1.2fr .6fr 1fr 1fr;gap:10px;align-items:end}
|
||||||
@media(max-width:700px){.topbar-inner{padding:0 14px;gap:13px}.brand span:last-child{display:none}.user-name,.signout{display:none}.page{padding:20px 14px 40px}.page-head{display:block}.page-head>.actions{margin-top:14px}.grid-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.list-row{grid-template-columns:1fr}.right-meta{text-align:left}.graph-toolbar{align-items:flex-start;flex-direction:column}.graph-scroll{display:none}.mobile-only{display:block}.assertion-grid,.evidence-grid{grid-template-columns:1fr}.relationship{grid-template-columns:1fr}.relationship-mid{text-align:left}}
|
.trust-form .wide{grid-column:1/-1}
|
||||||
|
.field label{display:block;font-size:.76rem;color:var(--muted);font-weight:700;margin-bottom:5px}
|
||||||
|
.field input,.field select,.field textarea{width:100%;border:1px solid #cfd6df;border-radius:8px;padding:9px 10px;background:#fff;color:var(--ink)}
|
||||||
|
.field textarea{min-height:72px;resize:vertical}
|
||||||
|
.trust-row{display:grid;grid-template-columns:minmax(180px,1.2fr) minmax(160px,1fr) 90px minmax(160px,1fr) auto;gap:12px;align-items:center;padding:13px 0}
|
||||||
|
.trust-row+.trust-row{border-top:1px solid #edf0f4}
|
||||||
|
.weight{font-weight:800;font-variant-numeric:tabular-nums}
|
||||||
|
.weightbar{height:6px;background:#eef1f5;border-radius:999px;overflow:hidden;margin-top:5px}.weightbar>span{display:block;height:100%;background:#2762d2;border-radius:999px}
|
||||||
|
@media(max-width:980px){.trust-form{grid-template-columns:1fr 1fr}.trust-row{grid-template-columns:1fr auto}.trust-row .optional-col{display:none}.grid-metrics{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-2{grid-template-columns:1fr}.thread-hero{grid-template-columns:1fr}}
|
||||||
|
@media(max-width:700px){.trust-form{grid-template-columns:1fr}.topbar-inner{padding:0 14px;gap:13px}.brand span:last-child{display:none}.user-name,.signout{display:none}.page{padding:20px 14px 40px}.page-head{display:block}.page-head>.actions{margin-top:14px}.grid-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.list-row{grid-template-columns:1fr}.right-meta{text-align:left}.graph-toolbar{align-items:flex-start;flex-direction:column}.graph-scroll{display:none}.mobile-only{display:block}.assertion-grid,.evidence-grid{grid-template-columns:1fr}.relationship{grid-template-columns:1fr}.relationship-mid{text-align:left}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -384,3 +394,70 @@ export function renderDashboard(args:{identity:string;counts:AnyRow;messages:Any
|
|||||||
<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><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,body});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function renderTrustPolicy(args:{identity:string;entries:AnyRow[]}) {
|
||||||
|
const body=`
|
||||||
|
<div class="page-head">
|
||||||
|
<div><div class="eyebrow">Editorial policy</div><h1>Source Trust</h1>
|
||||||
|
<p>Configurable source priors for agent research. Weights describe editorial confidence in a source class/domain; they do not make a claim true. Independence groups stop multiple pages from the same publisher being counted as independent corroboration.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<div class="field"><label>Name</label><input id="trust-name" name="name" placeholder="Publisher / source name" required></div>
|
||||||
|
<div class="field"><label>Weight</label><input id="trust-weight" name="trust_weight" type="number" min="0" max="1" step="0.01" value="0.80" required></div>
|
||||||
|
<div class="field"><label>Independence group</label><input id="trust-group" name="independence_group" placeholder="publisher-family" required></div>
|
||||||
|
<div class="field"><label>Source class</label><select id="trust-class" name="source_class">
|
||||||
|
<option value="official_primary">official primary</option>
|
||||||
|
<option value="official_data">official data</option>
|
||||||
|
<option value="official_registry">official registry</option>
|
||||||
|
<option value="official_notice">official notice</option>
|
||||||
|
<option value="official_analysis">official analysis</option>
|
||||||
|
<option value="independent_research">independent research</option>
|
||||||
|
<option value="newsroom">newsroom</option>
|
||||||
|
<option value="specialist">specialist</option>
|
||||||
|
<option value="catalogue">catalogue</option>
|
||||||
|
<option value="other">other</option>
|
||||||
|
</select></div>
|
||||||
|
<div class="field wide"><label>Notes</label><textarea id="trust-notes" name="notes" placeholder="Why this source is useful, caveats, methodology notes…"></textarea></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>
|
||||||
|
</form>
|
||||||
|
</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>
|
||||||
|
<div class="panel">
|
||||||
|
${args.entries.map((e:AnyRow)=>`<div class="trust-row">
|
||||||
|
<div><div class="row-title">${esc(e.name)}</div><div class="meta mono">${esc(e.domain)}</div><div class="row-meta">${badge(e.source_class,"neutral")}${e.enabled?badge("enabled","green"):badge("disabled","neutral")}</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 class="optional-col meta">${esc(e.notes||"—")}</div>
|
||||||
|
<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>
|
||||||
|
</div>`).join("")}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('[data-edit-trust]').forEach(function(btn){
|
||||||
|
btn.addEventListener('click',function(){
|
||||||
|
document.getElementById('trust-domain').value=btn.dataset.domain||'';
|
||||||
|
document.getElementById('trust-name').value=btn.dataset.name||'';
|
||||||
|
document.getElementById('trust-weight').value=btn.dataset.weight||'0.8';
|
||||||
|
document.getElementById('trust-group').value=btn.dataset.group||'';
|
||||||
|
document.getElementById('trust-class').value=btn.dataset.class||'other';
|
||||||
|
document.getElementById('trust-notes').value=btn.dataset.notes||'';
|
||||||
|
document.getElementById('trust-enabled').value=btn.dataset.enabled||'true';
|
||||||
|
window.scrollTo({top:0,behavior:'smooth'});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>`;
|
||||||
|
return shell({title:"Source Trust",active:"trust",identity:args.identity,body});
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user