Sync story source audit visibility

This commit is contained in:
Admin 2026-09-29 08:07:20 +00:00
parent 8c728615af
commit 33304877dc

218
src/db.ts
View File

@ -686,6 +686,104 @@ export async function listStoryThreads(args: {
return result.rows; return result.rows;
} }
function buildStorySourceAudit(story: any) {
const nodes = Array.isArray(story.nodes) ? story.nodes : [];
const edges = Array.isArray(story.edges) ? story.edges : [];
const evidenceBacked = nodes.filter((n: any) =>
Boolean(
n.evidence_id &&
n.snapshot_id &&
n.provenance_source_id &&
n.snapshot_kind === "source_document" &&
n.evidence_kind === "source_document"
)
);
const distinctSourceIds = Array.from(new Set(
evidenceBacked.map((n: any) => String(n.provenance_source_id)).filter(Boolean)
));
const distinctGroups = Array.from(new Set(
evidenceBacked.map((n: any) => String(n.provenance_independence_group)).filter(Boolean)
));
const publishers = Array.from(new Set(
evidenceBacked.map((n: any) => String(n.provenance_publisher)).filter(Boolean)
));
const tierOneCount = evidenceBacked.filter((n: any) =>
Number(n.provenance_authority_tier) === 1
).length;
const evidentiaryTypes = new Set(["evidence","actual","forecast","trend","signal","metric"]);
const sourceTaggedButUnbacked = nodes
.filter((n: any) => evidentiaryTypes.has(n.node_type) && n.source_id && !n.evidence_id)
.map((n: any) => ({ id: n.id, title: n.title, node_type: n.node_type, source_id: n.source_id }));
const claimTypes = new Set(["claim","counterclaim"]);
const evidenceNodeIds = new Set(evidenceBacked.map((n: any) => n.id));
const supportingRelationships = new Set([
"supports","contradicts","qualifies","forecast_for","actual_for","regional_breakdown_of"
]);
const unsupportedClaims = nodes
.filter((n: any) => claimTypes.has(n.node_type))
.filter((claim: any) => !edges.some((edge: any) =>
edge.to_node_id === claim.id &&
evidenceNodeIds.has(edge.from_node_id) &&
supportingRelationships.has(edge.relationship)
))
.map((n: any) => ({ id: n.id, title: n.title, node_type: n.node_type }));
const staleCutoff = Date.now() - 45 * 86_400_000;
const staleEvidence = evidenceBacked
.filter((n: any) => {
const value = n.snapshot_published_at ?? n.snapshot_retrieved_at;
const ts = value ? new Date(value).getTime() : NaN;
return Number.isFinite(ts) && ts < staleCutoff;
})
.map((n: any) => ({ id: n.id, title: n.title, source_id: n.provenance_source_id }));
const checks = {
at_least_two_independent_sources: distinctGroups.length >= 2,
at_least_two_evidence_backed_nodes: evidenceBacked.length >= 2,
at_least_one_tier_one_source: tierOneCount >= 1,
all_claims_have_direct_evidence: unsupportedClaims.length === 0,
no_source_tag_only_evidence_nodes: sourceTaggedButUnbacked.length === 0
};
return {
robust: Object.values(checks).every(Boolean),
checks,
counts: {
evidence_backed_nodes: evidenceBacked.length,
distinct_sources: distinctSourceIds.length,
distinct_independence_groups: distinctGroups.length,
publishers: publishers.length,
tier_one_evidence_nodes: tierOneCount,
unsupported_claims: unsupportedClaims.length,
source_tag_only_nodes: sourceTaggedButUnbacked.length,
stale_evidence: staleEvidence.length
},
sources: evidenceBacked.map((n: any) => ({
node_id: n.id,
node_type: n.node_type,
title: n.title,
source_id: n.provenance_source_id,
publisher: n.provenance_publisher,
source_class: n.provenance_source_class,
independence_group: n.provenance_independence_group,
authority_tier: n.provenance_authority_tier,
evidence_id: n.evidence_id,
snapshot_id: n.snapshot_id,
snapshot_retrieved_at: n.snapshot_retrieved_at,
snapshot_published_at: n.snapshot_published_at,
url: n.evidence_url ?? n.signal_url
})),
unsupported_claims: unsupportedClaims,
source_tag_only_nodes: sourceTaggedButUnbacked,
stale_evidence: staleEvidence
};
}
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.*,
@ -735,12 +833,17 @@ export async function getStoryThread(id: string) {
) )
]); ]);
return { const story = {
...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
}; };
return {
...story,
source_audit: buildStorySourceAudit(story)
};
} }
export async function addStoryNode( export async function addStoryNode(
@ -937,105 +1040,7 @@ export async function markStoryThreadStatus(
export async function auditStorySources(threadId: string) { export async function auditStorySources(threadId: string) {
const story = await getStoryThread(threadId); const story = await getStoryThread(threadId);
if (!story) throw new Error("Story thread not found"); if (!story) throw new Error("Story thread not found");
return { thread_id: threadId, ...(story as any).source_audit };
const nodes = Array.isArray(story.nodes) ? story.nodes : [];
const edges = Array.isArray(story.edges) ? story.edges : [];
const evidenceBacked = nodes.filter((n: any) =>
Boolean(
n.evidence_id &&
n.snapshot_id &&
n.provenance_source_id &&
n.snapshot_kind === "source_document" &&
n.evidence_kind === "source_document"
)
);
const distinctSourceIds = Array.from(new Set(
evidenceBacked.map((n: any) => String(n.provenance_source_id)).filter(Boolean)
));
const distinctGroups = Array.from(new Set(
evidenceBacked.map((n: any) => String(n.provenance_independence_group)).filter(Boolean)
));
const publishers = Array.from(new Set(
evidenceBacked.map((n: any) => String(n.provenance_publisher)).filter(Boolean)
));
const tierOneCount = evidenceBacked.filter((n: any) =>
Number(n.provenance_authority_tier) === 1
).length;
const evidentiaryTypes = new Set(["evidence","actual","forecast","trend","signal","metric"]);
const sourceTaggedButUnbacked = nodes
.filter((n: any) => evidentiaryTypes.has(n.node_type) && n.source_id && !n.evidence_id)
.map((n: any) => ({ id: n.id, title: n.title, node_type: n.node_type, source_id: n.source_id }));
const claimTypes = new Set(["claim","counterclaim"]);
const evidenceNodeIds = new Set(evidenceBacked.map((n: any) => n.id));
const supportingRelationships = new Set([
"supports","contradicts","qualifies","forecast_for","actual_for","regional_breakdown_of"
]);
const unsupportedClaims = nodes
.filter((n: any) => claimTypes.has(n.node_type))
.filter((claim: any) => !edges.some((edge: any) =>
edge.to_node_id === claim.id &&
evidenceNodeIds.has(edge.from_node_id) &&
supportingRelationships.has(edge.relationship)
))
.map((n: any) => ({ id: n.id, title: n.title, node_type: n.node_type }));
const staleCutoff = Date.now() - 45 * 86_400_000;
const staleEvidence = evidenceBacked
.filter((n: any) => {
const value = n.snapshot_published_at ?? n.snapshot_retrieved_at;
const ts = value ? new Date(value).getTime() : NaN;
return Number.isFinite(ts) && ts < staleCutoff;
})
.map((n: any) => ({ id: n.id, title: n.title, source_id: n.provenance_source_id }));
const checks = {
at_least_two_independent_sources: distinctGroups.length >= 2,
at_least_two_evidence_backed_nodes: evidenceBacked.length >= 2,
at_least_one_tier_one_source: tierOneCount >= 1,
all_claims_have_direct_evidence: unsupportedClaims.length === 0,
no_source_tag_only_evidence_nodes: sourceTaggedButUnbacked.length === 0
};
const robust = Object.values(checks).every(Boolean);
return {
thread_id: threadId,
robust,
checks,
counts: {
evidence_backed_nodes: evidenceBacked.length,
distinct_sources: distinctSourceIds.length,
distinct_independence_groups: distinctGroups.length,
publishers: publishers.length,
tier_one_evidence_nodes: tierOneCount,
unsupported_claims: unsupportedClaims.length,
source_tag_only_nodes: sourceTaggedButUnbacked.length,
stale_evidence: staleEvidence.length
},
sources: evidenceBacked.map((n: any) => ({
node_id: n.id,
node_type: n.node_type,
title: n.title,
source_id: n.provenance_source_id,
publisher: n.provenance_publisher,
source_class: n.provenance_source_class,
independence_group: n.provenance_independence_group,
authority_tier: n.provenance_authority_tier,
evidence_id: n.evidence_id,
snapshot_id: n.snapshot_id,
snapshot_retrieved_at: n.snapshot_retrieved_at,
snapshot_published_at: n.snapshot_published_at,
url: n.evidence_url ?? n.signal_url
})),
unsupported_claims: unsupportedClaims,
source_tag_only_nodes: sourceTaggedButUnbacked,
stale_evidence: staleEvidence
};
} }
export async function enrichEvidenceDocument(evidenceId: string) { export async function enrichEvidenceDocument(evidenceId: string) {
@ -1153,10 +1158,21 @@ export async function enrichStoryEvidenceDocuments(limit = 20) {
const evidence = await enrichEvidenceDocument(row.evidence_id); const evidence = await enrichEvidenceDocument(row.evidence_id);
enriched.push({ evidence_id: row.evidence_id, ok: true, snapshot_id: evidence?.snapshot_id ?? null }); enriched.push({ evidence_id: row.evidence_id, ok: true, snapshot_id: evidence?.snapshot_id ?? null });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error);
const errorHash = createHash("sha256").update(message).digest("hex").slice(0, 16);
await postSystemMessageOnce({
dedupeKey: `evidence-enrich-error:${row.evidence_id}:${errorHash}`,
title: "Source document enrichment failed",
body: `Evidence ${row.evidence_id} could not be upgraded from discovery metadata to a frozen source document: ${message}`,
severity: "warning",
kind: "warning",
relatedType: "evidence",
relatedId: row.evidence_id
});
enriched.push({ enriched.push({
evidence_id: row.evidence_id, evidence_id: row.evidence_id,
ok: false, ok: false,
error: error instanceof Error ? error.message : String(error) error: message
}); });
} }
} }