From c0f67ab841e36f8bb968f592269e3b7d0d54bb80 Mon Sep 17 00:00:00 2001 From: Chaim Date: Mon, 4 May 2026 18:49:32 +0000 Subject: [PATCH] feat(precedents): split library into court rulings + appeals committee tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/precedent-library now accepts source_kind param (default external_upload) - list_external_case_law returns chair_name/district fields - LibraryListPanel renders two separate tables with appropriate columns - internal_decisions migration: added queue_halachot param to defer extraction - Fixed practice_area mapping from style_corpus (appeals_committee → proper enum) Co-Authored-By: Claude Sonnet 4.6 --- mcp-server/src/legal_mcp/services/db.py | 4 +- .../legal_mcp/services/internal_decisions.py | 21 +- .../precedents/library-list-panel.tsx | 317 +++++++++++------- web-ui/src/lib/api/precedent-library.ts | 4 + web/app.py | 4 +- 5 files changed, 221 insertions(+), 129 deletions(-) diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index e98e103..8e23da4 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -1982,10 +1982,11 @@ async def list_external_case_law( search: str = "", limit: int = 100, offset: int = 0, + source_kind: str = "external_upload", ) -> list[dict]: """List chair-uploaded precedents, with simple filters.""" pool = await get_pool() - conditions = ["source_kind = 'external_upload'"] + conditions = [f"source_kind = '{source_kind}'"] params: list = [] idx = 1 if practice_area: @@ -2017,6 +2018,7 @@ async def list_external_case_law( SELECT id, case_number, case_name, court, date, practice_area, appeal_subtype, source_type, precedent_level, is_binding, summary, headnote, subject_tags, source_kind, + chair_name, district, extraction_status, halacha_extraction_status, metadata_extraction_requested_at, halacha_extraction_requested_at, diff --git a/mcp-server/src/legal_mcp/services/internal_decisions.py b/mcp-server/src/legal_mcp/services/internal_decisions.py index 98b4032..195d944 100644 --- a/mcp-server/src/legal_mcp/services/internal_decisions.py +++ b/mcp-server/src/legal_mcp/services/internal_decisions.py @@ -85,6 +85,7 @@ async def ingest_internal_decision( file_path: str | Path | None = None, text: str | None = None, document_id: UUID | None = None, + queue_halachot: bool = True, ) -> dict: """Ingest an appeals-committee decision into the internal corpus. @@ -158,7 +159,8 @@ async def ingest_internal_decision( await db.set_case_law_extraction_status(case_law_id, "completed") await db.set_case_law_halacha_status(case_law_id, "pending") - await db.request_halacha_extraction(case_law_id) + if queue_halachot: + await db.request_halacha_extraction(case_law_id) return { "status": "completed", @@ -173,7 +175,7 @@ async def ingest_internal_decision( raise -async def migrate_from_style_corpus(dry_run: bool = False) -> dict: +async def migrate_from_style_corpus(dry_run: bool = False, queue_halachot: bool = True) -> dict: """Re-index all style_corpus entries as searchable internal committee decisions. Does NOT delete style_corpus rows — they remain for style analysis. @@ -211,16 +213,27 @@ async def migrate_from_style_corpus(dry_run: bool = False) -> dict: try: subject_tags = list(row["subject_categories"] or []) + raw_pa = row["practice_area"] or "" + subtype = row["appeal_subtype"] or "" + # style_corpus stores 'appeals_committee' (source_type) instead of practice_area + _subtype_to_pa = { + "building_permit": "rishuy_uvniya", + "betterment_levy": "betterment_levy", + "compensation_197": "compensation_197", + } + practice_area = raw_pa if raw_pa in ("rishuy_uvniya", "betterment_levy", "compensation_197") \ + else _subtype_to_pa.get(subtype, "") await ingest_internal_decision( case_number=case_number, court="ועדת הערר לתכנון ובנייה — מחוז ירושלים", decision_date=row["decision_date"], chair_name="דפנה תמיר", district="ירושלים", - practice_area=row["practice_area"] or "", - appeal_subtype=row["appeal_subtype"] or "", + practice_area=practice_area, + appeal_subtype=subtype, subject_tags=subject_tags, text=row["full_text"], + queue_halachot=queue_halachot, ) results["ingested"] += 1 logger.info("Migrated style_corpus entry: %s", case_number) diff --git a/web-ui/src/components/precedents/library-list-panel.tsx b/web-ui/src/components/precedents/library-list-panel.tsx index 4ae9866..ffeee82 100644 --- a/web-ui/src/components/precedents/library-list-panel.tsx +++ b/web-ui/src/components/precedents/library-list-panel.tsx @@ -20,7 +20,7 @@ import { type Precedent, type PracticeArea, } from "@/lib/api/precedent-library"; -import { PRACTICE_AREAS, PRECEDENT_LEVELS, practiceAreaShort } from "./practice-area"; +import { PRACTICE_AREAS, practiceAreaShort } from "./practice-area"; import { PrecedentUploadSheet } from "./precedent-upload-sheet"; import { PrecedentEditSheet } from "./precedent-edit-sheet"; @@ -33,17 +33,11 @@ function formatDate(iso: string | null) { } } -/* The upload form (and Nevo PDFs) embed Unicode bidi marks (RTL/LTR/embedding/ - * isolate) inside the citation. They render as zero-width but visually push - * the text away from the cell edge. Strip them for display only — DB still - * has the original. */ function cleanCitation(s: string | null | undefined): string { if (!s) return "—"; return s.replace(/[‎‏‪-‮⁦-⁩]/g, "").trim(); } -/* Shimmering pill — used while extraction is actively running. - * Visually distinct from the static "queued" / "completed" pills. */ function ActivePill({ label }: { label: string }) { return ( ); } - // halacha_extraction_status === "completed" if (p.halachot_count === 0) { return ללא הלכות; } return ( - + {p.approved_count}/{p.halachot_count} מאושרות ); } -function PrecedentRow({ - p, onEdit, -}: { - p: Precedent; - onEdit: (id: string) => void; -}) { +function CourtRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => void }) { const del = useDeletePrecedent(); const active = isPrecedentActive(p); const onDelete = async () => { if (active) { - toast.error( - "מתבצע עיבוד — לא ניתן למחוק עכשיו. המתיני לסיום או רעני את הדף.", - ); + toast.error("מתבצע עיבוד — לא ניתן למחוק עכשיו."); return; } - if (!window.confirm(`למחוק את ${p.case_number}? cascade ימחק את ה-chunks וההלכות.`)) return; + if (!window.confirm(`למחוק את ${p.case_number}?`)) return; try { await del.mutateAsync(p.id); toast.success("נמחק"); @@ -145,12 +122,6 @@ function PrecedentRow({ return ( @@ -158,49 +129,32 @@ function PrecedentRow({
{cleanCitation(p.case_name)}
- {p.court ? ( -
{p.court}
- ) : null} -
- - {p.date ? formatDate(p.date) : } + {p.court ?
{p.court}
: null}
+ {p.date ? formatDate(p.date) : } {p.practice_area ? ( {practiceAreaShort(p.practice_area)} - ) : ( - - )} + ) : } - {p.precedent_level ? ( - p.precedent_level - ) : ( - - )} - - - + {p.precedent_level || } +
- -
@@ -209,33 +163,112 @@ function PrecedentRow({ ); } +function CommitteeRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => void }) { + const del = useDeletePrecedent(); + const active = isPrecedentActive(p); + + const onDelete = async () => { + if (active) { + toast.error("מתבצע עיבוד — לא ניתן למחוק עכשיו."); + return; + } + if (!window.confirm(`למחוק את ${p.case_number}?`)) return; + try { + await del.mutateAsync(p.id); + toast.success("נמחק"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "שגיאה"); + } + }; + + return ( + + + {cleanCitation(p.case_number)} + + +
{cleanCitation(p.case_name)}
+
+ + {p.district || } + + + {p.chair_name || } + + {p.date ? formatDate(p.date) : } + + {p.practice_area ? ( + + {practiceAreaShort(p.practice_area)} + + ) : } + + + +
+ + +
+
+
+ ); +} + +function TableSkeleton({ cols }: { cols: number }) { + return ( + <> + {[...Array(4)].map((_, i) => ( + + {[...Array(cols)].map((_, j) => ( + + ))} + + ))} + + ); +} + export function LibraryListPanel() { const [practiceArea, setPracticeArea] = useState(""); - const [precedentLevel, setPrecedentLevel] = useState(""); const [search, setSearch] = useState(""); const [uploadOpen, setUploadOpen] = useState(false); const [editingId, setEditingId] = useState(null); - const { data, isPending, error } = usePrecedents({ + const sharedFilters = { practiceArea: practiceArea || undefined, - precedentLevel: precedentLevel || undefined, search: search.trim() || undefined, limit: 200, - }); + }; + + const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload" }); + const committee = usePrecedents({ ...sharedFilters, sourceKind: "internal_committee" }); return ( -
+
+ {/* Shared filters */}
- + setSearch(e.target.value)} - placeholder="עע"מ 3975/22" + placeholder="עע״מ 3975/22 / 1200-25" dir="rtl" />
-
- -
- - -
-
- {error ? ( -
- {error.message} + {/* Table 1 — Court rulings */} +
+
+

פסיקת בתי משפט

+ {courts.data && ( + + {courts.data.count} + + )}
- ) : ( -
- - - - מס׳ / מראה מקום - שם / ערכאה - תאריך - תחום - רמה - הלכות - - - - - {isPending ? ( - [...Array(5)].map((_, i) => ( - - {[...Array(7)].map((_, j) => ( - - - - ))} - - )) - ) : !data?.items.length ? ( + {courts.error ? ( +
+ {courts.error.message} +
+ ) : ( +
+
+ - - אין פסיקה בקורפוס. העלה את פסק הדין הראשון. - + מס׳ / מראה מקום + שם / ערכאה + תאריך + תחום + רמה + הלכות + - ) : ( - data.items.map((p) => ( - - )) - )} - -
+ + + {courts.isPending ? ( + + ) : !courts.data?.items.length ? ( + + + אין פסיקת בתי משפט בקורפוס. + + + ) : ( + courts.data.items.map((p) => ( + + )) + )} + + +
+ )} +
+ + {/* Table 2 — Appeals committee decisions */} +
+
+

החלטות ועדות ערר

+ {committee.data && ( + + {committee.data.count} + + )}
- )} + {committee.error ? ( +
+ {committee.error.message} +
+ ) : ( +
+ + + + מספר ערר + שם + מחוז + יו״ר + תאריך + תחום + הלכות + + + + + {committee.isPending ? ( + + ) : !committee.data?.items.length ? ( + + + אין החלטות ועדת ערר בקורפוס. + + + ) : ( + committee.data.items.map((p) => ( + + )) + )} + +
+
+ )} +