feat: implement memory and evidence administration with guided repairs
Publish documentation / publish (push) Successful in 1m27s
Publish documentation / publish (push) Successful in 1m27s
Add PostgreSQL-backed memory, editable evidence with source review and activation, and human-approved archive repairs across the harness, API, and UI. Include migrations, deployment support, regression coverage, and validation documentation. Refresh permissions from validated session roles so existing administrator logins can access newly deployed archive management features.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ArchiveRepairWidget } from "./ArchiveRepairWidget";
|
||||
|
||||
const repair = { repair_id: "receipt", reason: "Conflicting order definitions", choice: null,
|
||||
status: "proposed", saved: false, indexed: false, can_apply: true, options: [
|
||||
{ id: "m", label: "Correct Memory", archive: "memory", target_id: "mem-1",
|
||||
before: { detail: "Before memory" }, content: { detail: "After memory" } },
|
||||
{ id: "e", label: "Correct Evidence", archive: "evidence", target_id: "evidence:order",
|
||||
before: { payload: { rule: "Before evidence" } }, content: { payload: { rule: "After evidence" } } },
|
||||
] };
|
||||
|
||||
test.each(["Memory", "Evidence"])("shows both contents and sends only the selected %s choice", async archive => {
|
||||
const respond = vi.fn();
|
||||
render(<ArchiveRepairWidget descriptor={{ id: "gate", widget: "archive-repair", repair }} onRespond={respond} />);
|
||||
expect(screen.getByText("Before memory")).toBeVisible();
|
||||
expect(screen.getByText("After memory")).toBeVisible();
|
||||
expect(screen.getByText(/After evidence/)).toBeVisible();
|
||||
const button = screen.getByRole("button", { name: `Apply this correction to ${archive}` });
|
||||
await userEvent.click(button);
|
||||
expect(respond).toHaveBeenCalledWith({ id: "gate", choices: [archive === "Memory" ? "m" : "e"] });
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
test("non-admin can reject inadequate proposals without an archive write option", async () => {
|
||||
const respond = vi.fn();
|
||||
render(<ArchiveRepairWidget descriptor={{ id: "gate", widget: "archive-repair",
|
||||
repair: { ...repair, can_apply: false } }} onRespond={respond} />);
|
||||
expect(screen.queryByRole("button", { name: /Apply this correction/ })).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: /None are adequate/ }));
|
||||
expect(respond).toHaveBeenCalledWith({ id: "gate", choices: ["reject"] });
|
||||
});
|
||||
|
||||
test("saved but inactive corrections keep the choice fixed and offer retry", async () => {
|
||||
const respond = vi.fn();
|
||||
render(<ArchiveRepairWidget descriptor={{ id: "retry", widget: "archive-repair",
|
||||
repair: { ...repair, choice: "e", saved: true, status: "pending_activation" } }} onRespond={respond} />);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Index activation is incomplete");
|
||||
expect(screen.queryByRole("button", { name: /None are adequate/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Continue with repair pending" })).toBeVisible();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Retry selected correction" }));
|
||||
expect(respond).toHaveBeenCalledWith({ id: "retry", choices: ["e"] });
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState } from "react";
|
||||
import type { WidgetProps } from "./types";
|
||||
import { ReservedControls } from "./ReservedControls";
|
||||
|
||||
interface RepairOption {
|
||||
id: string; label: string; archive: "memory" | "evidence"; target_id: string;
|
||||
before: Record<string, unknown>; content: Record<string, unknown>;
|
||||
}
|
||||
interface Repair {
|
||||
repair_id: string; reason: string; options: RepairOption[]; choice: string | null;
|
||||
status: string; saved: boolean; indexed: boolean; can_apply: boolean;
|
||||
}
|
||||
const statuses: Record<string, string> = {
|
||||
proposed: "Awaiting your decision. No archive changes have been saved.",
|
||||
applying: "Your choice is recorded. The file update needs recovery; activation is not confirmed.",
|
||||
pending_activation: "Correction saved. Index activation is incomplete; retry to make it available.",
|
||||
active: "Correction saved and active in the archive index.",
|
||||
rejected: "Proposals rejected. No archive changes were made.",
|
||||
superseded: "The archive changed after this correction. Review its current content before proposing another repair.",
|
||||
};
|
||||
const fieldLabels: Record<string, string> = {
|
||||
id: "Identity", subject: "Subject", title: "Title", detail: "Detail", scope: "Scope",
|
||||
rationale: "Rationale", question: "Question", sql: "SQL", concepts: "Concepts",
|
||||
dependencies: "Dependencies", links: "Links", family: "Family", kind: "Kind",
|
||||
language: "Language", purposes: "Purposes", applies_to: "Applies to", payload: "Content",
|
||||
provenance: "Source history", review_items: "Review items",
|
||||
};
|
||||
|
||||
function Value({ value }: { value: unknown }) {
|
||||
if (value == null) return null;
|
||||
if (Array.isArray(value)) return <ul className="space-y-1">{value.map((item, index) =>
|
||||
<li key={index}><Value value={item} /></li>)}</ul>;
|
||||
if (typeof value === "object") return <dl className="space-y-1">{Object.entries(value).map(([key, item]) =>
|
||||
<div key={key}><dt className="text-xs capitalize">{key.replaceAll("_", " ")}</dt>
|
||||
<dd className="pl-2"><Value value={item} /></dd></div>)}</dl>;
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
|
||||
function Content({ value }: { value: Record<string, unknown> }) {
|
||||
return <dl className="min-w-0 space-y-2 text-sm">{Object.entries(fieldLabels).map(([key, label]) => {
|
||||
const item = value[key];
|
||||
if (item == null || item === "" || (Array.isArray(item) && item.length === 0)) return null;
|
||||
return <div key={key}><dt className="font-medium">{label}</dt>
|
||||
<dd className="whitespace-pre-wrap break-words text-muted-foreground"><Value value={item} /></dd></div>;
|
||||
})}</dl>;
|
||||
}
|
||||
|
||||
export function ArchiveRepairWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
const repair = descriptor.repair as Repair;
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const send = (choice: string) => {
|
||||
if (submitted) return;
|
||||
setSubmitted(true);
|
||||
onRespond({ id: descriptor.id, choices: [choice] });
|
||||
};
|
||||
const pending = repair.status === "applying" || repair.status === "pending_activation";
|
||||
return <section aria-label="Archive conflict repair" className="space-y-4 rounded-xl border border-border bg-card p-5">
|
||||
<div><h3 className="font-semibold">Resolve archive conflict</h3>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm">{repair.reason}</p></div>
|
||||
<p role="status" className="rounded-md bg-muted p-3 text-sm">{statuses[repair.status] ?? repair.status}</p>
|
||||
{Boolean(descriptor.error) && <p role="alert" className="text-sm text-destructive">{String(descriptor.error)}</p>}
|
||||
{!repair.can_apply && <p className="text-sm text-muted-foreground">An administrator must approve shared archive corrections.
|
||||
You can reject the proposals or continue reviewing this question.</p>}
|
||||
<fieldset disabled={submitted} className="min-w-0 space-y-4">
|
||||
{repair.options.map(option => <article key={option.id} className="space-y-3 border-t border-border pt-4">
|
||||
<div><h4 className="text-sm font-semibold">{option.label}</h4>
|
||||
<p className="text-xs text-muted-foreground">{option.archive === "memory" ? "Memory" : "Evidence"} · {option.target_id}
|
||||
{repair.choice === option.id ? " · Selected" : ""}</p></div>
|
||||
<div className="grid min-w-0 gap-4 md:grid-cols-2">
|
||||
<div className="min-w-0 rounded-md bg-muted/50 p-3"><p className="mb-2 text-xs font-semibold">Before this proposal</p>
|
||||
<Content value={option.before} /></div>
|
||||
<div className="min-w-0 rounded-md border border-border p-3"><p className="mb-2 text-xs font-semibold">Proposed result</p>
|
||||
<Content value={option.content} /></div>
|
||||
</div>
|
||||
{repair.can_apply && !repair.choice && <button type="button" onClick={() => send(option.id)}
|
||||
className="rounded-md bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground">
|
||||
Apply this correction to {option.archive === "memory" ? "Memory" : "Evidence"}
|
||||
</button>}
|
||||
</article>)}
|
||||
<div className="flex flex-wrap gap-3 border-t border-border pt-4">
|
||||
{!repair.choice && <button type="button" onClick={() => send("reject")} className="rounded-md border border-border px-3 py-2 text-sm">
|
||||
None are adequate — reformulate
|
||||
</button>}
|
||||
{pending && repair.can_apply && repair.choice && <button type="button" onClick={() => send(repair.choice!)}
|
||||
className="rounded-md bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground">Retry selected correction</button>}
|
||||
{(repair.choice || !repair.can_apply) && <button type="button" onClick={() => send("continue")}
|
||||
className="rounded-md border border-border px-3 py-2 text-sm">{pending ? "Continue with repair pending" : "Continue question review"}</button>}
|
||||
</div>
|
||||
<ReservedControls reserved={descriptor.reserved} onControl={(control, text) => {
|
||||
if (!submitted) { setSubmitted(true); onRespond({ id: descriptor.id, control, text }); }
|
||||
}} />
|
||||
</fieldset>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryReviewWidget } from "./MemoryReviewWidget";
|
||||
import type { MemoryInput } from "../api/memory";
|
||||
|
||||
const card: MemoryInput = { family: "domain_clarification", subject: "Active patients",
|
||||
detail: "Active means the flag is true", scope: "clinical", rationale: "Approved definition",
|
||||
question: "", sql: "", concepts: ["Active"], dependencies: [], links: [] };
|
||||
const descriptor = { id: "review", widget: "memory-review", summary: { summary_id: "hash", items: [
|
||||
{ id: "domain", card, reason: "Approved clarification", source_seqs: [3], before: null, target_id: null },
|
||||
{ id: "solved", card: { ...card, family: "solved_question", subject: "Count active patients",
|
||||
question: "How many active patients?", sql: "SELECT COUNT(*) FROM patients WHERE active" },
|
||||
reason: "Approved solution", source_seqs: [7], before: null, target_id: null },
|
||||
] } };
|
||||
|
||||
test("sends only selected cards with reviewer edits and physical dependencies", async () => {
|
||||
const onRespond = vi.fn();
|
||||
render(<MemoryReviewWidget descriptor={descriptor} onRespond={onRespond} />);
|
||||
await userEvent.clear(screen.getByLabelText("domain detail"));
|
||||
await userEvent.type(screen.getByLabelText("domain detail"), "Reviewer correction");
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: /Count active patients/ }));
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "Add dependency" })[0]!);
|
||||
await userEvent.type(screen.getByLabelText("domain dependency 1 database"), "warehouse");
|
||||
await userEvent.type(screen.getByLabelText("domain dependency 1 schema_name"), "clinical");
|
||||
await userEvent.type(screen.getByLabelText("domain dependency 1 table"), "patients");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save selected and finish" }));
|
||||
expect(onRespond).toHaveBeenCalledTimes(1);
|
||||
const response = JSON.parse(onRespond.mock.calls[0]![0].text);
|
||||
expect(response).toEqual({ summary_id: "hash", items: [{ id: "domain", card: { ...card,
|
||||
detail: "Reviewer correction", dependencies: [{ database: "warehouse", schema_name: "clinical",
|
||||
table: "patients", column: "" }] } }] });
|
||||
expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("declining every proposal submits an explicit empty review", async () => {
|
||||
const onRespond = vi.fn();
|
||||
render(<MemoryReviewWidget descriptor={descriptor} onRespond={onRespond} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Deselect all" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Finish without saving Memory" }));
|
||||
expect(JSON.parse(onRespond.mock.calls[0]![0].text)).toEqual({ summary_id: "hash", items: [] });
|
||||
});
|
||||
|
||||
test("links selected proposals and keeps approved SQL read only", async () => {
|
||||
const onRespond = vi.fn();
|
||||
render(<MemoryReviewWidget descriptor={descriptor} onRespond={onRespond} />);
|
||||
expect(screen.getByText("SELECT COUNT(*) FROM patients WHERE active")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox", { name: /sql/i })).not.toBeInTheDocument();
|
||||
await userEvent.selectOptions(screen.getByLabelText("domain add link"), "proposal:solved");
|
||||
await userEvent.type(screen.getByLabelText("domain link 1 meaning"), "Example using this definition");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save selected and finish" }));
|
||||
expect(JSON.parse(onRespond.mock.calls[0]![0].text).items[0].card.links)
|
||||
.toEqual([{ target_id: "proposal:solved", meaning: "Example using this definition" }]);
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState } from "react";
|
||||
import type { MemoryCard, MemoryInput } from "../api/memory";
|
||||
import type { WidgetProps } from "./types";
|
||||
import { ReservedControls } from "./ReservedControls";
|
||||
|
||||
interface Proposal {
|
||||
id: string; card: MemoryInput; reason: string; source_seqs: number[];
|
||||
before: MemoryCard | null; target_id: string | null;
|
||||
}
|
||||
interface Summary { summary_id: string; items: Proposal[] }
|
||||
const labels = { domain_clarification: "Domain clarification", sql_rule: "SQL rule",
|
||||
solved_question: "Solved question", explained_error: "Explained error" };
|
||||
const inputStyle = "min-w-0 w-full rounded-md border border-border bg-background p-2 text-sm text-foreground";
|
||||
|
||||
export function MemoryReviewWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
const summary = descriptor.summary as Summary;
|
||||
const [items, setItems] = useState(summary.items);
|
||||
const [selected, setSelected] = useState(new Set(summary.items.map(p => p.id)));
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const patch = (id: string, changes: Partial<MemoryInput>) => setItems(current => current.map(
|
||||
item => item.id === id ? { ...item, card: { ...item.card, ...changes } } : item));
|
||||
const toggle = (id: string) => setSelected(current => {
|
||||
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
|
||||
});
|
||||
|
||||
return <form aria-label="Memory summary" className="space-y-5 rounded-xl border border-border bg-card p-5"
|
||||
onSubmit={event => {
|
||||
event.preventDefault();
|
||||
if (submitted) return;
|
||||
setSubmitted(true);
|
||||
onRespond({ id: descriptor.id, kind: "memory-review", text: JSON.stringify({
|
||||
summary_id: summary.summary_id,
|
||||
items: items.filter(item => selected.has(item.id)).map(item => ({ id: item.id,
|
||||
card: { ...item.card, concepts: item.card.concepts.map(c => c.trim()).filter(Boolean) } })),
|
||||
}) });
|
||||
}}>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">Memory for future questions</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Review the scope and wording, then choose what to save.
|
||||
Only selected cards and their links will enter the shared archive.</p>
|
||||
</div>
|
||||
<fieldset disabled={submitted} className="min-w-0 space-y-4">
|
||||
<button type="button" className="text-sm font-medium text-primary" onClick={() => setSelected(
|
||||
selected.size ? new Set() : new Set(items.map(item => item.id)))}>
|
||||
{selected.size ? "Deselect all" : "Select all"}
|
||||
</button>
|
||||
{items.map(item => <section key={item.id} className="space-y-3 border-t border-border pt-4">
|
||||
<label className="flex items-start gap-3 text-sm font-medium">
|
||||
<input type="checkbox" className="mt-1 size-4" checked={selected.has(item.id)} onChange={() => toggle(item.id)} />
|
||||
<span>{item.card.subject}<span className="block text-xs font-normal text-muted-foreground">
|
||||
{item.target_id ? "Update existing card" : "New card"} · {labels[item.card.family]}
|
||||
</span></span>
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">{item.reason}</p>
|
||||
<p className="text-xs text-muted-foreground">Approved decisions: {item.source_seqs.map(seq => `#${seq}`).join(", ")}</p>
|
||||
{item.before && <details className="rounded-md bg-muted p-3 text-sm">
|
||||
<summary className="cursor-pointer font-medium">Current card being replaced</summary>
|
||||
<dl className="mt-2 space-y-2">
|
||||
{(["subject", "detail", "scope", "rationale", "question", "sql"] as const).map(key =>
|
||||
<div key={key}><dt className="font-medium capitalize">{key}</dt>
|
||||
<dd className="whitespace-pre-wrap break-words">{item.before![key] || "—"}</dd></div>)}
|
||||
</dl>
|
||||
</details>}
|
||||
<fieldset disabled={!selected.has(item.id)} className="min-w-0 space-y-3 disabled:opacity-60">
|
||||
{(["subject", "scope", "detail", "rationale", "question"] as const).map(key => <label key={key} className="block text-xs font-medium capitalize">
|
||||
{key}<textarea aria-label={`${item.id} ${key}`} className={`${inputStyle} mt-1`} rows={key === "detail" ? 3 : 2}
|
||||
required={key === "subject" || key === "scope" || (key === "rationale" && item.card.family === "explained_error")}
|
||||
value={item.card[key]} onChange={event => patch(item.id, { [key]: event.target.value })} />
|
||||
</label>)}
|
||||
{item.card.family === "solved_question" && <div className="text-sm">
|
||||
<p className="font-medium">Approved SQL</p><pre className="mt-1 overflow-x-auto rounded-md bg-muted p-3 text-xs">{item.card.sql}</pre>
|
||||
<p className="mt-1 text-xs text-muted-foreground">To change the solution, return to SQL review.</p>
|
||||
</div>}
|
||||
<label className="block text-xs font-medium">Concepts, separated by commas
|
||||
<input aria-label={`${item.id} concepts`} className={`${inputStyle} mt-1`} value={item.card.concepts.join(", ")}
|
||||
onChange={event => patch(item.id, { concepts: event.target.value.split(",").map(v => v.trim()) })} />
|
||||
</label>
|
||||
<div className="space-y-2"><p className="text-xs font-medium">Physical dependencies</p>
|
||||
{item.card.dependencies.map((dependency, index) => <div key={index} className="grid gap-2 sm:grid-cols-2">
|
||||
{(["database", "schema_name", "table", "column"] as const).map(key => <input key={key}
|
||||
className={inputStyle} aria-label={`${item.id} dependency ${index+1} ${key}`} placeholder={key}
|
||||
required={key === "database"} value={dependency[key]} onChange={event => patch(item.id, {
|
||||
dependencies: item.card.dependencies.map((d, i) => i === index ? { ...d, [key]: event.target.value } : d),
|
||||
})} />)}
|
||||
<button type="button" className="text-left text-xs text-muted-foreground" onClick={() => patch(item.id, {
|
||||
dependencies: item.card.dependencies.filter((_, i) => i !== index),
|
||||
})}>Remove dependency</button>
|
||||
</div>)}
|
||||
<button type="button" className="text-xs text-primary" onClick={() => patch(item.id, {
|
||||
dependencies: [...item.card.dependencies, { database: "", schema_name: "", table: "", column: "" }],
|
||||
})}>Add dependency</button>
|
||||
</div>
|
||||
<div className="space-y-2"><p className="text-xs font-medium">Links to other cards</p>
|
||||
{item.card.links.map((link, index) => <div key={index} className="space-y-2 rounded-md border border-border p-2">
|
||||
<input aria-label={`${item.id} link ${index+1} target`} className={inputStyle} required
|
||||
value={link.target_id} placeholder="Card ID or proposal reference" onChange={event => patch(item.id, {
|
||||
links: item.card.links.map((l, i) => i === index ? { ...l, target_id: event.target.value } : l),
|
||||
})} />
|
||||
<input aria-label={`${item.id} link ${index+1} meaning`} className={inputStyle} required
|
||||
value={link.meaning} placeholder="Why these cards are linked" onChange={event => patch(item.id, {
|
||||
links: item.card.links.map((l, i) => i === index ? { ...l, meaning: event.target.value } : l),
|
||||
})} />
|
||||
<button type="button" className="text-xs text-muted-foreground" onClick={() => patch(item.id, {
|
||||
links: item.card.links.filter((_, i) => i !== index),
|
||||
})}>Remove link</button>
|
||||
</div>)}
|
||||
<select aria-label={`${item.id} add link`} className={inputStyle} value="" onChange={event => {
|
||||
if (event.target.value) patch(item.id, { links: [...item.card.links, { target_id: event.target.value, meaning: "" }] });
|
||||
}}><option value="">Link to another proposed card…</option>
|
||||
{items.filter(p => p.id !== item.id).map(p => <option key={p.id} value={`proposal:${p.id}`}>{p.card.subject}</option>)}
|
||||
</select>
|
||||
<button type="button" className="text-xs text-primary" onClick={() => patch(item.id, {
|
||||
links: [...item.card.links, { target_id: "", meaning: "" }],
|
||||
})}>Link to an existing card</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</section>)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4">
|
||||
<span className="text-sm text-muted-foreground">{selected.size} of {items.length} selected</span>
|
||||
<button type="submit" className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground">
|
||||
{submitted ? "Saving…" : selected.size ? "Save selected and finish" : "Finish without saving Memory"}
|
||||
</button>
|
||||
</div>
|
||||
<ReservedControls reserved={descriptor.reserved} onControl={(control, text) => onRespond({ id: descriptor.id, control, text })} />
|
||||
</fieldset>
|
||||
</form>;
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { ArtifactGateWidget } from "./ArtifactGateWidget";
|
||||
import { ArtifactWidget } from "./ArtifactWidget";
|
||||
import { SchemaLinkingGateWidget } from "./SchemaLinkingGateWidget";
|
||||
import { JoinReviewWidget } from "./JoinReviewWidget";
|
||||
import { MemoryReviewWidget } from "./MemoryReviewWidget";
|
||||
import { ArchiveRepairWidget } from "./ArchiveRepairWidget";
|
||||
register("select", SelectWidget);
|
||||
register("info", InfoWidget);
|
||||
register("freetext", FreetextWidget);
|
||||
@@ -15,4 +17,6 @@ register("artifact-gate", ArtifactGateWidget);
|
||||
register("artifact", ArtifactWidget);
|
||||
register("schema-linking", SchemaLinkingGateWidget);
|
||||
register("join-review", JoinReviewWidget);
|
||||
register("memory-review", MemoryReviewWidget);
|
||||
register("archive-repair", ArchiveRepairWidget);
|
||||
export { resolve } from "./registry";
|
||||
|
||||
Reference in New Issue
Block a user