Files
legal-ai/web-ui/src/lib/api/documents.ts
Chaim 1f1a025509 fix(lint): תיקון 10 שגיאות ESLint + ניקוי directives מיותרים
10 שגיאות (כולן קיימות-מראש, לא מהפיצ'רים האחרונים):
- react/no-unescaped-entities (3): legal-arguments-panel, precedent-edit-sheet
  — escaping של מרכאות ב-JSX (“/")
- react-hooks/set-state-in-effect (6): documents-panel, chair-editor,
  content-checklists, discussion-rules, golden-ratios, documents.ts
  — disable-comment לדפוסי sync/reset לגיטימיים (false-positive ידוע)
- React Compiler reassign (1): subject-donut — refactor לחישוב prefix-sums
  ללא mutable accumulator

ניקוי: הסרת 5 eslint-disable directives מיותרים (halacha-review-panel,
precedent-upload-sheet). תוצאה: 0 errors (היה 10), 24→ warnings (היה 29).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:31:31 +00:00

266 lines
8.2 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;
}
| {
// The chair clicked the button; backend created a child Paperclip
// issue assigned to the legal-analyst, which will run the MCP tool
// on the host (where the Claude CLI lives) and post results back.
status: "queued";
sub_issue_id: string;
analyst_id: string;
main_issue_id: string;
}
| {
// No analyst route was available (no API key / no analyst configured /
// no Paperclip issue linked to the case). Non-fatal — the chair can
// still trigger extraction manually from Claude Code.
status: "skipped";
reason: "no_api_key" | "no_analyst" | "no_issue" | string;
company_id?: 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;
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset on taskId change
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;
}