diff --git a/docs/internal/recall-extraction.md b/docs/internal/recall-extraction.md index b2d3bd795..14f94cec8 100644 --- a/docs/internal/recall-extraction.md +++ b/docs/internal/recall-extraction.md @@ -409,7 +409,11 @@ activation promotes them to `accepted` and archives the retired generation's still-automatic entries in the same transaction, so the served corpus switches atomically. Retiring a generation likewise archives its still-automatic entries. Entries a human has touched (any review state other than `unreviewed_auto`) are -never moved by these flips. +never moved by these flips. In particular, `human_reviewed` entries remain +accepted and `human_rejected` entries remain archived across activation and +retirement. Privacy retraction and content-digest reset cleanup also target only +`unreviewed_auto`; neither human state is deleted or regenerated by those +machine lifecycle paths. ## Entry mapping diff --git a/docs/recall.md b/docs/recall.md index bfc3da785..a70dbc6d7 100644 --- a/docs/recall.md +++ b/docs/recall.md @@ -35,11 +35,11 @@ The current implementation is local and SQLite-only. The CLI provides: The top-level **Recall** page has two tabs: -- **Corpus** is a read-only browser for distilled entries. It shows extraction - coverage and generation state, and filters entries by text, project, entry - type, generation, and review state. Expand an entry to inspect its body, - trigger, uncertainty, provenance metadata, and evidence links back to the - source transcript. +- **Corpus** is a browser and review surface for distilled entries. It shows + extraction coverage and generation state, and filters entries by text, + project, entry type, generation, and review state. Expand an entry to inspect + its body, trigger, uncertainty, provenance metadata, evidence links back to + the source transcript, and review controls for automatic entries. - **Generated insights** creates and stores longer reports over an explicit session scope. Its form always shows the date range, project, session agent, automated-session scope, report template, generator, and optional focus used @@ -86,13 +86,25 @@ The Corpus tab is not available through PostgreSQL or DuckDB stores, so those read-only servers open Recall on Generated insights instead. On the local SQLite UI, Session Vital Signs also includes a read-only Recall panel for the open session and links each evidence range back to the transcript. Corpus population, -review, extraction-generation management, and ranked querying remain CLI and -HTTP API workflows. +extraction-generation management, and ranked querying remain CLI and HTTP API +workflows. The daemon exposes the same inspection and query operations over its HTTP API. Ordinary queries record measurement data when the SQLite store is writable, but read-only archives remain queryable without recording. +### Review extracted entries + +Expand an accepted `unreviewed_auto` entry in the Corpus table to approve or +archive it. **Approve** immediately marks the entry `human_reviewed`; approval +is disabled when its source evidence has been revoked. **Archive** asks for +confirmation, then marks the entry `human_rejected` and removes it from the +served Recall corpus. + +Both decisions are durable human states and are not reversed by later +extraction-generation changes. This surface deliberately has no entry editing, +bulk review, or undo action. + ## Vector and hybrid retrieval `recall query` and `recall brief` accept `--mode lexical`, `--mode vector`, or @@ -208,11 +220,12 @@ or manufacture stable message IDs and digests. Evidence must belong to the same source session as its entry. These checks run through the shared insertion and reviewed-import boundaries rather than through a separate model write path. -Entries have one of four review states: +Entries have one of five review states: | Review state | Meaning | | ----------------- | ------------------------------------------------------- | -| `human_reviewed` | Explicitly accepted through the reviewed import surface | +| `human_reviewed` | Explicitly approved by a human | +| `human_rejected` | Explicitly rejected and archived by a human | | `unreviewed_auto` | Generated or omitted review decision | | `calibrated_auto` | Automated output from a calibrated future policy | | `eval_raw` | Quarantined evaluation material | diff --git a/frontend/messages/en.json b/frontend/messages/en.json index ba654ac93..16e633774 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1304,6 +1304,19 @@ "recall_page_all_generations": "All generations", "recall_page_review_filter": "Review state", "recall_page_all_review_states": "All review states", + "recall_page_review_state_human_reviewed": "Human approved", + "recall_page_review_state_human_rejected": "Human rejected", + "recall_page_review_state_unreviewed_auto": "Unreviewed automatic", + "recall_page_review_state_calibrated_auto": "Calibrated automatic", + "recall_page_review_state_eval_raw": "Evaluation raw", + "recall_page_review_approve": "Approve", + "recall_page_review_archive": "Archive", + "recall_page_review_approve_disabled": "Approval is unavailable because the source evidence was revoked.", + "recall_page_review_archive_title": "Archive Recall entry", + "recall_page_review_archive_message": "Archive “{title}” as rejected? It will remain outside the served Recall corpus.", + "recall_page_review_cancel": "Cancel", + "recall_page_review_close": "Close archive confirmation", + "recall_page_review_error": "Could not review this Recall entry: {error}", "recall_page_table_label": "Recall entries", "recall_page_fact_column": "Fact", "recall_page_project_column": "Project", diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index 16f517e9c..62bb51531 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -1304,6 +1304,19 @@ "recall_page_all_generations": "Toutes les générations", "recall_page_review_filter": "État de révision", "recall_page_all_review_states": "Tous les états de révision", + "recall_page_review_state_human_reviewed": "Approuvé par un humain", + "recall_page_review_state_human_rejected": "Rejeté par un humain", + "recall_page_review_state_unreviewed_auto": "Automatique non vérifié", + "recall_page_review_state_calibrated_auto": "Automatique calibré", + "recall_page_review_state_eval_raw": "Évaluation brute", + "recall_page_review_approve": "Approuver", + "recall_page_review_archive": "Archiver", + "recall_page_review_approve_disabled": "L’approbation est indisponible car les preuves sources ont été révoquées.", + "recall_page_review_archive_title": "Archiver l’entrée Recall", + "recall_page_review_archive_message": "Archiver « {title} » comme rejetée ? Elle restera exclue du corpus Recall servi.", + "recall_page_review_cancel": "Annuler", + "recall_page_review_close": "Fermer la confirmation d’archivage", + "recall_page_review_error": "Impossible de vérifier cette entrée Recall : {error}", "recall_page_table_label": "Entrées de rappel", "recall_page_fact_column": "Fait", "recall_page_project_column": "Projet", diff --git a/frontend/messages/ko.json b/frontend/messages/ko.json index 160b45708..d13f66105 100644 --- a/frontend/messages/ko.json +++ b/frontend/messages/ko.json @@ -1274,6 +1274,19 @@ "recall_page_all_generations": "모든 세대", "recall_page_review_filter": "검토 상태", "recall_page_all_review_states": "모든 검토 상태", + "recall_page_review_state_human_reviewed": "사람이 승인함", + "recall_page_review_state_human_rejected": "사람이 거부함", + "recall_page_review_state_unreviewed_auto": "검토되지 않은 자동 항목", + "recall_page_review_state_calibrated_auto": "보정된 자동 항목", + "recall_page_review_state_eval_raw": "원시 평가", + "recall_page_review_approve": "승인", + "recall_page_review_archive": "보관", + "recall_page_review_approve_disabled": "원본 근거가 철회되어 승인할 수 없습니다.", + "recall_page_review_archive_title": "Recall 항목 보관", + "recall_page_review_archive_message": "“{title}”을(를) 거부됨으로 보관하시겠습니까? 제공되는 Recall 코퍼스에서 계속 제외됩니다.", + "recall_page_review_cancel": "취소", + "recall_page_review_close": "보관 확인 닫기", + "recall_page_review_error": "이 Recall 항목을 검토할 수 없습니다: {error}", "recall_page_table_label": "리콜 항목", "recall_page_fact_column": "사실", "recall_page_project_column": "프로젝트", diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index acf1acffd..baea9de59 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -1272,6 +1272,19 @@ "recall_page_all_generations": "所有代次", "recall_page_review_filter": "审核状态", "recall_page_all_review_states": "所有审核状态", + "recall_page_review_state_human_reviewed": "人工已批准", + "recall_page_review_state_human_rejected": "人工已拒绝", + "recall_page_review_state_unreviewed_auto": "未审核的自动条目", + "recall_page_review_state_calibrated_auto": "已校准的自动条目", + "recall_page_review_state_eval_raw": "原始评估", + "recall_page_review_approve": "批准", + "recall_page_review_archive": "归档", + "recall_page_review_approve_disabled": "由于来源证据已被撤销,无法批准。", + "recall_page_review_archive_title": "归档 Recall 条目", + "recall_page_review_archive_message": "将“{title}”归档为已拒绝吗?它将继续排除在提供服务的 Recall 语料库之外。", + "recall_page_review_cancel": "取消", + "recall_page_review_close": "关闭归档确认", + "recall_page_review_error": "无法审核此 Recall 条目:{error}", "recall_page_table_label": "召回条目", "recall_page_fact_column": "事实", "recall_page_project_column": "项目", diff --git a/frontend/messages/zh-TW.json b/frontend/messages/zh-TW.json index e56409e48..6e2a3875f 100644 --- a/frontend/messages/zh-TW.json +++ b/frontend/messages/zh-TW.json @@ -1272,6 +1272,19 @@ "recall_page_all_generations": "所有代次", "recall_page_review_filter": "審核狀態", "recall_page_all_review_states": "所有審核狀態", + "recall_page_review_state_human_reviewed": "人工已核准", + "recall_page_review_state_human_rejected": "人工已拒絕", + "recall_page_review_state_unreviewed_auto": "未審核的自動項目", + "recall_page_review_state_calibrated_auto": "已校準的自動項目", + "recall_page_review_state_eval_raw": "原始評估", + "recall_page_review_approve": "核准", + "recall_page_review_archive": "封存", + "recall_page_review_approve_disabled": "來源證據已撤銷,因此無法核准。", + "recall_page_review_archive_title": "封存 Recall 項目", + "recall_page_review_archive_message": "要將「{title}」封存為已拒絕嗎?它仍會排除在提供服務的 Recall 語料庫之外。", + "recall_page_review_cancel": "取消", + "recall_page_review_close": "關閉封存確認", + "recall_page_review_error": "無法審核此 Recall 項目:{error}", "recall_page_table_label": "召回項目", "recall_page_fact_column": "事實", "recall_page_project_column": "專案", diff --git a/frontend/src/lib/api/recall.test.ts b/frontend/src/lib/api/recall.test.ts index 0de648822..606b69edb 100644 --- a/frontend/src/lib/api/recall.test.ts +++ b/frontend/src/lib/api/recall.test.ts @@ -1,14 +1,9 @@ -import { - afterEach, - describe, - expect, - it, - vi, -} from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { activateRecallExtractionGeneration, fetchRecallEntries, fetchRecallExtractionProgress, + reviewRecallEntry, retireRecallExtractionGeneration, } from "./recall.js"; @@ -18,15 +13,17 @@ afterEach(() => { describe("fetchRecallEntries", () => { it("sends every corpus-browser filter to the Recall API", async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response( - JSON.stringify({ - entries: [], - trusted_only: false, - next_cursor: "cursor-2", - result_cap: 500, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - )); + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + entries: [], + trusted_only: false, + next_cursor: "cursor-2", + result_cap: 500, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); vi.stubGlobal("fetch", fetchMock); const page = await fetchRecallEntries({ @@ -34,13 +31,13 @@ describe("fetchRecallEntries", () => { project: "project-a", type: "decision", sourceRunId: "generation-a", + status: "archived", reviewState: "human_reviewed", limit: 75, }); expect(fetchMock).toHaveBeenCalledOnce(); - const url = new URL(String(fetchMock.mock.calls[0]?.[0]), - window.location.origin); + const url = new URL(String(fetchMock.mock.calls[0]?.[0]), window.location.origin); expect(url.pathname).toBe("/api/v1/recall/entries"); expect(Object.fromEntries(url.searchParams)).toEqual({ limit: "75", @@ -48,6 +45,7 @@ describe("fetchRecallEntries", () => { project: "project-a", type: "decision", source_run_id: "generation-a", + status: "archived", review_state: "human_reviewed", }); expect(page).toEqual({ @@ -58,16 +56,44 @@ describe("fetchRecallEntries", () => { }); }); +describe("reviewRecallEntry", () => { + it("posts one encoded review action and returns the updated entry", async () => { + const updated = { + id: "entry one", + status: "archived", + review_state: "human_rejected", + }; + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(updated), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect(reviewRecallEntry("entry one", "archive")).resolves.toEqual(updated); + expect(fetchMock).toHaveBeenCalledWith( + "/api/v1/recall/entries/entry%20one/review", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ action: "archive" }), + }), + ); + }); +}); + describe("fetchRecallExtractionProgress", () => { it("sends bounded generation, state, and cursor filters", async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response( - JSON.stringify({ - generation_fingerprint: "generation-a", - progress: [], - next_cursor: "progress-cursor-2", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - )); + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + generation_fingerprint: "generation-a", + progress: [], + next_cursor: "progress-cursor-2", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); vi.stubGlobal("fetch", fetchMock); const page = await fetchRecallExtractionProgress({ @@ -78,8 +104,7 @@ describe("fetchRecallExtractionProgress", () => { }); expect(fetchMock).toHaveBeenCalledOnce(); - const url = new URL(String(fetchMock.mock.calls[0]?.[0]), - window.location.origin); + const url = new URL(String(fetchMock.mock.calls[0]?.[0]), window.location.origin); expect(url.pathname).toBe("/api/v1/recall/extraction/progress"); expect(Object.fromEntries(url.searchParams)).toEqual({ limit: "25", @@ -97,9 +122,11 @@ describe("fetchRecallExtractionProgress", () => { describe("Recall extraction generation actions", () => { it("activates the configured generation", async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response(null, { - status: 204, - })); + const fetchMock = vi.fn().mockResolvedValue( + new Response(null, { + status: 204, + }), + ); vi.stubGlobal("fetch", fetchMock); await activateRecallExtractionGeneration(); @@ -111,9 +138,11 @@ describe("Recall extraction generation actions", () => { }); it("retires one generation without a force option", async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response(null, { - status: 204, - })); + const fetchMock = vi.fn().mockResolvedValue( + new Response(null, { + status: 204, + }), + ); vi.stubGlobal("fetch", fetchMock); await retireRecallExtractionGeneration("generation old"); diff --git a/frontend/src/lib/api/recall.ts b/frontend/src/lib/api/recall.ts index df364490a..647c3fa78 100644 --- a/frontend/src/lib/api/recall.ts +++ b/frontend/src/lib/api/recall.ts @@ -7,13 +7,9 @@ import type { RecallExtractProgressPage, RecallExtractProgressResponse, RecallExtractionStatus, + RecallReviewAction, } from "./types/recall.js"; -import { - ApiError, - authHeaders, - getBase, - responseErrorMessage, -} from "./runtime.js"; +import { ApiError, authHeaders, getBase, responseErrorMessage } from "./runtime.js"; const SESSION_RECALL_LIMIT = 500; const RECALL_PAGE_LIMIT = 200; @@ -32,6 +28,7 @@ export async function fetchRecallEntries( if (filters.sourceRunId) { query.set("source_run_id", filters.sourceRunId); } + if (filters.status) query.set("status", filters.status); if (filters.reviewState) { query.set("review_state", filters.reviewState); } @@ -41,10 +38,7 @@ export async function fetchRecallEntries( authHeaders({ signal }), ); if (!response.ok) { - throw new ApiError( - response.status, - await responseErrorMessage(response), - ); + throw new ApiError(response.status, await responseErrorMessage(response)); } const data = (await response.json()) as RecallEntriesResponse; return { @@ -54,18 +48,29 @@ export async function fetchRecallEntries( }; } +export async function reviewRecallEntry( + id: string, + action: RecallReviewAction, +): Promise { + const response = await fetch( + `${getBase()}/recall/entries/${encodeURIComponent(id)}/review`, + authHeaders({ + method: "POST", + body: JSON.stringify({ action }), + }), + ); + if (!response.ok) { + throw new ApiError(response.status, await responseErrorMessage(response)); + } + return (await response.json()) as RecallEntry; +} + export async function fetchRecallExtractionStatus( signal?: AbortSignal, ): Promise { - const response = await fetch( - `${getBase()}/recall/extraction/status`, - authHeaders({ signal }), - ); + const response = await fetch(`${getBase()}/recall/extraction/status`, authHeaders({ signal })); if (!response.ok) { - throw new ApiError( - response.status, - await responseErrorMessage(response), - ); + throw new ApiError(response.status, await responseErrorMessage(response)); } return (await response.json()) as RecallExtractionStatus; } @@ -85,10 +90,7 @@ export async function fetchRecallExtractionProgress( authHeaders({ signal }), ); if (!response.ok) { - throw new ApiError( - response.status, - await responseErrorMessage(response), - ); + throw new ApiError(response.status, await responseErrorMessage(response)); } const data = (await response.json()) as RecallExtractProgressResponse; return { @@ -99,15 +101,9 @@ export async function fetchRecallExtractionProgress( } async function postRecallExtractionAction(path: string): Promise { - const response = await fetch( - `${getBase()}${path}`, - authHeaders({ method: "POST" }), - ); + const response = await fetch(`${getBase()}${path}`, authHeaders({ method: "POST" })); if (!response.ok) { - throw new ApiError( - response.status, - await responseErrorMessage(response), - ); + throw new ApiError(response.status, await responseErrorMessage(response)); } } @@ -115,9 +111,7 @@ export async function activateRecallExtractionGeneration(): Promise { await postRecallExtractionAction("/recall/extraction/activate"); } -export async function retireRecallExtractionGeneration( - fingerprint: string, -): Promise { +export async function retireRecallExtractionGeneration(fingerprint: string): Promise { await postRecallExtractionAction( `/recall/extraction/generations/${encodeURIComponent(fingerprint)}/retire`, ); @@ -136,10 +130,7 @@ export async function fetchSessionRecall( authHeaders({ signal }), ); if (!response.ok) { - throw new ApiError( - response.status, - await responseErrorMessage(response), - ); + throw new ApiError(response.status, await responseErrorMessage(response)); } const data = (await response.json()) as RecallEntriesResponse; return data.entries ?? []; diff --git a/frontend/src/lib/api/types/recall.ts b/frontend/src/lib/api/types/recall.ts index 06d861f80..583304944 100644 --- a/frontend/src/lib/api/types/recall.ts +++ b/frontend/src/lib/api/types/recall.ts @@ -33,6 +33,8 @@ export interface RecallEntry { evidence?: RecallEvidence[]; } +export type RecallReviewAction = "approve" | "archive"; + export interface RecallEntriesResponse { entries: RecallEntry[]; trusted_only: boolean; @@ -117,6 +119,7 @@ export interface RecallEntryFilters { project?: string; type?: string; sourceRunId?: string; + status?: string; reviewState?: string; limit?: number; cursor?: string; diff --git a/frontend/src/lib/components/recall/RecallCorpusPanel.svelte b/frontend/src/lib/components/recall/RecallCorpusPanel.svelte index 753351654..1410244dd 100644 --- a/frontend/src/lib/components/recall/RecallCorpusPanel.svelte +++ b/frontend/src/lib/components/recall/RecallCorpusPanel.svelte @@ -17,6 +17,7 @@ fetchRecallEntries, fetchRecallExtractionProgress, fetchRecallExtractionStatus, + reviewRecallEntry, retireRecallExtractionGeneration, } from "../../api/recall.js"; import type { @@ -26,6 +27,7 @@ RecallExtractProgress, RecallExtractProgressState, RecallExtractionStatus, + RecallReviewAction, } from "../../api/types/recall.js"; import { ApiError, isAbortError } from "../../api/runtime.js"; import { formatDateTime, m } from "../../i18n/index.js"; @@ -47,6 +49,7 @@ ]; const REVIEW_STATES = [ "human_reviewed", + "human_rejected", "unreviewed_auto", "calibrated_auto", "eval_raw", @@ -76,6 +79,9 @@ let generationAction = $state(null); let generationActionLoading = $state(false); let generationActionError = $state(""); + let reviewingEntryIds = $state([]); + let reviewErrors = $state>({}); + let archiveEntry = $state(null); let search = $state(""); let query = $state(""); let project = $state(""); @@ -141,8 +147,8 @@ }, ...REVIEW_STATES.map((name) => ({ name, - label: name, - displayLabel: name, + label: reviewStateLabel(name), + displayLabel: reviewStateLabel(name), })), ]); const progressStateOptions = $derived([ @@ -173,6 +179,7 @@ project: project || undefined, type: entryType || undefined, sourceRunId: generation || undefined, + status: reviewState === "human_rejected" ? "archived" : undefined, reviewState: reviewState || undefined, cursor: cursor || undefined, }, signal); @@ -314,6 +321,66 @@ } } + function reviewStateLabel(state: string): string { + switch (state) { + case "human_reviewed": + return m.recall_page_review_state_human_reviewed(); + case "human_rejected": + return m.recall_page_review_state_human_rejected(); + case "unreviewed_auto": + return m.recall_page_review_state_unreviewed_auto(); + case "calibrated_auto": + return m.recall_page_review_state_calibrated_auto(); + case "eval_raw": + return m.recall_page_review_state_eval_raw(); + default: + return state; + } + } + + function isReviewable(entry: RecallEntry): boolean { + return entry.status === "accepted" && + entry.review_state === "unreviewed_auto"; + } + + function keepAfterReview(entry: RecallEntry): boolean { + return entry.status === "accepted" && + (!reviewState || entry.review_state === reviewState); + } + + async function submitReview( + entry: RecallEntry, + action: RecallReviewAction, + ) { + if (reviewingEntryIds.includes(entry.id)) return; + reviewingEntryIds = [...reviewingEntryIds, entry.id]; + reviewErrors = { ...reviewErrors, [entry.id]: "" }; + try { + const updated = await reviewRecallEntry(entry.id, action); + const keep = keepAfterReview(updated); + entries = keep + ? entries.map((item) => item.id === updated.id ? updated : item) + : entries.filter((item) => item.id !== updated.id); + if (!keep) { + expandedEntryIds = expandedEntryIds.filter((id) => id !== updated.id); + } + archiveEntry = null; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + reviewErrors = { + ...reviewErrors, + [entry.id]: m.recall_page_review_error({ error: detail }), + }; + } finally { + reviewingEntryIds = reviewingEntryIds.filter((id) => id !== entry.id); + } + } + + function closeArchiveReview() { + if (archiveEntry && reviewingEntryIds.includes(archiveEntry.id)) return; + archiveEntry = null; + } + function progressTimestamp(value: string): string { return formatDateTime(value, { dateStyle: "medium", @@ -392,6 +459,28 @@ {/if} {/snippet} +{#snippet archiveReviewFooter()} + {#if archiveEntry} + {@const busy = reviewingEntryIds.includes(archiveEntry.id)} + +