Trigger appraiser-facts extraction from the UI
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 36s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 36s
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.
This commit is contained in:
@@ -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<string>(docType || "");
|
||||
const [draftSide, setDraftSide] = useState<string>(appraiserSide || "");
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [extractResult, setExtractResult] =
|
||||
useState<ExtractAppraiserFactsResponse | null>(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,7 +127,17 @@ export function DocumentTypeEditor({
|
||||
</Badge>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 space-y-3" align="end" dir="rtl">
|
||||
<PopoverContent className="w-80 space-y-3" align="end" dir="rtl">
|
||||
{saved ? (
|
||||
<PostSaveView
|
||||
isAppraisal={isAppraisal}
|
||||
extract={extract}
|
||||
extractResult={extractResult}
|
||||
onExtract={handleExtract}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-ink-muted">סוג מסמך</label>
|
||||
<Select value={draftType} onValueChange={setDraftType} dir="rtl">
|
||||
@@ -173,10 +202,14 @@ export function DocumentTypeEditor({
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || sideMissing || patch.isPending}
|
||||
>
|
||||
{patch.isPending && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
{patch.isPending && (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
)}
|
||||
שמור
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
@@ -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<typeof useExtractAppraiserFacts>;
|
||||
extractResult: ExtractAppraiserFactsResponse | null;
|
||||
onExtract: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const pending = extract.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">התיוג נשמר</span>
|
||||
</div>
|
||||
|
||||
{isAppraisal && !extractResult && (
|
||||
<p className="text-[0.72rem] text-ink-muted leading-relaxed">
|
||||
ההתגיה עודכנה. אם סיימת לתייג את כל השומות בתיק, הפעל חילוץ
|
||||
עובדות שמאיות (תכניות + היתרים + סתירות בין שמאים).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{extractResult?.status === "completed" && (
|
||||
<div className="rounded-md border border-success/30 bg-success-bg px-2.5 py-2 text-[0.72rem] text-ink space-y-0.5">
|
||||
<p>
|
||||
<strong>הושלם.</strong> חולצו {extractResult.total_facts} עובדות
|
||||
מ-{extractResult.appraisal_count} שומות.
|
||||
</p>
|
||||
{extractResult.conflicts.length > 0 && (
|
||||
<p>זוהו {extractResult.conflicts.length} סתירות בין שמאים.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extractResult?.status === "no_appraisals" && (
|
||||
<p className="text-[0.72rem] text-ink-muted">
|
||||
אין בתיק מסמכים מתויגים כ-שומה.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{extractResult?.status === "sides_missing" && (
|
||||
<div className="rounded-md border border-warn/40 bg-warn-bg px-2.5 py-2 text-[0.72rem] text-ink space-y-1">
|
||||
<p>
|
||||
נותרו {extractResult.missing.length} שומות ללא תיוג צד. תייג אותן
|
||||
לפני הפעלת החילוץ:
|
||||
</p>
|
||||
<ul className="list-disc pr-4 space-y-0.5">
|
||||
{extractResult.missing.map((m) => (
|
||||
<li key={m.document_id} className="truncate">
|
||||
{m.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extract.isError && !extractResult && (
|
||||
<p className="text-[0.72rem] text-danger">
|
||||
החילוץ נכשל. נסה שוב.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
|
||||
סגור
|
||||
</Button>
|
||||
{isAppraisal && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={onExtract}
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{extractResult ? "הפעל שוב" : "חלץ עובדות שמאיות עכשיו"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pending && (
|
||||
<p className="text-[0.68rem] text-ink-muted leading-tight">
|
||||
החילוץ יכול להימשך כמה דקות — שומות ארוכות עוברות ניתוח פסקה אחר
|
||||
פסקה ע"י המודל.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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) {
|
||||
const [event, setEvent] = useState<ProgressEvent | null>(null);
|
||||
|
||||
|
||||
27
web/app.py
27
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)."""
|
||||
|
||||
Reference in New Issue
Block a user