Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions apps/web/src/knowledge/KnowledgeStore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Alert } from "@protolabsai/ui/data";
import { ConfirmDialog, Dialog, useToast } from "@protolabsai/ui/overlays";
import { Badge, Button, Empty } from "@protolabsai/ui/primitives";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowDownFromLine, ArrowUpToLine, ChevronRight, ChevronsDownUp, ChevronsUpDown, Database, FileUp, Library, Pencil, Plus, Trash2 } from "lucide-react";
import { ArrowDownFromLine, ArrowUpToLine, ChevronRight, ChevronsDownUp, ChevronsUpDown, ClipboardCheck, Database, FileUp, Library, Pencil, Plus, Trash2 } from "lucide-react";

import { useEffect, useState } from "react";

Expand All @@ -15,6 +15,8 @@ import { knowledgeQuery, queryKeys } from "../lib/queries";
import { QuickSetting } from "../settings/QuickSetting";
import type { KnowledgeChunk } from "../lib/types";

import { ReviewActions, ReviewChip } from "./ReviewVerdict";

// The shape every knowledge list/search query caches — reused for optimistic
// bulk-delete cache surgery (#1770) without re-declaring the response fields.
type KnowledgeSearchData = Awaited<ReturnType<typeof api.knowledgeSearch>>;
Expand Down Expand Up @@ -313,8 +315,13 @@ export function KnowledgeStore() {
return () => window.clearTimeout(t);
}, [query]);

// "Pending review" queue (ADR 0108 D7): narrow the list to rows awaiting an operator
// verdict. Its own cache entry (the query key carries the filter), so toggling never
// relabels the unfiltered list.
const [pendingOnly, setPendingOnly] = useState(false);

const { data, isFetching, error, refetch } = useQuery({
...knowledgeQuery(debouncedQuery),
...knowledgeQuery(debouncedQuery, pendingOnly ? "pending" : undefined),
placeholderData: keepPreviousData,
});
const enabled = data?.enabled ?? true;
Expand Down Expand Up @@ -517,6 +524,7 @@ export function KnowledgeStore() {
<Badge status="neutral">private</Badge>
</span>
) : null}
<ReviewChip chunk={c} />
{c.heading ? <strong>{c.heading}</strong> : null}
</div>
<p className="playbook-desc">{c.content || c.preview}</p>
Expand All @@ -529,6 +537,8 @@ export function KnowledgeStore() {
<div className="playbook-meta">
<span title="added">{ago(c.created_at)}</span>
<span className="knowledge-chunk-actions">
{/* Review verdict (ADR 0108 D7) — nothing for commons rows (read-only here). */}
<ReviewActions chunk={c} />
{c.tier === "private" ? (
<Button
icon
Expand Down Expand Up @@ -584,13 +594,24 @@ export function KnowledgeStore() {
<section className="panel stage-panel" data-testid="knowledge-store">
<PanelHeader
title="Knowledge"
kicker={`searchable knowledge base${total ? ` · ${total} entr${total === 1 ? "y" : "ies"}` : ""}${stats.commons ? ` · ${stats.commons} shared` : ""}`}
kicker={`searchable knowledge base${total ? ` · ${total} entr${total === 1 ? "y" : "ies"}` : ""}${stats.commons ? ` · ${stats.commons} shared` : ""}${pendingOnly ? " · pending review" : ""}`}
actions={
<>
{/* Quick-set recall behaviour right where you inspect what the agent knows (ADR 0048). */}
<QuickSetting keys={["knowledge.top_k", "knowledge.embeddings"]} title="Recall" label="Knowledge recall settings" />
{enabled ? (
<>
<Button
icon
variant={pendingOnly ? "primary" : "ghost"}
type="button"
onClick={() => setPendingOnly((v) => !v)}
title={pendingOnly ? "Show every entry" : "Show only entries awaiting review"}
aria-label="pending review filter"
aria-pressed={pendingOnly}
>
<ClipboardCheck size={16} />
</Button>
{groupSources.length > 0 ? (
<Button
icon
Expand Down Expand Up @@ -682,7 +703,12 @@ export function KnowledgeStore() {
The knowledge store is off (enable <code>middleware.knowledge</code>).
</Empty>
) : results.length === 0 ? (
query.trim() ? (
pendingOnly ? (
<Empty
title="Nothing awaiting review"
description="agent-written memories start pending; confirm or reject them here as they arrive."
/>
) : query.trim() ? (
<Empty>No entries match your search.</Empty>
) : (
<Empty
Expand Down
203 changes: 203 additions & 0 deletions apps/web/src/knowledge/ReviewVerdict.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { useToast } from "@protolabsai/ui/overlays";
import { Badge, Button } from "@protolabsai/ui/primitives";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Check, RotateCcw, X } from "lucide-react";
import { useState } from "react";

import { api } from "../lib/api";
import { errMsg } from "../lib/format";
import { queryKeys } from "../lib/queries";
import type { KnowledgeChunk, ReviewState } from "../lib/types";

// Operator review verdicts (ADR 0108 D7) — the console half of the memory write
// lifecycle. Agent-derived memories land as `pending`; the operator confirms or
// rejects them here, on the Knowledge → Store rows and the Memory → Hot memory rows.
// A rejected row stays in the store for audit but leaves delivery (D6); re-open puts
// it back to pending. Backend: POST /api/memory/chunks/{id}/review.
//
// Two rules the route relies on the CLIENT for (memory_routes.py):
// - a commons row is read-only here (its id would collide with a private row's),
// so commons rows get the chip and NO actions, and every request carries the
// row's `tier` so the server can refuse a commons id it wasn't told about;
// - an ADR 0031 backend without verdicts answers 501 once — after that the actions
// hide everywhere (module-level, so sibling rows learn it on their next render).

const REVIEW_STATES: readonly ReviewState[] = ["confirmed", "pending", "rejected"];

/** The row's verdict as the typed enum, or null when the backend sent nothing / an
* unknown value (pre-D7 rows, custom backends) — the chip is drawn only for a value. */
export function reviewStateOf(chunk: Pick<KnowledgeChunk, "review_state">): ReviewState | null {
const raw = chunk.review_state;
if (typeof raw !== "string") return null;
const v = raw.trim().toLowerCase();
return (REVIEW_STATES as readonly string[]).includes(v) ? (v as ReviewState) : null;
}

let reviewUnsupported = false;

/** Test seam: forget a 501 learned by an earlier render. */
export function resetReviewSupport(): void {
reviewUnsupported = false;
}

const CHIP: Record<ReviewState, { status: "info" | "success" | "warning"; label: string; title: string }> = {
pending: {
status: "info",
label: "pending review",
title: "Written by the agent and not yet reviewed — it still delivers; confirm to trust it, reject to stop delivering it.",
},
confirmed: {
status: "success",
label: "confirmed",
title: "Confirmed by an operator — trusted context.",
},
rejected: {
status: "warning",
label: "rejected",
title: "Rejected — kept for audit, no longer delivered to the agent. Re-open to review it again.",
},
};

export function ReviewChip({ chunk }: { chunk: Pick<KnowledgeChunk, "review_state"> }) {
const state = reviewStateOf(chunk);
if (!state) return null;
const chip = CHIP[state];
return (
<span title={chip.title} data-review-state={state}>
<Badge status={chip.status}>{chip.label}</Badge>
</span>
);
}

type ReviewVars = { chunk: KnowledgeChunk; state: ReviewState };
type KnowledgeListData = { results?: KnowledgeChunk[] };
type HotListData = { chunks?: KnowledgeChunk[] };

function restamp<T extends KnowledgeChunk>(rows: T[] | undefined, id: number, state: ReviewState): T[] | undefined {
return rows?.map((row) => (row.id === id ? { ...row, review_state: state } : row));
}

/** The verdict mutation: optimistic chip flip across every cached knowledge / hot-memory
* list, rollback + verbatim server `detail` on failure, one toast per outcome. */
export function useReviewVerdict() {
const qc = useQueryClient();
const toast = useToast();
const [unsupported, setUnsupported] = useState(() => reviewUnsupported);

const mutation = useMutation({
mutationFn: ({ chunk, state }: ReviewVars) =>
api.reviewMemoryChunk(chunk.id, chunk.tier ? { state, tier: chunk.tier } : { state }),
onMutate: async ({ chunk, state }) => {
await qc.cancelQueries({ queryKey: queryKeys.knowledge });
await qc.cancelQueries({ queryKey: queryKeys.memoryHot });
const prevKnowledge = qc.getQueriesData<KnowledgeListData>({ queryKey: queryKeys.knowledge });
const prevHot = qc.getQueriesData<HotListData>({ queryKey: queryKeys.memoryHot });
qc.setQueriesData<KnowledgeListData>({ queryKey: queryKeys.knowledge }, (old) =>
old ? { ...old, results: restamp(old.results, chunk.id, state) } : old,
);
qc.setQueriesData<HotListData>({ queryKey: queryKeys.memoryHot }, (old) =>
old ? { ...old, chunks: restamp(old.chunks, chunk.id, state) } : old,
);
return { prevKnowledge, prevHot };
},
onSuccess: (r, { state }, ctx) => {
if (r.enabled === false) {
rollback(ctx);
toast({
tone: "error",
title: "Memory",
message: "The knowledge store is off — the verdict was not saved (enable middleware.knowledge).",
});
return;
}
toast({ tone: "success", title: "Memory", message: `Marked ${r.review_state ?? state}.` });
void qc.invalidateQueries({ queryKey: queryKeys.knowledge });
void qc.invalidateQueries({ queryKey: queryKeys.memoryHot });
},
onError: (e, _vars, ctx) => {
rollback(ctx);
const status = (e as { status?: number }).status;
if (status === 501) {
// An ADR 0031 backend without verdicts — learn it once, hide the actions.
reviewUnsupported = true;
setUnsupported(true);
toast({
tone: "info",
title: "Memory",
message: "This knowledge backend doesn't support review verdicts.",
});
return;
}
// 400 (bad state / commons row) and 404 carry the server's own `detail` — show it verbatim.
toast({ tone: "error", title: "Memory", message: errMsg(e) });
},
});

function rollback(ctx: { prevKnowledge: [unknown, unknown][]; prevHot: [unknown, unknown][] } | undefined) {
for (const [key, data] of ctx?.prevKnowledge ?? []) qc.setQueryData(key as readonly unknown[], data);
for (const [key, data] of ctx?.prevHot ?? []) qc.setQueryData(key as readonly unknown[], data);
}

return {
setVerdict: (chunk: KnowledgeChunk, state: ReviewState) => mutation.mutate({ chunk, state }),
pendingId: mutation.isPending ? mutation.variables?.chunk.id : undefined,
unsupported,
};
}

/** Confirm / Reject / Re-open for one row. Nothing for commons rows (read-only here) or
* once the backend has said it has no verdicts. A row with no verdict from the backend
* is treated as pending (ADR 0108 D4: NULL reads as pending). */
export function ReviewActions({ chunk }: { chunk: KnowledgeChunk }) {
const { setVerdict, pendingId, unsupported } = useReviewVerdict();
if (unsupported || chunk.tier === "commons") return null;
const state = reviewStateOf(chunk) ?? "pending";
const busy = pendingId === chunk.id;
const confirm = (
<Button
icon
variant="ghost"
type="button"
title="Confirm — keep this memory as trusted context"
aria-label={`confirm entry ${chunk.id}`}
onClick={() => setVerdict(chunk, "confirmed")}
loading={busy}
>
<Check size={14} />
</Button>
);
const reject = (
<Button
icon
variant="ghost"
type="button"
title="Reject — keep the row for audit but stop delivering it to the agent"
aria-label={`reject entry ${chunk.id}`}
onClick={() => setVerdict(chunk, "rejected")}
loading={busy}
>
<X size={14} />
</Button>
);
const reopen = (
<Button
icon
variant="ghost"
type="button"
title="Re-open — back to pending review (delivers again)"
aria-label={`reopen entry ${chunk.id}`}
onClick={() => setVerdict(chunk, "pending")}
loading={busy}
>
<RotateCcw size={14} />
</Button>
);
if (state === "rejected") return reopen;
if (state === "confirmed") return reject;
return (
<>
{confirm}
{reject}
</>
);
}
Loading
Loading