From eac7784b87c25d59f08eb2509371619b1f669510 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sun, 19 Apr 2026 09:42:49 +0000 Subject: [PATCH] Trigger appraiser-facts extraction from the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extraction is expensive (multi-minute LLM calls) and runs across every appraisal in the case at once, so we don't kick it off silently on every tag save. The chair tags the appraisals, then runs extraction once when they're ready. - New POST /api/cases/{n}/extract-appraiser-facts endpoint returns the extractor's summary as-is: status=completed with fact counts and conflicts, or status=sides_missing with the list of still-untagged appraisal docs. - DocumentTypeEditor now has a two-phase popover. After a successful save on an appraisal doc, the body switches to a confirmation view with a "חלץ עובדות שמאיות עכשיו" button. The result (completed / sides_missing / no_appraisals / error) renders in the same popover so the chair sees exactly which appraisals still need tagging without closing and reopening anything. - useExtractAppraiserFacts React-Query mutation invalidates the case detail on success so downstream views (conflict rendering in block-tet context) pick up the new facts. --- .../components/cases/document-type-editor.tsx | 281 +++++++++++++----- web-ui/src/lib/api/documents.ts | 55 ++++ web/app.py | 27 ++ 3 files changed, 294 insertions(+), 69 deletions(-) diff --git a/web-ui/src/components/cases/document-type-editor.tsx b/web-ui/src/components/cases/document-type-editor.tsx index 20c5b04..1555d66 100644 --- a/web-ui/src/components/cases/document-type-editor.tsx +++ b/web-ui/src/components/cases/document-type-editor.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Loader2 } from "lucide-react"; +import { CheckCircle2, Loader2, Sparkles } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -26,7 +26,11 @@ import { type AppraiserSide, type DocType, } from "@/lib/doc-types"; -import { usePatchDocument } from "@/lib/api/documents"; +import { + useExtractAppraiserFacts, + usePatchDocument, + type ExtractAppraiserFactsResponse, +} from "@/lib/api/documents"; /* * Inline editor for a document's tags. Renders a colored Badge that opens a @@ -34,8 +38,10 @@ import { usePatchDocument } from "@/lib/api/documents"; * 1. doc_type (always shown) * 2. appraiser_side (only when doc_type === "appraisal") * - * The Save button is disabled when doc_type is "appraisal" but no side is - * picked — extract_appraiser_facts requires it, so we enforce here too. + * After a successful save we swap the Popover body to a confirmation view + * with a "חלץ עובדות שמאיות עכשיו" button — extraction is expensive so we + * never run it silently on save; the chair clicks this when they're done + * tagging all appraisals in the case. */ export function DocumentTypeEditor({ @@ -52,11 +58,19 @@ export function DocumentTypeEditor({ const [open, setOpen] = useState(false); const [draftType, setDraftType] = useState(docType || ""); const [draftSide, setDraftSide] = useState(appraiserSide || ""); + const [saved, setSaved] = useState(false); + const [extractResult, setExtractResult] = + useState(null); const patch = usePatchDocument(caseNumber); + const extract = useExtractAppraiserFacts(caseNumber); function reset() { setDraftType(docType || ""); setDraftSide(appraiserSide || ""); + setSaved(false); + setExtractResult(null); + patch.reset(); + extract.reset(); } const isAppraisal = draftType === "appraisal"; @@ -77,7 +91,12 @@ export function DocumentTypeEditor({ if (!isAppraisal && appraiserSide) body.appraiser_side = ""; await patch.mutateAsync({ docId, patch: body }); - setOpen(false); + setSaved(true); + } + + async function handleExtract() { + const result = await extract.mutateAsync(); + setExtractResult(result); } // Build the on-badge label: "שומה · שמאי הוועדה" when both present. @@ -108,75 +127,89 @@ export function DocumentTypeEditor({ - -
- - -
+ + {saved ? ( + setOpen(false)} + /> + ) : ( + <> +
+ + +
- {isAppraisal && ( -
- - - {sideMissing && ( -

- נדרש לציין את הצד לפני חילוץ עובדות שמאיות. + {isAppraisal && ( +

+ + + {sideMissing && ( +

+ נדרש לציין את הצד לפני חילוץ עובדות שמאיות. +

+ )} +

+ ערכים: {Object.values(APPRAISER_SIDE_LABELS).join(" · ")} +

+
+ )} + + {patch.isError && ( +

+ שמירה נכשלה. נסה שוב.

)} -

- ערכים: {Object.values(APPRAISER_SIDE_LABELS).join(" · ")} -

-
- )} - {patch.isError && ( -

- שמירה נכשלה. נסה שוב. -

+
+ + +
+ )} - -
- - -
); @@ -184,3 +217,113 @@ export function DocumentTypeEditor({ // Re-export types so callers don't need to dual-import. export type { AppraiserSide, DocType }; + + +/* ── Post-save view: saved confirmation + extract action ───────────── + * + * Extraction is case-scoped (runs across every appraisal tagged in the + * case), so the button sits here rather than per-doc. If the chair is + * mid-tagging, clicking extract may return sides_missing with the list + * of still-untagged appraisals — we surface that list instead of a + * generic error. + */ + +function PostSaveView({ + isAppraisal, + extract, + extractResult, + onExtract, + onClose, +}: { + isAppraisal: boolean; + extract: ReturnType; + extractResult: ExtractAppraiserFactsResponse | null; + onExtract: () => void; + onClose: () => void; +}) { + const pending = extract.isPending; + + return ( +
+
+ + התיוג נשמר +
+ + {isAppraisal && !extractResult && ( +

+ ההתגיה עודכנה. אם סיימת לתייג את כל השומות בתיק, הפעל חילוץ + עובדות שמאיות (תכניות + היתרים + סתירות בין שמאים). +

+ )} + + {extractResult?.status === "completed" && ( +
+

+ הושלם. חולצו {extractResult.total_facts} עובדות + מ-{extractResult.appraisal_count} שומות. +

+ {extractResult.conflicts.length > 0 && ( +

זוהו {extractResult.conflicts.length} סתירות בין שמאים.

+ )} +
+ )} + + {extractResult?.status === "no_appraisals" && ( +

+ אין בתיק מסמכים מתויגים כ-שומה. +

+ )} + + {extractResult?.status === "sides_missing" && ( +
+

+ נותרו {extractResult.missing.length} שומות ללא תיוג צד. תייג אותן + לפני הפעלת החילוץ: +

+
    + {extractResult.missing.map((m) => ( +
  • + {m.title} +
  • + ))} +
+
+ )} + + {extract.isError && !extractResult && ( +

+ החילוץ נכשל. נסה שוב. +

+ )} + +
+ + {isAppraisal && ( + + )} +
+ + {pending && ( +

+ החילוץ יכול להימשך כמה דקות — שומות ארוכות עוברות ניתוח פסקה אחר + פסקה ע"י המודל. +

+ )} +
+ ); +} diff --git a/web-ui/src/lib/api/documents.ts b/web-ui/src/lib/api/documents.ts index 3f21e9e..5b252e0 100644 --- a/web-ui/src/lib/api/documents.ts +++ b/web-ui/src/lib/api/documents.ts @@ -136,6 +136,61 @@ export function usePatchDocument(caseNumber: string) { } +// ── 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 { + 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) { const [event, setEvent] = useState(null); diff --git a/web/app.py b/web/app.py index f01edc3..a0c60fb 100644 --- a/web/app.py +++ b/web/app.py @@ -3064,6 +3064,33 @@ async def api_patch_document(case_number: str, doc_id: str, req: DocumentPatchRe return {"status": "completed", "document": fresh} +@app.post("/api/cases/{case_number}/extract-appraiser-facts") +async def api_extract_appraiser_facts(case_number: str): + """Run structured extraction of plans + permits from every appraisal + document in the case, and detect conflicts between appraisers. + + Blocks if any appraisal document is missing metadata.appraiser_side — + the chair must tag every appraisal (committee / appellant / deciding) + before extraction can identify the deciding appraiser's governing view. + + Returns the extractor's summary dict as-is. Shape: + {"status": "completed"|"sides_missing"|"no_appraisals", ...} + """ + from legal_mcp.services import appraiser_facts_extractor + + case = await db.get_case_by_number(case_number) + if not case: + raise HTTPException(404, f"תיק {case_number} לא נמצא") + + try: + result = await appraiser_facts_extractor.extract_appraiser_facts( + UUID(case["id"]) + ) + except Exception as e: + raise HTTPException(500, f"חילוץ נכשל: {e}") + return result + + @app.delete("/api/cases/{case_number}/documents/{doc_id}") async def api_delete_document(case_number: str, doc_id: str): """Delete a single document from a case (including its chunks and file)."""