Task 011 implement evidence matrix and claim gate

This commit is contained in:
2026-05-21 22:52:41 +03:00
parent 7d4a79acb5
commit 7e1b05067c
23 changed files with 2090 additions and 17 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"build": "next build",
"start": "next start",
"test:roles": "node tests/role-navigation.test.mjs",
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/agent_jobs.model.test.mjs && node tests/boundary_questions.model.test.mjs && node tests/plan_review.model.test.mjs && node tests/research_manifest.model.test.mjs",
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/agent_jobs.model.test.mjs && node tests/boundary_questions.model.test.mjs && node tests/plan_review.model.test.mjs && node tests/research_manifest.model.test.mjs && node tests/evidence_matrix.model.test.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -0,0 +1,14 @@
import ArticleEvidencePage from "@/pages/article-evidence";
type DynamicArticleEvidencePageProps = {
params: {
articleId: string;
};
};
export default async function DynamicArticleEvidencePage({
params,
}: DynamicArticleEvidencePageProps) {
const { articleId } = params;
return <ArticleEvidencePage articleId={articleId} />;
}
@@ -50,6 +50,9 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
<li>
<Link href={`/articles/${summary.articleId}/research`}>Research</Link>
</li>
<li>
<Link href={`/articles/${summary.articleId}/evidence`}>Evidence matrix</Link>
</li>
</ul>
<h3>Workflow timeline</h3>
@@ -0,0 +1,67 @@
import type { ClaimSummary, EvidenceSummary } from "@pipeline/shared";
export type EvidenceMatrixRow = {
id: string;
sourceTitle: string;
sourceUrl: string;
sourceType: string;
summary: string;
qualityScore: number;
retrievedAt: string;
manifestId: string;
reviewStatus: string;
};
export type ClaimReviewRow = {
id: string;
sectionId: string | null;
claimText: string;
supportStatus: string;
riskLevel: string;
evidenceCount: number;
isUnsupported: boolean;
};
export function buildEvidenceRows(
evidence: readonly EvidenceSummary[],
): EvidenceMatrixRow[] {
return evidence.map((item) => ({
id: item.id,
sourceTitle: item.source_title,
sourceUrl: item.source_url,
sourceType: item.source_type,
summary: item.summary,
qualityScore: item.source_quality_score,
retrievedAt: item.retrieved_at,
manifestId: item.artifact_manifest_id,
reviewStatus: item.review_status ?? "PENDING",
}));
}
export function buildClaimRows(claims: readonly ClaimSummary[]): ClaimReviewRow[] {
return claims.map((claim) => ({
id: claim.id,
sectionId: claim.section_id ?? null,
claimText: claim.claim_text,
supportStatus: claim.support_status,
riskLevel: claim.risk_level,
evidenceCount: claim.evidence_item_ids?.length ?? 0,
isUnsupported: claim.support_status === "UNSUPPORTED",
}));
}
export function filterUnsupportedClaims(
rows: readonly ClaimReviewRow[],
): ClaimReviewRow[] {
return rows.filter((row) => row.isUnsupported);
}
export function filterClaimsBySection(
rows: readonly ClaimReviewRow[],
sectionId: string | null,
): ClaimReviewRow[] {
if (!sectionId) {
return [...rows];
}
return rows.filter((row) => row.sectionId === sectionId);
}
@@ -0,0 +1,247 @@
"use client";
import { useMemo, useState } from "react";
import type { ClaimSummary, EvidenceSummary } from "@pipeline/shared";
import { addEvidence, ApiError, removeEvidence, updateEvidence } from "@/shared/pipeline-api";
import {
buildClaimRows,
buildEvidenceRows,
filterClaimsBySection,
filterUnsupportedClaims,
} from "./model";
type EvidenceMatrixPanelProps = {
articleId: string;
evidence: readonly EvidenceSummary[];
claims: readonly ClaimSummary[];
insufficientReasons: readonly string[];
};
export function EvidenceMatrixPanel({
articleId,
evidence,
claims,
insufficientReasons,
}: EvidenceMatrixPanelProps) {
const [message, setMessage] = useState("");
const [statusById, setStatusById] = useState<Record<string, string>>({});
const [rows, setRows] = useState(() => buildEvidenceRows(evidence));
const [isSubmittingEvidence, setIsSubmittingEvidence] = useState(false);
const [manualEvidence, setManualEvidence] = useState({
sourceTitle: "",
sourceUrl: "",
sourceType: "manual",
summary: "",
});
const [onlyUnsupported, setOnlyUnsupported] = useState(false);
const [sectionFilter, setSectionFilter] = useState("");
const evidenceRows = rows.map((row) => ({
...row,
reviewStatus: statusById[row.id] ?? row.reviewStatus,
}));
const claimRows = useMemo(() => {
const baseRows = buildClaimRows(claims);
const sectionRows = filterClaimsBySection(baseRows, sectionFilter || null);
return onlyUnsupported ? filterUnsupportedClaims(sectionRows) : sectionRows;
}, [claims, onlyUnsupported, sectionFilter]);
const sectionIds = Array.from(
new Set(claims.map((claim) => claim.section_id).filter(Boolean)),
) as string[];
async function setEvidenceStatus(evidenceId: string, reviewStatus: string) {
setMessage("");
try {
const response = await updateEvidence(articleId, evidenceId, {
review_status: reviewStatus,
});
setStatusById((current) => ({
...current,
[evidenceId]: response.evidence.review_status ?? "PENDING",
}));
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Unable to update evidence");
}
}
async function onAddEvidence() {
if (
!manualEvidence.sourceTitle.trim()
|| !manualEvidence.sourceUrl.trim()
|| !manualEvidence.summary.trim()
) {
setMessage("Manual evidence title, URL, and summary are required");
return;
}
setMessage("");
setIsSubmittingEvidence(true);
try {
const response = await addEvidence(articleId, {
source_title: manualEvidence.sourceTitle.trim(),
source_url: manualEvidence.sourceUrl.trim(),
source_type: manualEvidence.sourceType.trim() || "manual",
summary: manualEvidence.summary.trim(),
source_quality_score: 0.7,
});
const created = buildEvidenceRows([response.evidence])[0];
setRows((current) => [...current, created]);
setManualEvidence({
sourceTitle: "",
sourceUrl: "",
sourceType: "manual",
summary: "",
});
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Unable to add evidence");
} finally {
setIsSubmittingEvidence(false);
}
}
async function onRemoveEvidence(evidenceId: string) {
setMessage("");
try {
await removeEvidence(articleId, evidenceId);
setRows((current) => current.filter((item) => item.id !== evidenceId));
setStatusById((current) => {
const next = { ...current };
delete next[evidenceId];
return next;
});
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Unable to remove evidence");
}
}
return (
<section>
<div className="sectionHeader">
<div>
<h2>Evidence matrix</h2>
<p>{claimRows.length} claims</p>
</div>
<label>
<input
type="checkbox"
checked={onlyUnsupported}
onChange={(event) => setOnlyUnsupported(event.target.checked)}
/>
Unsupported only
</label>
</div>
<select
value={sectionFilter}
onChange={(event) => setSectionFilter(event.target.value)}
>
<option value="">All sections</option>
{sectionIds.map((sectionId) => (
<option value={sectionId} key={sectionId}>
{sectionId}
</option>
))}
</select>
{message ? <p className="formError">Error: {message}</p> : null}
<div className="detailGrid">
<input
type="text"
value={manualEvidence.sourceTitle}
placeholder="Manual source title"
onChange={(event) =>
setManualEvidence((current) => ({ ...current, sourceTitle: event.target.value }))
}
/>
<input
type="url"
value={manualEvidence.sourceUrl}
placeholder="https://source.example"
onChange={(event) =>
setManualEvidence((current) => ({ ...current, sourceUrl: event.target.value }))
}
/>
<input
type="text"
value={manualEvidence.sourceType}
placeholder="manual"
onChange={(event) =>
setManualEvidence((current) => ({ ...current, sourceType: event.target.value }))
}
/>
<input
type="text"
value={manualEvidence.summary}
placeholder="Manual evidence summary"
onChange={(event) =>
setManualEvidence((current) => ({ ...current, summary: event.target.value }))
}
/>
<button type="button" disabled={isSubmittingEvidence} onClick={onAddEvidence}>
{isSubmittingEvidence ? "Adding..." : "Add evidence"}
</button>
</div>
{insufficientReasons.length > 0 ? (
<ul className="timeline">
{insufficientReasons.map((reason) => (
<li key={reason}>{reason}</li>
))}
</ul>
) : null}
<table className="table">
<thead>
<tr>
<th>Claim</th>
<th>Status</th>
<th>Risk</th>
<th>Evidence</th>
</tr>
</thead>
<tbody>
{claimRows.map((claim) => (
<tr key={claim.id}>
<td>{claim.claimText}</td>
<td>{claim.supportStatus}</td>
<td>{claim.riskLevel}</td>
<td>{claim.evidenceCount}</td>
</tr>
))}
</tbody>
</table>
<table className="table">
<thead>
<tr>
<th>Source</th>
<th>Summary</th>
<th>Quality</th>
<th>Review</th>
</tr>
</thead>
<tbody>
{evidenceRows.map((item) => (
<tr key={item.id}>
<td>
<div>{item.sourceTitle}</div>
<a href={item.sourceUrl}>{item.sourceUrl}</a>
<div>{item.sourceType}</div>
</td>
<td>{item.summary}</td>
<td>{item.qualityScore}</td>
<td>
<div>{item.reviewStatus}</div>
<button type="button" onClick={() => setEvidenceStatus(item.id, "APPROVED")}>
Approve
</button>
<button type="button" onClick={() => setEvidenceStatus(item.id, "REJECTED")}>
Reject
</button>
<button type="button" onClick={() => onRemoveEvidence(item.id)}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
@@ -0,0 +1,36 @@
import Link from "next/link";
import { EvidenceMatrixPanel } from "@/features/evidence-matrix/ui";
import { fetchEvidence } from "@/shared/pipeline-api";
import { RoleNavigation } from "@/widgets/role-navigation";
type ArticleEvidencePageProps = {
articleId: string;
};
export default async function ArticleEvidencePage({
articleId,
}: ArticleEvidencePageProps) {
const response = await fetchEvidence(articleId);
return (
<main>
<header className="pageHeader">
<div>
<h1>Evidence matrix</h1>
<p>{response.claims?.length ?? 0} claims</p>
</div>
<Link href={`/articles/${articleId}`}>Back to article</Link>
</header>
<RoleNavigation role="EDITOR" />
<section className="panel">
<EvidenceMatrixPanel
articleId={articleId}
evidence={response.evidence ?? []}
claims={response.claims ?? []}
insufficientReasons={response.insufficient_evidence_reasons ?? []}
/>
</section>
</main>
);
}
+40
View File
@@ -10,6 +10,10 @@ import type {
BoundaryQuestionResponse,
BoundaryQuestionUpdateRequest,
CurrentUserResponse,
EvidenceMatrixResponse,
EvidenceResponse,
EvidenceCreateRequest,
EvidenceUpdateRequest,
PlanListResponse,
PlanResponse,
PlanRevisionRequest,
@@ -386,3 +390,39 @@ export function startResearch(articleId: string): Promise<ResearchStartResponse>
export function fetchResearch(articleId: string): Promise<ResearchListResponse> {
return apiGet<ResearchListResponse>(`/api/articles/${articleId}/research`);
}
export function fetchEvidence(articleId: string): Promise<EvidenceMatrixResponse> {
return apiGet<EvidenceMatrixResponse>(`/api/articles/${articleId}/evidence`);
}
export function updateEvidence(
articleId: string,
evidenceId: string,
request: EvidenceUpdateRequest,
): Promise<EvidenceResponse> {
return apiPatch<EvidenceUpdateRequest, EvidenceResponse>(
`/api/articles/${articleId}/evidence/${evidenceId}`,
request,
);
}
export function addEvidence(
articleId: string,
request: EvidenceCreateRequest,
): Promise<EvidenceResponse> {
return apiPost<EvidenceCreateRequest, EvidenceResponse>(
`/api/articles/${articleId}/evidence`,
request,
);
}
export function removeEvidence(
articleId: string,
evidenceId: string,
): Promise<void> {
return requestJson<void>(
`/api/articles/${articleId}/evidence/${evidenceId}`,
{ method: "DELETE" },
DEMO_EDITOR_EMAIL,
);
}
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const testDir = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(testDir, "../src/features/evidence-matrix/model.ts");
const source = readFileSync(sourcePath, "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
});
const moduleExports = {};
new Function("exports", compiled.outputText)(moduleExports);
const {
buildEvidenceRows,
buildClaimRows,
filterUnsupportedClaims,
filterClaimsBySection,
} = moduleExports;
const evidenceRows = buildEvidenceRows([
{
id: "e1",
article_id: "a1",
source_title: "Source",
source_url: "https://example.com",
source_type: "primary",
source_quality_score: 0.9,
summary: "Summary",
supports_claims: [],
artifact_manifest_id: "m1",
retrieved_at: "2026-05-21T00:00:00Z",
review_status: "APPROVED",
},
]);
assert.equal(evidenceRows[0].sourceTitle, "Source");
assert.equal(evidenceRows[0].reviewStatus, "APPROVED");
const claimRows = buildClaimRows([
{
id: "c1",
article_id: "a1",
section_id: "s1",
claim_text: "Unsupported",
support_status: "UNSUPPORTED",
risk_level: "high",
evidence_item_ids: [],
},
{
id: "c2",
article_id: "a1",
section_id: "s2",
claim_text: "Supported",
support_status: "SUPPORTED",
risk_level: "low",
evidence_item_ids: ["e1"],
},
]);
assert.equal(filterUnsupportedClaims(claimRows).length, 1);
assert.equal(filterClaimsBySection(claimRows, "s2")[0].claimText, "Supported");