feat(precedents): formal citation per Israeli citation rules + copy/edit UI
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m25s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m25s
Until now, "case_number" was the only stored identifier for a precedent.
But a *citation per the Israeli unified citation rules* is a different
beast — it has bold parties, an unbold prefix (court abbrev + panel/
district parenthetical + case number), and an unbold trailing reporter
(נבו / פ"ד...). Without storing it as a first-class field we couldn't
hand the chair a one-click "copy as citation" experience for pasting
into decisions.
Changes:
- Schema V19: case_law.citation_formatted TEXT (Markdown — parties
wrapped in **…** so the copy helper can render <strong> for Word/Docs
paste and keep plain-text fallback meaningful).
- Metadata extractor: composes citation_formatted from the document
text per the unified citation rules, with worked examples for ע"א /
עת"מ / ערר / בל"מ in the prompt. Refuses to store half-formed strings.
- PATCH /api/precedent-library/{id} accepts citation_formatted so the
chair can correct LLM mistakes.
- /precedents/[id]: dedicated "מראה מקום" block with bold rendering,
a copy-to-clipboard button (text/html + text/plain so Word keeps
the bolds), and an inline edit textarea.
- /precedents list rows: link displays the formatted citation when
available, with a small inline copy button — falls back to the bare
case_number for older rows.
Backfill of existing rows happens by re-stamping the extraction queue
once V19 has rolled out and the new field is reachable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
await conn.execute(SCHEMA_SQL)
|
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_V16_SQL)
|
||||||
await conn.execute(SCHEMA_V17_SQL)
|
await conn.execute(SCHEMA_V17_SQL)
|
||||||
await conn.execute(SCHEMA_V18_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:
|
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,
|
Allowed fields: case_name, court, date, practice_area, appeal_subtype,
|
||||||
subject_tags, summary, headnote, key_quote, source_url, source_type,
|
subject_tags, summary, headnote, key_quote, source_url, source_type,
|
||||||
precedent_level, is_binding.
|
precedent_level, is_binding, citation_formatted.
|
||||||
"""
|
"""
|
||||||
allowed = {
|
allowed = {
|
||||||
"case_number", "case_name", "court", "date", "practice_area", "appeal_subtype",
|
"case_number", "case_name", "court", "date", "practice_area", "appeal_subtype",
|
||||||
"subject_tags", "summary", "headnote", "key_quote", "source_url",
|
"subject_tags", "summary", "headnote", "key_quote", "source_url",
|
||||||
"source_type", "precedent_level", "is_binding", "district", "chair_name",
|
"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}
|
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||||
if not updates:
|
if not updates:
|
||||||
@@ -2405,7 +2420,7 @@ async def list_external_case_law(
|
|||||||
SELECT id, case_number, case_name, court, date, practice_area,
|
SELECT id, case_number, case_name, court, date, practice_area,
|
||||||
appeal_subtype, source_type, precedent_level, is_binding,
|
appeal_subtype, source_type, precedent_level, is_binding,
|
||||||
summary, headnote, subject_tags, source_kind,
|
summary, headnote, subject_tags, source_kind,
|
||||||
chair_name, district,
|
chair_name, district, citation_formatted,
|
||||||
extraction_status, halacha_extraction_status,
|
extraction_status, halacha_extraction_status,
|
||||||
metadata_extraction_requested_at,
|
metadata_extraction_requested_at,
|
||||||
halacha_extraction_requested_at,
|
halacha_extraction_requested_at,
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
|
|||||||
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.",
|
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.",
|
||||||
"case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות.",
|
"case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות.",
|
||||||
"chair_name": "שם יו\\\"ר ההרכב — רלוונטי **רק להחלטות ועדת ערר**, לא לפסקי בית משפט. חפש בכותרת/חתימה: 'עו\\\"ד דפנה תמיר, יו\\\"ר ועדת הערר', 'בפני: עו\\\"ד פלוני אלמוני (יו\\\"ר)'. השאר שם פרטי+משפחה בלי תוארים ('עו\\\"ד', 'אדריכל'). אם זה פסק דין של בית משפט — מחרוזת ריקה.",
|
"chair_name": "שם יו\\\"ר ההרכב — רלוונטי **רק להחלטות ועדת ערר**, לא לפסקי בית משפט. חפש בכותרת/חתימה: 'עו\\\"ד דפנה תמיר, יו\\\"ר ועדת הערר', 'בפני: עו\\\"ד פלוני אלמוני (יו\\\"ר)'. השאר שם פרטי+משפחה בלי תוארים ('עו\\\"ד', 'אדריכל'). אם זה פסק דין של בית משפט — מחרוזת ריקה.",
|
||||||
"district": "מחוז ועדת הערר — רלוונטי **רק להחלטות ועדת ערר**. ערכים מותרים: 'ירושלים', 'תל אביב', 'מרכז', 'חיפה', 'צפון', 'דרום', 'ארצית'. זהה מהכותרת ('ועדת הערר לתכנון ובניה — מחוז ירושלים' → 'ירושלים'; 'ועדות ערר - תכנון ובנייה תל אביב-יפו' → 'תל אביב'). אם זה פסק דין של בית משפט — מחרוזת ריקה."
|
"district": "מחוז ועדת הערר — רלוונטי **רק להחלטות ועדת ערר**. ערכים מותרים: 'ירושלים', 'תל אביב', 'מרכז', 'חיפה', 'צפון', 'דרום', 'ארצית'. זהה מהכותרת ('ועדת הערר לתכנון ובניה — מחוז ירושלים' → 'ירושלים'; 'ועדות ערר - תכנון ובנייה תל אביב-יפו' → 'תל אביב'). אם זה פסק דין של בית משפט — מחרוזת ריקה.",
|
||||||
|
"citation_formatted": "המראה מקום המלא לפי **כללי הציטוט האחיד**, בפורמט Markdown — שמות הצדדים בלבד מוקפים בכפול-כוכבית (`**…**`), הכל השאר רגיל. ראה כללים מפורטים בסעיף 12 למטה."
|
||||||
}
|
}
|
||||||
|
|
||||||
## כללי איכות
|
## כללי איכות
|
||||||
@@ -78,6 +79,22 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
|
|||||||
10. **court** — מהכותרת הראשית של הפסק. ניסוח מלא (לא קיצור). מחרוזת ריקה אם לא ניתן לזהות.
|
10. **court** — מהכותרת הראשית של הפסק. ניסוח מלא (לא קיצור). מחרוזת ריקה אם לא ניתן לזהות.
|
||||||
11. **proceeding_type** — חובה לזהות עבור החלטות ועדת ערר; ריק עבור פסיקת בית משפט. הסימן הברור: בכותרת הראשונה של המסמך כתוב "ערר (ועדות ערר ...) NNNN/YY" → 'ערר'; "בל\"מ NNNN/YY" או הנושא "בקשה להארכת מועד להגשת ערר" → 'בל\"מ'. שני הסוגים יכולים לחלוק אותו מספר תיק — לכן חשוב להבחין מפורשות.
|
11. **proceeding_type** — חובה לזהות עבור החלטות ועדת ערר; ריק עבור פסיקת בית משפט. הסימן הברור: בכותרת הראשונה של המסמך כתוב "ערר (ועדות ערר ...) NNNN/YY" → 'ערר'; "בל\"מ NNNN/YY" או הנושא "בקשה להארכת מועד להגשת ערר" → 'בל\"מ'. שני הסוגים יכולים לחלוק אותו מספר תיק — לכן חשוב להבחין מפורשות.
|
||||||
12. **chair_name / district** — חובה למלא רק עבור החלטות ועדת ערר (source_type='appeals_committee'). chair_name נמצא בכותרת ("בפני: עו\"ד פלוני אלמוני, יו\"ר") או בחתימה. district = מחוז הוועדה, מתוך רשימה סגורה. עבור פסקי בית משפט — שני השדות ריקים.
|
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.
|
# silently storing free-text in what callers treat as a filter facet.
|
||||||
if d in {"ירושלים", "תל אביב", "מרכז", "חיפה", "צפון", "דרום", "ארצית"}:
|
if d in {"ירושלים", "תל אביב", "מרכז", "חיפה", "צפון", "דרום", "ארצית"}:
|
||||||
out["district"] = d
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -304,6 +329,13 @@ async def apply_to_record(
|
|||||||
if cn:
|
if cn:
|
||||||
fields_to_update["case_number"] = 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
|
# chair_name / district — only for internal_committee rows. The DB CHECK
|
||||||
# forces these to be non-empty, so the upload endpoint stamps the row
|
# forces these to be non-empty, so the upload endpoint stamps the row
|
||||||
# with "(טרם חולץ)" as a placeholder. Treat that placeholder as empty
|
# with "(טרם חולץ)" as a placeholder. Treat that placeholder as empty
|
||||||
|
|||||||
@@ -2,14 +2,24 @@
|
|||||||
|
|
||||||
import { use, useState } from "react";
|
import { use, useState } from "react";
|
||||||
import Link from "next/link";
|
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 { AppShell } from "@/components/app-shell";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
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 { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet";
|
||||||
|
import {
|
||||||
|
FormattedCitation,
|
||||||
|
CitationCopyButton,
|
||||||
|
} from "@/components/precedents/formatted-citation";
|
||||||
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
|
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
|
||||||
import { RelatedCasesSection } from "@/components/precedents/link-related-dialog";
|
import { RelatedCasesSection } from "@/components/precedents/link-related-dialog";
|
||||||
|
|
||||||
@@ -34,6 +44,9 @@ export default function PrecedentDetailPage({
|
|||||||
const { id } = use(params);
|
const { id } = use(params);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const { data, isPending, error } = usePrecedent(id);
|
const { data, isPending, error } = usePrecedent(id);
|
||||||
|
const update = useUpdatePrecedent();
|
||||||
|
const [editingCitation, setEditingCitation] = useState(false);
|
||||||
|
const [citationDraft, setCitationDraft] = useState("");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
@@ -80,6 +93,36 @@ export default function PrecedentDetailPage({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Citation per Israeli unified citation rules. The LLM
|
||||||
|
extractor composes this from the document; the chair
|
||||||
|
can override below. */}
|
||||||
|
<CitationBlock
|
||||||
|
precedent={data as Precedent}
|
||||||
|
editing={editingCitation}
|
||||||
|
draft={citationDraft}
|
||||||
|
onStartEdit={() => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{data.practice_area ? (
|
{data.practice_area ? (
|
||||||
<Badge variant="outline" className="text-[0.7rem]">
|
<Badge variant="outline" className="text-[0.7rem]">
|
||||||
@@ -178,3 +221,109 @@ export default function PrecedentDetailPage({
|
|||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="rounded-md border border-gold/40 bg-gold-wash/30 p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[0.78rem] font-semibold text-navy">
|
||||||
|
עריכת מראה מקום
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.7rem] text-ink-muted">
|
||||||
|
הקף את שמות הצדדים בכפול-כוכבית <code className="font-mono">**שם**</code> להדגשה
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
dir="rtl"
|
||||||
|
className="font-mono text-sm"
|
||||||
|
placeholder='ערר (ועדות ערר ...) 1234/24 **עורר נ' הוועדה המקומית** (נבו 1.2.2025)'
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving || !draft.trim()}
|
||||||
|
className="bg-navy text-parchment hover:bg-navy-soft"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5 me-1" />
|
||||||
|
שמור
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5 me-1" />
|
||||||
|
ביטול
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!citation) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-dashed border-rule bg-rule-soft/30 p-3 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[0.78rem] text-ink-muted">
|
||||||
|
מראה מקום (כללי הציטוט האחיד) — טרם חולץ
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={onStartEdit}>
|
||||||
|
<Pencil className="w-3.5 h-3.5 me-1" />
|
||||||
|
הוסף ידנית
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-rule bg-parchment-50 p-3 space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[0.7rem] uppercase tracking-wide text-ink-muted">
|
||||||
|
מראה מקום
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<CitationCopyButton citation={citation} size="xs" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onStartEdit}
|
||||||
|
title="ערוך מראה מקום"
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-rule bg-surface hover:bg-rule-soft/50 text-ink h-7 px-2 text-[0.72rem]"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
ערוך
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormattedCitation
|
||||||
|
citation={citation}
|
||||||
|
className="block text-navy text-sm leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
121
web-ui/src/components/precedents/formatted-citation.tsx
Normal file
121
web-ui/src/components/precedents/formatted-citation.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Rendering helpers for the formal Israeli citation ("כללי הציטוט האחיד").
|
||||||
|
//
|
||||||
|
// Backend stores the citation as Markdown: parties' names wrapped in
|
||||||
|
// **double asterisks**, everything else regular. These helpers:
|
||||||
|
// 1. Render the citation with <strong> for the bold ranges.
|
||||||
|
// 2. Copy it to the clipboard as BOTH text/html (so Word/Docs paste
|
||||||
|
// with bold preserved) and text/plain (which keeps the markers
|
||||||
|
// so the markdown survives a plain-text paste).
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Check, Copy } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
function parseSegments(md: string): Array<{ bold: boolean; text: string }> {
|
||||||
|
const out: Array<{ bold: boolean; text: string }> = [];
|
||||||
|
const re = /\*\*([^*]+)\*\*/g;
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(md)) !== null) {
|
||||||
|
if (m.index > last) out.push({ bold: false, text: md.slice(last, m.index) });
|
||||||
|
out.push({ bold: true, text: m[1] });
|
||||||
|
last = re.lastIndex;
|
||||||
|
}
|
||||||
|
if (last < md.length) out.push({ bold: false, text: md.slice(last) });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormattedCitation({
|
||||||
|
citation,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
citation: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const segments = parseSegments(citation);
|
||||||
|
return (
|
||||||
|
<span className={className} dir="rtl">
|
||||||
|
{segments.map((s, i) =>
|
||||||
|
s.bold ? (
|
||||||
|
<strong key={i} className="font-semibold">
|
||||||
|
{s.text}
|
||||||
|
</strong>
|
||||||
|
) : (
|
||||||
|
<span key={i}>{s.text}</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CitationCopyButton({
|
||||||
|
citation,
|
||||||
|
size = "sm",
|
||||||
|
}: {
|
||||||
|
citation: string;
|
||||||
|
size?: "sm" | "xs";
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
const segments = parseSegments(citation);
|
||||||
|
const html = segments
|
||||||
|
.map((s) =>
|
||||||
|
s.bold
|
||||||
|
? `<strong>${escapeHtml(s.text)}</strong>`
|
||||||
|
: escapeHtml(s.text),
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
const wrappedHtml = `<span dir="rtl">${html}</span>`;
|
||||||
|
try {
|
||||||
|
const cb = navigator.clipboard;
|
||||||
|
if (typeof ClipboardItem !== "undefined" && cb && "write" in cb) {
|
||||||
|
const item = new ClipboardItem({
|
||||||
|
"text/html": new Blob([wrappedHtml], { type: "text/html" }),
|
||||||
|
"text/plain": new Blob([citation], { type: "text/plain" }),
|
||||||
|
});
|
||||||
|
await cb.write([item]);
|
||||||
|
} else {
|
||||||
|
await cb.writeText(citation);
|
||||||
|
}
|
||||||
|
setCopied(true);
|
||||||
|
toast.success("המראה מקום הועתק (עם הדגשה לצדדים)");
|
||||||
|
window.setTimeout(() => setCopied(false), 1800);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("citation copy failed", err);
|
||||||
|
toast.error("העתקה נכשלה");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dims = size === "xs" ? "h-7 px-2 text-[0.72rem]" : "h-8 px-2.5 text-xs";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
title="העתק לפי כללי הציטוט (עם הדגשה לצדדים)"
|
||||||
|
className={`inline-flex items-center gap-1 rounded-md border border-rule bg-surface hover:bg-rule-soft/50 text-ink ${dims}`}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-3.5 h-3.5 text-emerald-600" />
|
||||||
|
הועתק
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
העתק
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,10 @@ import {
|
|||||||
import { PRACTICE_AREAS, practiceAreaShort } from "./practice-area";
|
import { PRACTICE_AREAS, practiceAreaShort } from "./practice-area";
|
||||||
import { PrecedentUploadSheet } from "./precedent-upload-sheet";
|
import { PrecedentUploadSheet } from "./precedent-upload-sheet";
|
||||||
import { PrecedentEditSheet } from "./precedent-edit-sheet";
|
import { PrecedentEditSheet } from "./precedent-edit-sheet";
|
||||||
|
import {
|
||||||
|
FormattedCitation,
|
||||||
|
CitationCopyButton,
|
||||||
|
} from "./formatted-citation";
|
||||||
|
|
||||||
function formatDate(iso: string | null) {
|
function formatDate(iso: string | null) {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
@@ -152,9 +156,25 @@ function CourtRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => void })
|
|||||||
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[280px] max-w-[420px] py-3"
|
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[280px] max-w-[420px] py-3"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
>
|
>
|
||||||
<Link href={`/precedents/${p.id}`} className="hover:underline hover:text-gold-deep" dir="auto">
|
<div className="flex items-start justify-between gap-2">
|
||||||
{cleanCitation(p.case_number)}
|
<Link
|
||||||
|
href={`/precedents/${p.id}`}
|
||||||
|
className="hover:underline hover:text-gold-deep block min-w-0"
|
||||||
|
dir="auto"
|
||||||
|
>
|
||||||
|
{p.citation_formatted ? (
|
||||||
|
<FormattedCitation
|
||||||
|
citation={p.citation_formatted}
|
||||||
|
className="block leading-snug"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
cleanCitation(p.case_number)
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
{p.citation_formatted ? (
|
||||||
|
<CitationCopyButton citation={p.citation_formatted} size="xs" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{/* Column "שם / ערכאה" hidden by request (case_name often equals case_number prefix). Keep field in DB; restore by un-hiding. */}
|
{/* Column "שם / ערכאה" hidden by request (case_name often equals case_number prefix). Keep field in DB; restore by un-hiding. */}
|
||||||
<TableCell className="hidden text-ink whitespace-normal break-words max-w-[260px] py-3">
|
<TableCell className="hidden text-ink whitespace-normal break-words max-w-[260px] py-3">
|
||||||
@@ -237,9 +257,25 @@ function CommitteeRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => voi
|
|||||||
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[200px] max-w-[320px] py-3"
|
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[200px] max-w-[320px] py-3"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
>
|
>
|
||||||
<Link href={`/precedents/${p.id}`} className="hover:underline hover:text-gold-deep" dir="auto">
|
<div className="flex items-start justify-between gap-2">
|
||||||
{cleanCitation(p.case_number)}
|
<Link
|
||||||
|
href={`/precedents/${p.id}`}
|
||||||
|
className="hover:underline hover:text-gold-deep block min-w-0"
|
||||||
|
dir="auto"
|
||||||
|
>
|
||||||
|
{p.citation_formatted ? (
|
||||||
|
<FormattedCitation
|
||||||
|
citation={p.citation_formatted}
|
||||||
|
className="block leading-snug"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
cleanCitation(p.case_number)
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
{p.citation_formatted ? (
|
||||||
|
<CitationCopyButton citation={p.citation_formatted} size="xs" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{/* Column "שם" hidden by request (case_name often equals case_number prefix). Keep field in DB; restore by un-hiding. */}
|
{/* Column "שם" hidden by request (case_name often equals case_number prefix). Keep field in DB; restore by un-hiding. */}
|
||||||
<TableCell className="hidden text-ink whitespace-normal break-words max-w-[220px] py-3">
|
<TableCell className="hidden text-ink whitespace-normal break-words max-w-[220px] py-3">
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export type Precedent = {
|
|||||||
source_kind: string;
|
source_kind: string;
|
||||||
chair_name: string | null;
|
chair_name: string | null;
|
||||||
district: string | null;
|
district: string | null;
|
||||||
|
citation_formatted: string;
|
||||||
extraction_status: string;
|
extraction_status: string;
|
||||||
halacha_extraction_status: string;
|
halacha_extraction_status: string;
|
||||||
metadata_extraction_requested_at: string | null;
|
metadata_extraction_requested_at: string | null;
|
||||||
@@ -416,6 +417,7 @@ export type PrecedentPatch = Partial<{
|
|||||||
is_binding: boolean;
|
is_binding: boolean;
|
||||||
district: string;
|
district: string;
|
||||||
chair_name: string;
|
chair_name: string;
|
||||||
|
citation_formatted: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export function useUpdatePrecedent() {
|
export function useUpdatePrecedent() {
|
||||||
|
|||||||
@@ -4454,6 +4454,7 @@ class PrecedentUpdateRequest(BaseModel):
|
|||||||
is_binding: bool | None = None
|
is_binding: bool | None = None
|
||||||
district: str | None = None
|
district: str | None = None
|
||||||
chair_name: str | None = None
|
chair_name: str | None = None
|
||||||
|
citation_formatted: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class HalachaUpdateRequest(BaseModel):
|
class HalachaUpdateRequest(BaseModel):
|
||||||
|
|||||||
Reference in New Issue
Block a user