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)."""