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:
@@ -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 (
|
||||
<AppShell>
|
||||
@@ -80,6 +93,36 @@ export default function PrecedentDetailPage({
|
||||
</Button>
|
||||
</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">
|
||||
{data.practice_area ? (
|
||||
<Badge variant="outline" className="text-[0.7rem]">
|
||||
@@ -178,3 +221,109 @@ export default function PrecedentDetailPage({
|
||||
</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 { PrecedentUploadSheet } from "./precedent-upload-sheet";
|
||||
import { PrecedentEditSheet } from "./precedent-edit-sheet";
|
||||
import {
|
||||
FormattedCitation,
|
||||
CitationCopyButton,
|
||||
} from "./formatted-citation";
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
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"
|
||||
dir="rtl"
|
||||
>
|
||||
<Link href={`/precedents/${p.id}`} className="hover:underline hover:text-gold-deep" dir="auto">
|
||||
{cleanCitation(p.case_number)}
|
||||
</Link>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<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>
|
||||
{p.citation_formatted ? (
|
||||
<CitationCopyButton citation={p.citation_formatted} size="xs" />
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* 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">
|
||||
@@ -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"
|
||||
dir="rtl"
|
||||
>
|
||||
<Link href={`/precedents/${p.id}`} className="hover:underline hover:text-gold-deep" dir="auto">
|
||||
{cleanCitation(p.case_number)}
|
||||
</Link>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<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>
|
||||
{p.citation_formatted ? (
|
||||
<CitationCopyButton citation={p.citation_formatted} size="xs" />
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* 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">
|
||||
|
||||
@@ -48,6 +48,7 @@ export type Precedent = {
|
||||
source_kind: string;
|
||||
chair_name: string | null;
|
||||
district: string | null;
|
||||
citation_formatted: string;
|
||||
extraction_status: string;
|
||||
halacha_extraction_status: string;
|
||||
metadata_extraction_requested_at: string | null;
|
||||
@@ -416,6 +417,7 @@ export type PrecedentPatch = Partial<{
|
||||
is_binding: boolean;
|
||||
district: string;
|
||||
chair_name: string;
|
||||
citation_formatted: string;
|
||||
}>;
|
||||
|
||||
export function useUpdatePrecedent() {
|
||||
|
||||
Reference in New Issue
Block a user