All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m24s
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>
248 lines
7.4 KiB
TypeScript
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;
|
|
}
|