W5: claims review queue

components-claims: ClaimsReviewQueue (proposed → conflicts → unreviewed per
claims-spec §2.3), ClaimCard (confirm/reject/reopen tombstone), ConflictPair
(both sides raw, three resolutions: keep-with-supersede, dismiss-both,
reject-newcomer; human_confirmed can't be rejected). Tiers raw, no scores.
Demo Review tab wired. Typechecks clean.

Co-Authored-By: Claude Fable 5 (build) <noreply@anthropic.com>
This commit is contained in:
jules
2026-07-07 10:03:54 +10:00
parent 9dd162d815
commit 40d2f94795
2 changed files with 368 additions and 12 deletions

View File

@@ -4,11 +4,13 @@
// ===========================================================================
import { useEffect, useState } from "react";
import {
ClaimsReviewQueue,
CollectionForm,
CollectionList,
ObjectList,
ObjectViewer,
MockKnowledgeTransport,
type Claim,
type Collection,
type CollectionInput,
type CollectionPatch,
@@ -25,11 +27,14 @@ type View =
| { kind: "object"; id: string };
export default function KnowledgeDemo() {
const [tab, setTab] = useState<"browse" | "review">("browse");
const [view, setView] = useState<View>({ kind: "collections" });
const [collections, setCollections] = useState<Collection[]>([]);
const [pending, setPending] = useState<Record<string, number>>({});
const [objects, setObjects] = useState<ObjectSummary[]>([]);
const [outline, setOutline] = useState<ObjectOutline | null>(null);
const [claims, setClaims] = useState<Claim[]>([]);
const [busyId, setBusyId] = useState<string | undefined>();
const [creating, setCreating] = useState<null | "account" | "tenant">(null);
const [editing, setEditing] = useState<Collection | null>(null);
const [busy, setBusy] = useState(false);
@@ -38,11 +43,19 @@ export default function KnowledgeDemo() {
const page = await transport.listCollections();
setCollections(page.collections);
const queue = await transport.reviewQueue();
setClaims(queue);
const counts: Record<string, number> = {};
for (const c of queue) counts[c.collection] = (counts[c.collection] ?? 0) + 1;
setPending(counts);
}
async function afterClaim(id: string, fn: () => Promise<unknown>) {
setBusyId(id);
await fn();
setBusyId(undefined);
void refresh();
}
useEffect(() => {
void refresh();
}, []);
@@ -68,17 +81,43 @@ export default function KnowledgeDemo() {
void refresh();
}
const reviewCount = claims.length;
return (
<div className="mx-auto max-w-5xl p-6">
<button
type="button"
onClick={() => setView({ kind: "collections" })}
className="mb-4 text-sm text-muted-foreground hover:text-foreground"
>
Knowledge{view.kind !== "collections" ? " / …" : ""}
</button>
<div className="mb-4 flex items-center gap-2">
<TabBtn active={tab === "browse"} onClick={() => setTab("browse")}>Browse</TabBtn>
<TabBtn active={tab === "review"} onClick={() => setTab("review")}>
Review{reviewCount > 0 ? ` (${reviewCount})` : ""}
</TabBtn>
</div>
{view.kind === "collections" && (
{tab === "review" && (
<ClaimsReviewQueue
claims={claims}
busyId={busyId}
onConfirm={(id) => afterClaim(id, () => transport.confirmClaim(id))}
onReject={(id) => afterClaim(id, () => transport.rejectClaim(id))}
onKeep={(winner, supersede) => afterClaim(winner, () => transport.confirmClaim(winner, { supersede }))}
onDismissConflict={(id) => afterClaim(id, () => transport.dismissConflict(id))}
onOpenSource={(objectId) => {
setTab("browse");
setView({ kind: "object", id: objectId });
}}
/>
)}
{tab === "browse" && view.kind !== "collections" && (
<button
type="button"
onClick={() => setView({ kind: "collections" })}
className="mb-4 text-sm text-muted-foreground hover:text-foreground"
>
All corpuses
</button>
)}
{tab === "browse" && view.kind === "collections" && (
<>
{creating && (
<div className="mb-6 rounded-xl border border-border bg-card p-4">
@@ -103,7 +142,7 @@ export default function KnowledgeDemo() {
</>
)}
{view.kind === "collection" && (
{tab === "browse" && view.kind === "collection" && (
<>
<div className="mb-3 flex items-center justify-between">
<h1 className="text-lg font-semibold">{view.slug}</h1>
@@ -113,7 +152,7 @@ export default function KnowledgeDemo() {
</>
)}
{view.kind === "object" && outline && (
{tab === "browse" && view.kind === "object" && outline && (
<ObjectViewer
outline={outline}
readText={(sel) => transport.readText(outline.object_id, sel)}
@@ -128,3 +167,18 @@ export default function KnowledgeDemo() {
</div>
);
}
function TabBtn({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className={
"rounded-lg px-3 py-1.5 text-sm font-medium transition " +
(active ? "bg-primary/10 text-primary" : "text-muted-foreground hover:bg-muted")
}
>
{children}
</button>
);
}

View File

@@ -1,2 +1,304 @@
// Placeholder — filled in its workstream (W4/W5/W6).
export {};
// PURPOSE: Claims review queue (spec §3.4, claims-spec §2.3) — proposed claims,
// open conflicts (both sides raw, three human resolutions), and
// unreviewed claims. Tiers surfaced raw, never scored. Tombstone re-open
// via the general claims list. Props in, callbacks out.
// ===========================================================================
import { type FC, type ReactNode } from "react";
import { AlertTriangle, ArrowUpRight, Check, RotateCcw, Sparkles, X } from "lucide-react";
import type { Claim } from "./types";
import { Badge, TierBadge, cn, formatDate, renderValue } from "./_internal";
// ---- ClaimCard ------------------------------------------------------------
export interface ClaimActions {
onConfirm?: (id: string) => void;
onReject?: (id: string) => void;
onReopen?: (id: string) => void;
onOpenSource?: (objectId: string, span?: { start: number; end: number }) => void;
}
export interface ClaimCardProps extends ClaimActions {
claim: Claim;
busy?: boolean;
className?: string;
}
export const ClaimCard: FC<ClaimCardProps> = ({ claim, busy, className, onConfirm, onReject, onReopen, onOpenSource }) => {
const rejected = claim.review_state === "rejected";
return (
<div className={cn("rounded-xl border border-border bg-card p-4", className)}>
<ClaimHead claim={claim} onOpenSource={onOpenSource} />
<div className="mt-3 flex flex-wrap items-center gap-2">
{rejected ? (
<>
<Badge tone="bg-muted text-muted-foreground">Rejected {formatDate(claim.updated_at)}</Badge>
{onReopen && (
<ActionBtn action="knowledge-claim-reopen" onClick={() => onReopen(claim.claim_id)} busy={busy} icon={<RotateCcw className="size-3.5" />}>
Re-open
</ActionBtn>
)}
</>
) : (
<>
{onConfirm && (
<ActionBtn action="knowledge-claim-confirm" primary onClick={() => onConfirm(claim.claim_id)} busy={busy} icon={<Check className="size-3.5" />}>
{claim.status === "proposed" ? "Confirm" : "Mark reviewed"}
</ActionBtn>
)}
{onReject && (
<ActionBtn action="knowledge-claim-reject" onClick={() => onReject(claim.claim_id)} busy={busy} icon={<X className="size-3.5" />}>
Reject
</ActionBtn>
)}
</>
)}
</div>
</div>
);
};
const ClaimHead: FC<{ claim: Claim; onOpenSource?: ClaimActions["onOpenSource"] }> = ({ claim, onOpenSource }) => (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="font-mono text-xs text-muted-foreground">{claim.subject}</span>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-sm font-medium text-foreground">{claim.predicate}</span>
</div>
<p className="mt-1 text-sm text-foreground">{renderValue(claim.value)}</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<TierBadge tier={claim.assertion_tier} />
{claim.effective_at && <span className="text-xs text-muted-foreground">effective {formatDate(claim.effective_at)}</span>}
{claim.source?.object_id && (
<button
type="button"
data-action="knowledge-claim-source"
onClick={() => onOpenSource?.(claim.source!.object_id, claim.source!.span)}
className="inline-flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-xs text-muted-foreground transition hover:text-foreground"
title="Open the source passage"
>
source <ArrowUpRight className="size-3" />
</button>
)}
</div>
</div>
</div>
);
// ---- ConflictPair ---------------------------------------------------------
export interface ConflictPairProps {
/** All claims in the conflict group (same subject+predicate, differing values). */
claims: Claim[];
busy?: boolean;
/** Confirm `winnerId` and supersede the rest (confirm-with-supersede). */
onKeep?: (winnerId: string, supersedeIds: string[]) => void;
/** Both true — dismiss the conflict (multi-valued predicate). */
onDismiss?: (id: string) => void;
onReject?: (id: string) => void;
onOpenSource?: ClaimActions["onOpenSource"];
className?: string;
}
export const ConflictPair: FC<ConflictPairProps> = ({ claims, busy, onKeep, onDismiss, onReject, onOpenSource, className }) => {
const values = claims.map((c) => renderValue(c.value));
return (
<div className={cn("rounded-xl border border-[var(--warning)]/40 bg-[color-mix(in_oklab,var(--warning)_6%,transparent)] p-4", className)}>
<div className="mb-3 flex items-center gap-2 text-sm">
<AlertTriangle className="size-4 text-[var(--warning)]" />
<span className="font-medium text-foreground">These disagree</span>
<span className="font-mono text-xs text-muted-foreground">
{claims[0]?.subject} · {claims[0]?.predicate}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{claims.map((c, i) => (
<div key={c.claim_id} className="flex flex-col gap-2 rounded-lg border border-border bg-card p-3">
<p className={cn("text-sm font-medium", values.filter((v) => v === values[i]).length === 1 && "text-[var(--warning)]")}>
{values[i]}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<TierBadge tier={c.assertion_tier} />
{c.effective_at && <span className="text-xs text-muted-foreground">effective {formatDate(c.effective_at)}</span>}
{c.source?.object_id && (
<button
type="button"
onClick={() => onOpenSource?.(c.source!.object_id, c.source!.span)}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
source <ArrowUpRight className="size-3" />
</button>
)}
</div>
<div className="mt-1 flex flex-wrap gap-1.5">
{onKeep && (
<ActionBtn
action="knowledge-conflict-keep"
primary
busy={busy}
onClick={() => onKeep(c.claim_id, claims.filter((o) => o.claim_id !== c.claim_id).map((o) => o.claim_id))}
icon={<Check className="size-3.5" />}
>
Keep this
</ActionBtn>
)}
{onReject && c.assertion_tier !== "human_confirmed" && (
<ActionBtn action="knowledge-conflict-reject" busy={busy} onClick={() => onReject(c.claim_id)} icon={<X className="size-3.5" />}>
Reject
</ActionBtn>
)}
</div>
</div>
))}
</div>
{onDismiss && (
<button
type="button"
data-action="knowledge-conflict-dismiss"
disabled={busy}
onClick={() => onDismiss(claims[0].claim_id)}
className="mt-3 text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:opacity-50"
>
They're both true — keep both
</button>
)}
</div>
);
};
// ---- ClaimsReviewQueue ----------------------------------------------------
export interface ClaimsReviewQueueProps {
claims: Claim[];
busyId?: string;
onConfirm?: (id: string) => void;
onReject?: (id: string) => void;
onReopen?: (id: string) => void;
/** Confirm winner + supersede the rest of a conflict group. */
onKeep?: (winnerId: string, supersedeIds: string[]) => void;
onDismissConflict?: (id: string) => void;
onOpenSource?: ClaimActions["onOpenSource"];
emptyState?: ReactNode;
className?: string;
}
/** Groups: proposed → open conflicts → unreviewed (claims-spec §2.3 order). */
export const ClaimsReviewQueue: FC<ClaimsReviewQueueProps> = ({
claims,
busyId,
onConfirm,
onReject,
onReopen,
onKeep,
onDismissConflict,
onOpenSource,
emptyState,
className,
}) => {
const byId = new Map(claims.map((c) => [c.claim_id, c]));
const conflictGroups = groupConflicts(claims, byId);
const inConflict = new Set(conflictGroups.flat().map((c) => c.claim_id));
const proposed = claims.filter((c) => c.status === "proposed" && !inConflict.has(c.claim_id));
const unreviewed = claims.filter(
(c) => c.status === "active" && c.review_state === "unreviewed" && !inConflict.has(c.claim_id),
);
if (claims.length === 0) {
return <>{emptyState ?? <Empty />}</>;
}
return (
<div className={cn("flex flex-col gap-6", className)}>
<Section title="Proposed" count={proposed.length} hint="An agent noted these — confirm or reject.">
{proposed.map((c) => (
<ClaimCard key={c.claim_id} claim={c} busy={busyId === c.claim_id} onConfirm={onConfirm} onReject={onReject} onOpenSource={onOpenSource} />
))}
</Section>
<Section title="Conflicts" count={conflictGroups.length} hint="Two facts disagree — you decide.">
{conflictGroups.map((group) => (
<ConflictPair
key={group.map((c) => c.claim_id).join("+")}
claims={group}
busy={group.some((c) => busyId === c.claim_id)}
onKeep={onKeep}
onDismiss={onDismissConflict}
onReject={onReject}
onOpenSource={onOpenSource}
/>
))}
</Section>
<Section title="Awaiting review" count={unreviewed.length} hint="Visible already, labelled by tier — review at your pace.">
{unreviewed.map((c) => (
<ClaimCard key={c.claim_id} claim={c} busy={busyId === c.claim_id} onConfirm={onConfirm} onReject={onReject} onReopen={onReopen} onOpenSource={onOpenSource} />
))}
</Section>
</div>
);
};
function groupConflicts(claims: Claim[], byId: Map<string, Claim>): Claim[][] {
const seen = new Set<string>();
const groups: Claim[][] = [];
for (const c of claims) {
if (c.conflict_state !== "open" || seen.has(c.claim_id)) continue;
const group: Claim[] = [c];
seen.add(c.claim_id);
for (const otherId of c.conflicts_with ?? []) {
const other = byId.get(otherId);
if (other && !seen.has(otherId)) {
group.push(other);
seen.add(otherId);
}
}
if (group.length > 1) groups.push(group);
}
return groups;
}
const Section: FC<{ title: string; count: number; hint: string; children: ReactNode }> = ({ title, count, hint, children }) => {
if (count === 0) return null;
return (
<section>
<div className="mb-2 flex items-baseline gap-2">
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
<span className="text-xs text-muted-foreground">({count})</span>
<span className="text-xs text-muted-foreground">— {hint}</span>
</div>
<div className="flex flex-col gap-3">{children}</div>
</section>
);
};
const Empty: FC = () => (
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border py-12 text-center">
<Sparkles className="size-6 text-muted-foreground" />
<p className="text-sm font-medium text-foreground">Nothing to review</p>
<p className="text-xs text-muted-foreground">Your agents' claims will appear here when there's something to confirm.</p>
</div>
);
const ActionBtn: FC<{
action: string;
onClick: () => void;
icon: ReactNode;
children: ReactNode;
primary?: boolean;
busy?: boolean;
}> = ({ action, onClick, icon, children, primary, busy }) => (
<button
type="button"
data-action={action}
disabled={busy}
onClick={onClick}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition disabled:opacity-50",
primary ? "bg-primary text-primary-foreground hover:opacity-90" : "border border-border text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{icon}
{children}
</button>
);