@@ -0,0 +1,282 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
Label,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@conusai-kit/ui/primitives"
|
||||
|
||||
// The review table: every extracted line with where it was read, and a person's decision on each --
|
||||
// approve, edit or reject -- and a bulk approve for what is still pending. A decision is a call to
|
||||
// the proposals module's own procedures (or `documents.line.edit`), so the permission, the
|
||||
// transaction and the audit event are the kernel's, and this component adds none of its own.
|
||||
//
|
||||
// What is machine-proposed says so: the disclosure badge (EU AI Act Art. 50) heads the table, and
|
||||
// each row names the model and extractor version that proposed it.
|
||||
|
||||
import { AiDisclosureBadge, EvidencePanel } from "@conusai-kit/ui"
|
||||
import { describeError, useDataClient } from "@conusai-kit/ui/data"
|
||||
import { type FormEvent, type ReactElement, useEffect, useState } from "react"
|
||||
import type { LineView } from "../contract/index.js"
|
||||
import { REVIEW_PROCEDURES } from "../contract/review.js"
|
||||
import type { ProjectSlugs } from "./slugs.js"
|
||||
|
||||
export interface ReviewLabels {
|
||||
readonly caption: string
|
||||
readonly empty: string
|
||||
readonly label: string
|
||||
readonly value: string
|
||||
readonly page: string
|
||||
readonly source: string
|
||||
readonly confidence: string
|
||||
readonly decision: string
|
||||
readonly actions: string
|
||||
/** Contain `{label}`, replaced with the line's label: the button's accessible name. */
|
||||
readonly approve: string
|
||||
readonly reject: string
|
||||
readonly edit: string
|
||||
readonly save: string
|
||||
readonly cancel: string
|
||||
readonly approveAll: string
|
||||
/** Shown beside a line whose quote was not found on the page it cites. */
|
||||
readonly needsReview: string
|
||||
/** Shown instead of `needsReview` for a quote the model read off the image of a scanned page. */
|
||||
readonly scannedQuote: string
|
||||
/** Contains `{id}`, replaced with the reader's block id of the quote. */
|
||||
readonly block: string
|
||||
readonly edited: string
|
||||
readonly sourceOnPage: string
|
||||
readonly proposedBy: string
|
||||
readonly aiDisclosure: string
|
||||
/** The word before a page number ("Page"): the evidence panel prints it beside the number. */
|
||||
readonly pageWord: string
|
||||
readonly states: Readonly<Record<LineView["status"], string>>
|
||||
/** The app's `app.failed` message, with `{detail}` still in it. */
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
/** `approveMany` takes at most the proposals policy's `maxBatch`; its default is 100. */
|
||||
const BATCH = 100
|
||||
|
||||
export function LineReview({
|
||||
slugs,
|
||||
lines,
|
||||
labels,
|
||||
locale,
|
||||
fileId,
|
||||
onDecided,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
lines: readonly LineView[]
|
||||
labels: ReviewLabels
|
||||
/** 0.97 as "97 %": the locale's own number format. */
|
||||
locale: string
|
||||
fileId: string
|
||||
/** Reads the live document again: its lines, its counts and what the export holds. */
|
||||
onDecided: () => Promise<void>
|
||||
}): ReactElement {
|
||||
const client = useDataClient()
|
||||
// Keep the actions disabled until hydration attaches handlers, so an early click cannot disappear.
|
||||
const [interactive, setInteractive] = useState(false)
|
||||
useEffect(() => setInteractive(true), [])
|
||||
const [editing, setEditing] = useState<string | undefined>(undefined)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [problem, setProblem] = useState<string | undefined>(undefined)
|
||||
const pending = lines.filter((line) => line.status === "pending")
|
||||
// A line whose quote nobody could find on the page is never approved in bulk: a person looks at it.
|
||||
const approvable = pending.filter((line) => line.verification === "verified")
|
||||
|
||||
/** Runs one decision, then reads the document again: the lines, the counts and the export follow it. */
|
||||
const run = async (work: () => Promise<unknown>): Promise<void> => {
|
||||
setBusy(true)
|
||||
setProblem(undefined)
|
||||
try {
|
||||
await work()
|
||||
await onDecided()
|
||||
setEditing(undefined)
|
||||
setBusy(false)
|
||||
} catch (error) {
|
||||
setProblem(labels.failed.replace("{detail}", describeError(error)))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const approve = (line: LineView) =>
|
||||
run(() =>
|
||||
client.call(REVIEW_PROCEDURES.approve, {
|
||||
...slugs,
|
||||
proposalId: line.lineId,
|
||||
expectedVersion: line.expectedVersion,
|
||||
}),
|
||||
)
|
||||
const reject = (line: LineView) =>
|
||||
run(() => client.call(REVIEW_PROCEDURES.reject, { ...slugs, proposalId: line.lineId }))
|
||||
const approveAll = () =>
|
||||
run(async () => {
|
||||
for (let at = 0; at < approvable.length; at += BATCH) {
|
||||
await client.call(REVIEW_PROCEDURES.approveMany, {
|
||||
...slugs,
|
||||
proposalIds: approvable.slice(at, at + BATCH).map((line) => line.lineId),
|
||||
expectations: Object.fromEntries(
|
||||
approvable.slice(at, at + BATCH).map((line) => [line.lineId, { expectedVersion: line.expectedVersion }]),
|
||||
),
|
||||
})
|
||||
}
|
||||
})
|
||||
const save = (line: LineView, event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const form = new FormData(event.currentTarget)
|
||||
return run(() =>
|
||||
client.call("documents.line.edit", {
|
||||
...slugs,
|
||||
lineId: line.lineId,
|
||||
expectedVersion: line.expectedVersion,
|
||||
label: String(form.get("label") ?? ""),
|
||||
value: String(form.get("value") ?? ""),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return <p>{labels.empty}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-busy={busy}>
|
||||
<p>
|
||||
<AiDisclosureBadge label={labels.aiDisclosure} />
|
||||
</p>
|
||||
{approvable.length > 0 ? (
|
||||
<Button type="button" disabled={!interactive || busy} onClick={approveAll}>
|
||||
{labels.approveAll}
|
||||
</Button>
|
||||
) : null}
|
||||
{problem ? (
|
||||
<Alert variant="destructive" role="alert">
|
||||
{problem}
|
||||
</Alert>
|
||||
) : null}
|
||||
<Table>
|
||||
<TableCaption>{labels.caption}</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">{labels.label}</TableHead>
|
||||
<TableHead scope="col">{labels.value}</TableHead>
|
||||
<TableHead scope="col">{labels.page}</TableHead>
|
||||
<TableHead scope="col">{labels.source}</TableHead>
|
||||
<TableHead scope="col">{labels.confidence}</TableHead>
|
||||
<TableHead scope="col">{labels.decision}</TableHead>
|
||||
<TableHead scope="col">{labels.actions}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((line) => (
|
||||
<TableRow key={line.lineId}>
|
||||
{editing === line.lineId ? (
|
||||
<TableCell colSpan={7}>
|
||||
<form onSubmit={(event) => save(line, event)}>
|
||||
<Field>
|
||||
<Label htmlFor={`label-${line.lineId}`}>{labels.label}</Label>
|
||||
<Input
|
||||
id={`label-${line.lineId}`}
|
||||
name="label"
|
||||
defaultValue={line.label}
|
||||
required
|
||||
maxLength={200}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label htmlFor={`value-${line.lineId}`}>{labels.value}</Label>
|
||||
<Input
|
||||
id={`value-${line.lineId}`}
|
||||
name="value"
|
||||
defaultValue={line.value}
|
||||
required
|
||||
maxLength={2000}
|
||||
/>
|
||||
</Field>
|
||||
<Button type="submit" disabled={!interactive || busy}>
|
||||
{labels.save}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!interactive || busy}
|
||||
onClick={() => setEditing(undefined)}
|
||||
>
|
||||
{labels.cancel}
|
||||
</Button>
|
||||
</form>
|
||||
</TableCell>
|
||||
) : (
|
||||
<>
|
||||
<TableHead scope="row">{line.label}</TableHead>
|
||||
<TableCell>
|
||||
{line.value}
|
||||
{line.edited ? ` (${labels.edited})` : ""}
|
||||
</TableCell>
|
||||
<TableCell>{line.page}</TableCell>
|
||||
<TableCell>
|
||||
<EvidencePanel
|
||||
evidence={{
|
||||
fileId,
|
||||
page: line.page,
|
||||
quote: line.quote,
|
||||
...(line.box ? { box: line.box } : {}),
|
||||
}}
|
||||
label={labels.sourceOnPage.replace("{page}", String(line.page))}
|
||||
pageLabel={labels.pageWord}
|
||||
/>
|
||||
<small>{labels.proposedBy.replace("{model}", `${line.model} ${line.promptVersion}`)}</small>
|
||||
{line.blockId ? <small>{` ${labels.block.replace("{id}", line.blockId)}`}</small> : null}
|
||||
</TableCell>
|
||||
<TableCell>{new Intl.NumberFormat(locale, { style: "percent" }).format(line.confidence)}</TableCell>
|
||||
<TableCell>
|
||||
{labels.states[line.status]}
|
||||
{line.verification === "needs_review" ? (
|
||||
<strong>{` ${line.note === "scanned_quote" ? labels.scannedQuote : labels.needsReview}`}</strong>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{line.status === "pending" ? (
|
||||
<>
|
||||
<Button type="button" disabled={!interactive || busy} onClick={() => approve(line)}>
|
||||
{labels.approve.replace("{label}", line.label)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!interactive || busy}
|
||||
onClick={() => setEditing(line.lineId)}
|
||||
>
|
||||
{labels.edit.replace("{label}", line.label)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={!interactive || busy}
|
||||
onClick={() => reject(line)}
|
||||
>
|
||||
{labels.reject.replace("{label}", line.label)}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user