feat(precedents): surface auto-detected incoming citations in the "ציטוטים מקושרים" panel
The precedent-detail "ציטוטים מקושרים" panel rendered only MANUAL appeal-chain links (case_law_relations), so it stayed empty even when decisions cite the ruling — the automatic citation graph (precedent_internal_citations) was never surfaced. e.g. עע"מ 317/10 (שפר) has 12 incoming citations in the DB, none shown. Fills the existing panel from the citation graph (data/logic only, no new visual design — gate-exempt per web-ui/AGENTS.md): - citation_extractor.list_citations_to_case_law: return source precedent_level/ court/date too (additive; reuses the one canonical incoming query — G2). - precedent_library.get_precedent: add incoming_citations[], shaped like the RelatedCase row. No parallel resolution path. - web-ui: render incoming citations in the same row style, read-only (no unlink), de-duped against manual links; manual "קשר" linking preserved alongside. Invariants: G2 (single citation-resolution path, reused), G1 (graph self-heals via existing relink_orphan_citations on upload). No schema change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -404,7 +404,10 @@ async def list_citations_to_case_law(case_law_id: UUID) -> list[dict]:
|
|||||||
cl.case_number AS source_case_number,
|
cl.case_number AS source_case_number,
|
||||||
cl.case_name AS source_case_name,
|
cl.case_name AS source_case_name,
|
||||||
cl.chair_name AS source_chair_name,
|
cl.chair_name AS source_chair_name,
|
||||||
cl.district AS source_district
|
cl.district AS source_district,
|
||||||
|
cl.precedent_level AS source_precedent_level,
|
||||||
|
cl.court AS source_court,
|
||||||
|
cl.date AS source_date
|
||||||
FROM precedent_internal_citations pic
|
FROM precedent_internal_citations pic
|
||||||
JOIN case_law cl ON cl.id = pic.source_case_law_id
|
JOIN case_law cl ON cl.id = pic.source_case_law_id
|
||||||
WHERE pic.cited_case_law_id = $1
|
WHERE pic.cited_case_law_id = $1
|
||||||
|
|||||||
@@ -411,6 +411,28 @@ async def get_precedent(case_law_id: UUID | str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
|
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
|
||||||
record["related_cases"] = await db.get_case_law_relations(case_law_id)
|
record["related_cases"] = await db.get_case_law_relations(case_law_id)
|
||||||
|
# Auto-detected citation graph: which decisions cite THIS ruling (incoming
|
||||||
|
# edges in precedent_internal_citations). Reuses the canonical query (G2) —
|
||||||
|
# no parallel resolution path. Shaped to match the front-end RelatedCase row
|
||||||
|
# so the existing "ציטוטים מקושרים" panel renders them with no visual change.
|
||||||
|
from legal_mcp.services import citation_extractor as _ce
|
||||||
|
raw = await _ce.list_citations_to_case_law(case_law_id)
|
||||||
|
record["incoming_citations"] = [
|
||||||
|
{
|
||||||
|
"id": r["source_case_law_id"],
|
||||||
|
"case_number": r.get("source_case_number") or r.get("cited_case_number") or "",
|
||||||
|
"case_name": r.get("source_case_name") or "",
|
||||||
|
"court": r.get("source_court") or "",
|
||||||
|
"precedent_level": r.get("source_precedent_level") or "",
|
||||||
|
"chair_name": r.get("source_chair_name") or "",
|
||||||
|
"date": (
|
||||||
|
r["source_date"].isoformat()
|
||||||
|
if r.get("source_date") is not None else None
|
||||||
|
),
|
||||||
|
"confidence": r.get("confidence"),
|
||||||
|
}
|
||||||
|
for r in raw
|
||||||
|
]
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -236,7 +236,11 @@ export default function PrecedentDetailPage({
|
|||||||
|
|
||||||
{/* side rail — citations + corroboration */}
|
{/* side rail — citations + corroboration */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<RelatedCasesSection caseId={id} related={data.related_cases ?? []} />
|
<RelatedCasesSection
|
||||||
|
caseId={id}
|
||||||
|
related={data.related_cases ?? []}
|
||||||
|
incoming={data.incoming_citations ?? []}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
useLinkRelatedCase,
|
useLinkRelatedCase,
|
||||||
useUnlinkRelatedCase,
|
useUnlinkRelatedCase,
|
||||||
RelatedCase,
|
RelatedCase,
|
||||||
|
IncomingCitation,
|
||||||
} from "@/lib/api/precedent-library";
|
} from "@/lib/api/precedent-library";
|
||||||
|
|
||||||
const LEVEL_LABELS: Record<string, string> = {
|
const LEVEL_LABELS: Record<string, string> = {
|
||||||
@@ -133,19 +134,28 @@ function LinkDialog({ caseId, currentRelated, open, onOpenChange }: DialogProps)
|
|||||||
type SectionProps = {
|
type SectionProps = {
|
||||||
caseId: string;
|
caseId: string;
|
||||||
related: RelatedCase[];
|
related: RelatedCase[];
|
||||||
|
/** Decisions that cite THIS ruling — auto-detected from the citation graph
|
||||||
|
* (read-only). Optional so existing call-sites stay valid. */
|
||||||
|
incoming?: IncomingCitation[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Rail-styled citations card (mockup 08 side rail). Renders linked related
|
/* Rail-styled citations card (mockup 08 side rail). Renders linked related
|
||||||
* decisions as a navy-headed card with arrow-prefixed rows; keeps the full
|
* decisions as a navy-headed card with arrow-prefixed rows; keeps the full
|
||||||
* link/unlink logic. Used in the precedent-detail side rail. */
|
* link/unlink logic. Auto-detected incoming citations (decisions that cite this
|
||||||
export function RelatedCasesSection({ caseId, related }: SectionProps) {
|
* ruling) render in the SAME row style, read-only (no unlink). Used in the
|
||||||
|
* precedent-detail side rail. */
|
||||||
|
export function RelatedCasesSection({ caseId, related, incoming = [] }: SectionProps) {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
// De-dupe: a decision already linked manually shouldn't appear twice.
|
||||||
|
const manualIds = new Set(related.map((r) => r.id));
|
||||||
|
const autoCites = incoming.filter((c) => !manualIds.has(c.id));
|
||||||
|
const total = related.length + autoCites.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-rule bg-surface shadow-sm px-4 py-3.5 space-y-2.5">
|
<div className="rounded-lg border border-rule bg-surface shadow-sm px-4 py-3.5 space-y-2.5">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h3 className="text-navy text-[0.92rem] font-semibold m-0">
|
<h3 className="text-navy text-[0.92rem] font-semibold m-0">
|
||||||
ציטוטים מקושרים{related.length > 0 ? ` (${related.length})` : ""}
|
ציטוטים מקושרים{total > 0 ? ` (${total})` : ""}
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -157,10 +167,46 @@ export function RelatedCasesSection({ caseId, related }: SectionProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{related.length === 0 ? (
|
{total === 0 ? (
|
||||||
<p className="text-ink-muted text-[0.82rem] m-0">אין החלטות קשורות עדיין</p>
|
<p className="text-ink-muted text-[0.82rem] m-0">אין החלטות קשורות עדיין</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="list-none p-0 m-0">
|
<ul className="list-none p-0 m-0">
|
||||||
|
{autoCites.map((c) => (
|
||||||
|
<li
|
||||||
|
key={c.id}
|
||||||
|
className="flex items-start gap-2 py-2 border-b border-rule-soft last:border-b-0"
|
||||||
|
>
|
||||||
|
<span className="text-gold font-bold leading-6 shrink-0" aria-hidden>←</span>
|
||||||
|
<a
|
||||||
|
href={`/precedents/${c.id}`}
|
||||||
|
className="min-w-0 flex-1 group hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
<div className="text-[0.82rem] text-ink-soft leading-5 group-hover:text-navy">
|
||||||
|
{c.case_name || c.case_number}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||||
|
{c.precedent_level && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-[0.6rem] ${LEVEL_COLORS[c.precedent_level] ?? ""}`}
|
||||||
|
>
|
||||||
|
{LEVEL_LABELS[c.precedent_level] ?? c.precedent_level}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{(c.chair_name || c.court) && (
|
||||||
|
<span className="text-[0.68rem] text-ink-muted truncate">
|
||||||
|
{c.chair_name || c.court}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{c.date && (
|
||||||
|
<span className="text-[0.68rem] text-ink-muted tabular-nums" dir="ltr">
|
||||||
|
{c.date.slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
{related.map((r) => (
|
{related.map((r) => (
|
||||||
<li
|
<li
|
||||||
key={r.id}
|
key={r.id}
|
||||||
|
|||||||
@@ -143,10 +143,24 @@ export type RelatedCase = {
|
|||||||
relation_type: string;
|
relation_type: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A decision that cites THIS ruling — auto-detected from the citation graph
|
||||||
|
* (precedent_internal_citations incoming edges). Read-only (no unlink). */
|
||||||
|
export type IncomingCitation = {
|
||||||
|
id: string;
|
||||||
|
case_number: string;
|
||||||
|
case_name: string;
|
||||||
|
court: string;
|
||||||
|
precedent_level: string;
|
||||||
|
chair_name: string;
|
||||||
|
date: string | null;
|
||||||
|
confidence: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type PrecedentDetail = Precedent & {
|
export type PrecedentDetail = Precedent & {
|
||||||
full_text: string;
|
full_text: string;
|
||||||
halachot: Halacha[];
|
halachot: Halacha[];
|
||||||
related_cases: RelatedCase[];
|
related_cases: RelatedCase[];
|
||||||
|
incoming_citations: IncomingCitation[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SearchHit =
|
export type SearchHit =
|
||||||
|
|||||||
Reference in New Issue
Block a user