diff --git a/mcp-server/src/legal_mcp/tools/documents.py b/mcp-server/src/legal_mcp/tools/documents.py index 8fa7a38..84614ea 100644 --- a/mcp-server/src/legal_mcp/tools/documents.py +++ b/mcp-server/src/legal_mcp/tools/documents.py @@ -433,17 +433,24 @@ ALLOWED_DOC_TYPES = { # Allowed appraiser_side values; '' (empty) clears the tag. ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding", ""} +# Allowed protocol_scope values (protocol docs only); mirrors web/app.py. +# appeal = ועדת הערר (default when absent) · lower = ועדה מקומית/מחוזית. +ALLOWED_PROTOCOL_SCOPES = {"appeal", "lower"} + async def document_update( case_number: str, doc_id: str, doc_type: str = "", appraiser_side: str = "", + is_post_hearing: bool | None = None, + protocol_scope: str = "", ) -> str: - """עדכון תיוג מסמך — doc_type ו/או appraiser_side. ריק = אין שינוי. + """עדכון תיוג מסמך — doc_type / appraiser_side / is_post_hearing / protocol_scope. - הולידציה זהה ל-PATCH endpoint ב-web/app.py. appraiser_side נשמר ב- - documents.metadata JSONB (מתפרסם משם ע"י extract_appraiser_facts). + הולידציה זהה ל-PATCH endpoint ב-web/app.py. הדגלים נשמרים ב- + documents.metadata JSONB (appraiser_side מתפרסם משם ע"י extract_appraiser_facts; + is_post_hearing נצרך ע"י כותב בלוק-ח; protocol_scope מסווג פרוטוקול בטאב-הסקירה). Args: case_number: מספר תיק הערר (לאישור שייכות) @@ -452,6 +459,9 @@ async def document_update( permit/appraisal/exhibit/objection/reference). ריק = אין שינוי. appraiser_side: ערך חדש (committee/appellant/deciding). ריק = אין שינוי; העבר במפורש מחרוזת ריקה לא-default אם רוצים לנקות. + is_post_hearing: True = התקבל אחרי הדיון · False = מנקה את הדגל · None = אין שינוי. + protocol_scope: היקף פרוטוקול (appeal=ועדת הערר / lower=ועדה מקומית-מחוזית). + ריק = אין שינוי. רלוונטי רק כשהסוג protocol. """ case = await db.get_case_by_number(case_number) if not case: @@ -477,17 +487,37 @@ async def document_update( data={"allowed": sorted(ALLOWED_DOC_TYPES)}) updates["doc_type"] = doc_type - # appraiser_side is optional. The MCP tool can't distinguish "skip" from - # "set to empty string", so we use the convention: only update if non-empty. - # To clear, the operator must edit metadata directly (rare). + # appraiser_side / protocol_scope are optional. The MCP tool can't + # distinguish "skip" from "set to empty string", so we use the convention: + # only update if non-empty. To clear, edit metadata directly (rare). + # is_post_hearing is a tri-state bool: None skips, True/False set/clear. + metadata = doc.get("metadata") or {} + if isinstance(metadata, str): + metadata = json.loads(metadata) + metadata_dirty = False + if appraiser_side: if appraiser_side not in ALLOWED_APPRAISER_SIDES: return err(f"appraiser_side לא תקין: {appraiser_side}", data={"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s)}) - metadata = doc.get("metadata") or {} - if isinstance(metadata, str): - metadata = json.loads(metadata) metadata["appraiser_side"] = appraiser_side + metadata_dirty = True + + if is_post_hearing is not None: + if is_post_hearing: + metadata["is_post_hearing"] = True + else: + metadata.pop("is_post_hearing", None) + metadata_dirty = True + + if protocol_scope: + if protocol_scope not in ALLOWED_PROTOCOL_SCOPES: + return err(f"protocol_scope לא תקין: {protocol_scope}", + data={"allowed": sorted(ALLOWED_PROTOCOL_SCOPES)}) + metadata["protocol_scope"] = protocol_scope + metadata_dirty = True + + if metadata_dirty: updates["metadata"] = metadata if not updates: diff --git a/web-ui/src/components/cases/document-type-editor.tsx b/web-ui/src/components/cases/document-type-editor.tsx index 9dec015..cf42a48 100644 --- a/web-ui/src/components/cases/document-type-editor.tsx +++ b/web-ui/src/components/cases/document-type-editor.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { CheckCircle2, Loader2, Sparkles } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; import { Popover, PopoverContent, @@ -20,6 +21,7 @@ import { APPRAISER_SIDE_LABELS, APPRAISER_SIDE_OPTIONS, DOC_TYPE_OPTIONS, + PROTOCOL_SCOPE_OPTIONS, appraiserSideLabel, doctypeLabel, doctypeTone, @@ -29,14 +31,17 @@ import { import { useExtractAppraiserFacts, usePatchDocument, + type DocumentPatch, type ExtractAppraiserFactsResponse, } from "@/lib/api/documents"; /* * Inline editor for a document's tags. Renders a colored Badge that opens a - * Popover with two Selects: + * Popover with: * 1. doc_type (always shown) - * 2. appraiser_side (only when doc_type === "appraisal") + * 2. appraiser_side (only when doc_type === "appraisal") + * 3. protocol_scope (only when doc_type === "protocol") — ועדת הערר / מקומית + * 4. is_post_hearing toggle (always shown) — moves the doc to overview group 4 * * After a successful save we swap the Popover body to a confirmation view * with a "חלץ עובדות שמאיות עכשיו" button — extraction is expensive so we @@ -49,15 +54,25 @@ export function DocumentTypeEditor({ docId, docType, appraiserSide, + isPostHearing, + protocolScope, }: { caseNumber: string; docId: string; docType: string; appraiserSide?: string; + isPostHearing?: boolean; + protocolScope?: string; }) { + // Effective stored scope: absent === "appeal" (ועדת הערר) by convention. + const storedScope = protocolScope === "lower" ? "lower" : "appeal"; + const storedPost = isPostHearing === true; + const [open, setOpen] = useState(false); const [draftType, setDraftType] = useState(docType || ""); const [draftSide, setDraftSide] = useState(appraiserSide || ""); + const [draftScope, setDraftScope] = useState(storedScope); + const [draftPost, setDraftPost] = useState(storedPost); const [saved, setSaved] = useState(false); const [extractResult, setExtractResult] = useState(null); @@ -67,6 +82,8 @@ export function DocumentTypeEditor({ function reset() { setDraftType(docType || ""); setDraftSide(appraiserSide || ""); + setDraftScope(storedScope); + setDraftPost(storedPost); setSaved(false); setExtractResult(null); patch.reset(); @@ -74,14 +91,17 @@ export function DocumentTypeEditor({ } const isAppraisal = draftType === "appraisal"; + const isProtocol = draftType === "protocol"; const sideMissing = isAppraisal && !draftSide; const dirty = draftType !== docType || - (isAppraisal && draftSide !== (appraiserSide || "")); + (isAppraisal && draftSide !== (appraiserSide || "")) || + (isProtocol && draftScope !== storedScope) || + draftPost !== storedPost; async function handleSave() { if (sideMissing || !dirty) return; - const body: { doc_type?: string; appraiser_side?: string } = {}; + const body: DocumentPatch = {}; if (draftType !== docType) body.doc_type = draftType; if (isAppraisal && draftSide !== (appraiserSide || "")) { body.appraiser_side = draftSide; @@ -90,6 +110,16 @@ export function DocumentTypeEditor({ // clear it so it doesn't dangle confusingly in metadata. if (!isAppraisal && appraiserSide) body.appraiser_side = ""; + // protocol_scope: store only the non-default "lower"; anything else clears + // back to the implicit ועדת-הערר default. Also clear when leaving protocol. + if (isProtocol && draftScope !== storedScope) { + body.protocol_scope = draftScope === "lower" ? "lower" : ""; + } else if (!isProtocol && protocolScope) { + body.protocol_scope = ""; + } + + if (draftPost !== storedPost) body.is_post_hearing = draftPost; + // Swallow the rejection — errors surface via `patch.isError`; an unhandled // rejection from this async click handler would otherwise leak. try { @@ -190,6 +220,46 @@ export function DocumentTypeEditor({ )} + {isProtocol && ( +
+ + +

+ ועדת הערר → קבוצת הפרוטוקול · מקומית/מחוזית → מסמכים נלווים +

+
+ )} + +
+
+ +

+ מעביר את המסמך לקבוצת המסמכים שלאחר הדיון ומזין את בלוק-ח +

+
+ +
+ {patch.isError && (

שמירה נכשלה. נסה שוב. diff --git a/web-ui/src/components/cases/documents-panel.tsx b/web-ui/src/components/cases/documents-panel.tsx index 24924da..9f55a2b 100644 --- a/web-ui/src/components/cases/documents-panel.tsx +++ b/web-ui/src/components/cases/documents-panel.tsx @@ -25,6 +25,13 @@ import { casesKeys } from "@/lib/api/cases"; import type { CaseDetail, CaseDocument } from "@/lib/api/cases"; import { DocumentTypeEditor } from "@/components/cases/document-type-editor"; import { formatDateShort as formatDate } from "@/lib/format-date"; +import { + documentGroup, + DOC_GROUP_ORDER, + DOC_GROUP_LABELS, + DOC_GROUP_HINTS, + type DocGroup, +} from "@/lib/doc-types"; /* * Document list for the case detail "מסמכים" tab. Uses the real document @@ -224,6 +231,13 @@ function DocumentRow({ const canPreview = doc.extraction_status === "completed" || doc.extraction_status === "proofread"; + const meta = doc.metadata as + | { appraiser_side?: string; is_post_hearing?: boolean; protocol_scope?: string } + | undefined; + const isPostHearing = meta?.is_post_hearing === true; + const protocolScope = meta?.protocol_scope; + const isProtocol = doc.doc_type === "protocol"; + return ( <>

  • @@ -246,15 +260,24 @@ function DocumentRow({ {doc.created_at && {formatDate(doc.created_at)}} + {isProtocol && ( + + {protocolScope === "lower" ? "ועדה מקומית" : "ועדת הערר"} + + )} + {isPostHearing && ( + + לאחר הדיון + + )} {doc.doc_type && ( )}