Files
legal-ai/web-ui/src/lib/api/documents.ts
Chaim 9bdfb05350
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m24s
Upload progress: Redis-backed store + flushed SSE + client fallback
The previous in-memory _progress dict + polling SSE handler had a 30s silent
tail after completion. HTTP/2 framing in the proxy chain (Traefik) buffered
the small chunks until the stream closed, so when a transient blip caused
EventSource to reconnect, the server returned 404 and the UI stuck on the
"מתחיל…" placeholder forever. Reproduced live: 445 bytes withheld 31s.

Changes:
  • web/progress_store.py — ProgressStore wraps Redis with TTL (5m), atomic
    GETDEL, dict-like API. Best-effort: Redis errors are logged and swallowed
    so observability outages don't break uploads.
  • web/app.py — _progress is now Redis-backed; every set/get/active/pop is
    awaited. SSE handler emits a heartbeat each tick (forces HTTP/2 flush),
    drops the 30s post-completion sleep, and returns a terminal
    {"status":"unknown"} payload instead of 404 when the task is gone — so
    EventSource closes cleanly instead of reconnect-looping. New _SSE_HEADERS
    set X-Accel-Buffering: no.
  • web-ui useProgress(taskId, caseNumber) — 10s fallback that invalidates
    the case detail if no SSE message arrived; treats "unknown" as terminal
    and triggers a refetch from the source of truth.
  • upload-sheet wires caseNumber through and renders "unknown" as completed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 12:53:23 +00:00

248 lines
7.4 KiB
TypeScript

/**
* Document upload + progress hooks.
*
* Upload hits `POST /api/cases/{n}/documents/upload-tagged` as multipart
* form-data (FastAPI UploadFile), and receives a `task_id` that streams
* progress events via `GET /api/progress/{task_id}` (SSE). We expose
* both as a single `useUploadDocument` mutation returning the task id
* plus a `useProgress(taskId)` hook that subscribes to the stream.
*/
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ApiError } from "./client";
import { casesKeys } from "./cases";
import { openSSE } from "@/lib/sse";
export type UploadTaggedResponse = {
task_id: string;
filename: string;
original_name: string;
doc_type: string;
};
export type ProgressEvent = {
/* "unknown" is sent by the backend when the task TTL expired or the
* caller subscribed before any state was published. Treat it as a
* terminal hint to refetch case state from the source of truth. */
status: "queued" | "processing" | "completed" | "failed" | "unknown" | string;
filename?: string;
step?: string;
error?: string;
result?: unknown;
case_number?: string;
doc_type?: string;
};
export type UploadVars = {
caseNumber: string;
file: File;
docType?: string;
partyName?: string;
title?: string;
};
async function uploadTagged({
caseNumber,
file,
docType = "auto",
partyName = "",
title = "",
}: UploadVars): Promise<UploadTaggedResponse> {
const fd = new FormData();
fd.append("file", file);
fd.append("doc_type", docType);
fd.append("party_name", partyName);
fd.append("title", title);
const res = await fetch(
`/api/cases/${encodeURIComponent(caseNumber)}/documents/upload-tagged`,
{ method: "POST", body: fd },
);
const contentType = res.headers.get("content-type") ?? "";
const parsed = contentType.includes("application/json")
? await res.json().catch(() => null)
: await res.text().catch(() => null);
if (!res.ok) {
throw new ApiError(
`Upload failed with ${res.status}`,
res.status,
parsed,
);
}
return parsed as UploadTaggedResponse;
}
export function useUploadDocument(caseNumber: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (vars: Omit<UploadVars, "caseNumber">) =>
uploadTagged({ caseNumber, ...vars }),
onSuccess: () => {
/* Nudge the case detail to refetch so the new document row appears
* immediately — the actual "processing" badge will update once the
* SSE stream reports status=completed. */
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
},
});
}
// ── PATCH document tags ───────────────────────────────────────────
export type DocumentPatch = {
doc_type?: string;
appraiser_side?: string; // "" clears; "committee" | "appellant" | "deciding" sets
};
export type PatchDocumentResponse = {
status: "completed" | "noop";
document: {
id: string;
doc_type: string;
title: string;
metadata?: Record<string, unknown>;
};
};
async function patchDocument(
caseNumber: string,
docId: string,
patch: DocumentPatch,
): Promise<PatchDocumentResponse> {
const res = await fetch(
`/api/cases/${encodeURIComponent(caseNumber)}/documents/${encodeURIComponent(docId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
},
);
const contentType = res.headers.get("content-type") ?? "";
const parsed = contentType.includes("application/json")
? await res.json().catch(() => null)
: await res.text().catch(() => null);
if (!res.ok) {
throw new ApiError(`Patch failed with ${res.status}`, res.status, parsed);
}
return parsed as PatchDocumentResponse;
}
export function usePatchDocument(caseNumber: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ docId, patch }: { docId: string; patch: DocumentPatch }) =>
patchDocument(caseNumber, docId, patch),
onSuccess: () => {
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
},
});
}
// ── Extract appraiser facts (on-demand, per case) ──────────────────
export type ExtractAppraiserFactsResponse =
| {
status: "completed";
appraisal_count: number;
total_facts: number;
conflicts: unknown[];
by_document?: unknown[];
}
| {
status: "no_appraisals";
appraisal_count: 0;
total_facts: 0;
conflicts: unknown[];
}
| {
status: "sides_missing";
appraisal_count: number;
missing: { document_id: string; title: string; current_side: string }[];
message: string;
};
async function extractAppraiserFacts(
caseNumber: string,
): Promise<ExtractAppraiserFactsResponse> {
const res = await fetch(
`/api/cases/${encodeURIComponent(caseNumber)}/extract-appraiser-facts`,
{ method: "POST" },
);
const contentType = res.headers.get("content-type") ?? "";
const parsed = contentType.includes("application/json")
? await res.json().catch(() => null)
: await res.text().catch(() => null);
if (!res.ok) {
throw new ApiError(
`Extraction failed with ${res.status}`,
res.status,
parsed,
);
}
return parsed as ExtractAppraiserFactsResponse;
}
export function useExtractAppraiserFacts(caseNumber: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => extractAppraiserFacts(caseNumber),
onSuccess: () => {
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
},
});
}
export function useProgress(taskId: string | null, caseNumber?: string) {
const [event, setEvent] = useState<ProgressEvent | null>(null);
const qc = useQueryClient();
useEffect(() => {
if (!taskId) return;
setEvent(null);
/* Self-heal fallback: if no SSE message arrives within 10s — usually
* because the proxy chain held the chunks or the EventSource is
* silently retrying — synthesize a refresh by invalidating the case
* detail. The actual document state is in the case detail anyway, so
* the UI heals from the source of truth without depending on SSE. */
let firstMessageReceived = false;
const fallback = window.setTimeout(() => {
if (firstMessageReceived) return;
if (caseNumber) qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
setEvent({ status: "completed" });
}, 10_000);
const close = openSSE<ProgressEvent>(
`/api/progress/${encodeURIComponent(taskId)}`,
{
onMessage: (data) => {
firstMessageReceived = true;
setEvent(data);
if (
data.status === "completed" ||
data.status === "failed" ||
data.status === "unknown"
) {
/* Close from within the callback so EventSource does not
* auto-reconnect after the server's EOF. For "unknown" we
* also nudge a case-detail refetch — the task state is gone
* but the document row will tell us the truth. */
if (data.status === "unknown" && caseNumber) {
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
}
close();
}
},
},
);
return () => {
window.clearTimeout(fallback);
close();
};
}, [taskId, caseNumber, qc]);
return event;
}