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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user