diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index a065dff..5e8f214 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -1007,6 +1007,20 @@ CREATE INDEX IF NOT EXISTS idx_relevance_case_law """ +# ── V19: case_law.citation_formatted ─────────────────────────────── +# Full formal citation per the Israeli unified citation rules ("כללי +# הציטוט האחיד"). Stored as Markdown: parties wrapped in **…** so the +# copy-to-clipboard helper can render bold for Word/Docs while keeping +# the plain-text form readable. +# +# Example: +# ערר (ועדות ערר - תכנון ובנייה ת"א-יפו) 81002-01-21 **אברהם אגסי +# נ' הועדה המקומית לתכנון ובנייה תל אביב** (נבו 25.9.2025) +SCHEMA_V19_SQL = """ +ALTER TABLE case_law ADD COLUMN IF NOT EXISTS citation_formatted TEXT DEFAULT ''; +""" + + async def _run_schema_migrations(pool: asyncpg.Pool) -> None: async with pool.acquire() as conn: await conn.execute(SCHEMA_SQL) @@ -1028,7 +1042,8 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None: await conn.execute(SCHEMA_V16_SQL) await conn.execute(SCHEMA_V17_SQL) await conn.execute(SCHEMA_V18_SQL) - logger.info("Database schema initialized (v1-v18)") + await conn.execute(SCHEMA_V19_SQL) + logger.info("Database schema initialized (v1-v19)") async def init_schema() -> None: @@ -2288,13 +2303,13 @@ async def update_case_law(case_law_id: UUID, **fields) -> dict | None: Allowed fields: case_name, court, date, practice_area, appeal_subtype, subject_tags, summary, headnote, key_quote, source_url, source_type, - precedent_level, is_binding. + precedent_level, is_binding, citation_formatted. """ allowed = { "case_number", "case_name", "court", "date", "practice_area", "appeal_subtype", "subject_tags", "summary", "headnote", "key_quote", "source_url", "source_type", "precedent_level", "is_binding", "district", "chair_name", - "proceeding_type", + "proceeding_type", "citation_formatted", } updates = {k: v for k, v in fields.items() if k in allowed} if not updates: @@ -2405,7 +2420,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, + chair_name, district, citation_formatted, extraction_status, halacha_extraction_status, metadata_extraction_requested_at, halacha_extraction_requested_at, diff --git a/mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py b/mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py index 42acc38..200d89f 100644 --- a/mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py +++ b/mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py @@ -62,7 +62,8 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א "court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.", "case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות.", "chair_name": "שם יו\\\"ר ההרכב — רלוונטי **רק להחלטות ועדת ערר**, לא לפסקי בית משפט. חפש בכותרת/חתימה: 'עו\\\"ד דפנה תמיר, יו\\\"ר ועדת הערר', 'בפני: עו\\\"ד פלוני אלמוני (יו\\\"ר)'. השאר שם פרטי+משפחה בלי תוארים ('עו\\\"ד', 'אדריכל'). אם זה פסק דין של בית משפט — מחרוזת ריקה.", - "district": "מחוז ועדת הערר — רלוונטי **רק להחלטות ועדת ערר**. ערכים מותרים: 'ירושלים', 'תל אביב', 'מרכז', 'חיפה', 'צפון', 'דרום', 'ארצית'. זהה מהכותרת ('ועדת הערר לתכנון ובניה — מחוז ירושלים' → 'ירושלים'; 'ועדות ערר - תכנון ובנייה תל אביב-יפו' → 'תל אביב'). אם זה פסק דין של בית משפט — מחרוזת ריקה." + "district": "מחוז ועדת הערר — רלוונטי **רק להחלטות ועדת ערר**. ערכים מותרים: 'ירושלים', 'תל אביב', 'מרכז', 'חיפה', 'צפון', 'דרום', 'ארצית'. זהה מהכותרת ('ועדת הערר לתכנון ובניה — מחוז ירושלים' → 'ירושלים'; 'ועדות ערר - תכנון ובנייה תל אביב-יפו' → 'תל אביב'). אם זה פסק דין של בית משפט — מחרוזת ריקה.", + "citation_formatted": "המראה מקום המלא לפי **כללי הציטוט האחיד**, בפורמט Markdown — שמות הצדדים בלבד מוקפים בכפול-כוכבית (`**…**`), הכל השאר רגיל. ראה כללים מפורטים בסעיף 12 למטה." } ## כללי איכות @@ -78,6 +79,22 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א 10. **court** — מהכותרת הראשית של הפסק. ניסוח מלא (לא קיצור). מחרוזת ריקה אם לא ניתן לזהות. 11. **proceeding_type** — חובה לזהות עבור החלטות ועדת ערר; ריק עבור פסיקת בית משפט. הסימן הברור: בכותרת הראשונה של המסמך כתוב "ערר (ועדות ערר ...) NNNN/YY" → 'ערר'; "בל\"מ NNNN/YY" או הנושא "בקשה להארכת מועד להגשת ערר" → 'בל\"מ'. שני הסוגים יכולים לחלוק אותו מספר תיק — לכן חשוב להבחין מפורשות. 12. **chair_name / district** — חובה למלא רק עבור החלטות ועדת ערר (source_type='appeals_committee'). chair_name נמצא בכותרת ("בפני: עו\"ד פלוני אלמוני, יו\"ר") או בחתימה. district = מחוז הוועדה, מתוך רשימה סגורה. עבור פסקי בית משפט — שני השדות ריקים. +13. **citation_formatted — כללי הציטוט האחיד הישראלי**. הרכב את המראה מקום במחרוזת אחת בפורמט Markdown, **כשרק שמות הצדדים מודגשים** (מוקפים ב-`**…**`). כל השאר — קיצור הערכאה, סוגריים של הרכב/מחוז, מספר תיק, מאגר/תאריך — **רגיל ללא הדגשה**. + + תבניות לסוגי פסיקה: + * **בית משפט עליון — לא פורסם:** `ע"א 1234/56 **פלוני נ' אלמוני** (נבו 1.2.3456)` + * **בית משפט עליון — פורסם:** `ע"א 1234/56 **פלוני נ' אלמוני**, פ"ד יב(3) 456 (1990)` + * **בית משפט מנהלי:** `עת"מ (י-ם) 1234/56 **פלוני נ' הוועדה** (נבו 1.2.3456)` — "(י-ם)" / "(ת"א)" / וכד' = קיצור המחוז + * **ועדת ערר תכנון ובנייה (מחוזית):** `ערר (ועדות ערר - תכנון ובנייה ת"א-יפו) 81002-01-21 **אברהם אגסי נ' הועדה המקומית לתכנון ובנייה תל אביב** (נבו 25.9.2025)` + * **בל"מ (בקשה להארכת מועד):** `בל"מ (ועדות ערר - ירושלים) 1028/20 **חלוואני ריאד נ' רשות הרישוי - הוועדה המקומית ירושלים** (נבו 7.1.2021)` + * **ועדת ערר ארצית:** `ערר ארצי 8047/23 **פלוני נ' אלמוני** (נבו 1.2.3456)` + + כללים: + - **הצדדים מודגשים בלבד** — כל השאר רגיל. אל תדגיש את "ע"א" / "ערר" / מספר התיק / "(נבו ...)" / "פ"ד". + - הצדדים = מי שמופיע **בין מספר התיק לבין הסוגריים הסופיים** (תאריך/מאגר), כלומר "[עורר/מבקש] נ' [משיב]". + - תאריך בסוגריים סופיים בפורמט עברי "(נבו 25.9.2025)" — יום.חודש.שנה ללא אפסים מובילים. + - אם המאגר הוא נבו והפסיקה לא פורסמה ב-פ"ד — השתמש ב-"(נבו DATE)". אם פורסמה ב-פ"ד — הוסף את ההפניה הפורמלית אחרי הצדדים: `..., פ"ד יב(3) 456 (1990)`. + - אם לא ניתן לזהות איזשהו רכיב במדויק — השאר את **כל** השדה ריק. אל תניח / תמציא. """ @@ -189,6 +206,14 @@ async def extract_metadata(case_law_id: UUID | str) -> dict: # silently storing free-text in what callers treat as a filter facet. if d in {"ירושלים", "תל אביב", "מרכז", "חיפה", "צפון", "דרום", "ארצית"}: out["district"] = d + if isinstance(result.get("citation_formatted"), str): + cf = result["citation_formatted"].strip() + # Sanity check: a valid citation should contain at least one bold + # marker pair (the parties) AND a closing paren (the reporter/date). + # If the LLM returned a half-formed string, drop it rather than + # store junk that the UI then has to special-case. + if cf.count("**") >= 2 and ")" in cf: + out["citation_formatted"] = cf return out @@ -304,6 +329,13 @@ async def apply_to_record( if cn: fields_to_update["case_number"] = cn + # citation_formatted — full citation per Israeli citation rules. Only + # fill if empty; user edits in /precedents/[id] are preserved. + if not (record.get("citation_formatted") or "").strip(): + s = (suggested.get("citation_formatted") or "").strip() + if s: + fields_to_update["citation_formatted"] = s + # chair_name / district — only for internal_committee rows. The DB CHECK # forces these to be non-empty, so the upload endpoint stamps the row # with "(טרם חולץ)" as a placeholder. Treat that placeholder as empty diff --git a/web-ui/src/app/precedents/[id]/page.tsx b/web-ui/src/app/precedents/[id]/page.tsx index d4379bf..7af8c28 100644 --- a/web-ui/src/app/precedents/[id]/page.tsx +++ b/web-ui/src/app/precedents/[id]/page.tsx @@ -2,14 +2,24 @@ import { use, useState } from "react"; import Link from "next/link"; -import { Pencil } from "lucide-react"; +import { Pencil, Check, X } from "lucide-react"; +import { toast } from "sonner"; import { AppShell } from "@/components/app-shell"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; -import { usePrecedent } from "@/lib/api/precedent-library"; +import { Textarea } from "@/components/ui/textarea"; +import { + usePrecedent, + useUpdatePrecedent, + type Precedent, +} from "@/lib/api/precedent-library"; import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet"; +import { + FormattedCitation, + CitationCopyButton, +} from "@/components/precedents/formatted-citation"; import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot"; import { RelatedCasesSection } from "@/components/precedents/link-related-dialog"; @@ -34,6 +44,9 @@ export default function PrecedentDetailPage({ const { id } = use(params); const [editing, setEditing] = useState(false); const { data, isPending, error } = usePrecedent(id); + const update = useUpdatePrecedent(); + const [editingCitation, setEditingCitation] = useState(false); + const [citationDraft, setCitationDraft] = useState(""); return ( @@ -80,6 +93,36 @@ export default function PrecedentDetailPage({ + {/* Citation per Israeli unified citation rules. The LLM + extractor composes this from the document; the chair + can override below. */} + { + setCitationDraft(data.citation_formatted ?? ""); + setEditingCitation(true); + }} + onCancel={() => setEditingCitation(false)} + onChange={setCitationDraft} + onSave={async () => { + try { + await update.mutateAsync({ + id, + patch: { citation_formatted: citationDraft.trim() }, + }); + toast.success("מראה מקום עודכן"); + setEditingCitation(false); + } catch (e) { + toast.error( + e instanceof Error ? e.message : "שמירה נכשלה", + ); + } + }} + saving={update.isPending} + /> +
{data.practice_area ? ( @@ -178,3 +221,109 @@ export default function PrecedentDetailPage({ ); } + +function CitationBlock({ + precedent, + editing, + draft, + onStartEdit, + onCancel, + onChange, + onSave, + saving, +}: { + precedent: Precedent; + editing: boolean; + draft: string; + onStartEdit: () => void; + onCancel: () => void; + onChange: (v: string) => void; + onSave: () => void; + saving: boolean; +}) { + const citation = (precedent.citation_formatted ?? "").trim(); + + if (editing) { + return ( +
+
+ + עריכת מראה מקום + + + הקף את שמות הצדדים בכפול-כוכבית **שם** להדגשה + +
+