feat(precedents): formal citation per Israeli citation rules + copy/edit UI
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:
2026-05-27 07:14:34 +00:00
parent a02a4e3a64
commit cbc7a1e336
7 changed files with 369 additions and 13 deletions

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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>
);
}

View File

@@ -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">