feat(precedents): surface auto-detected incoming citations in the "ציטוטים מקושרים" panel #317

Merged
chaim merged 1 commits from worktree-incoming-citations-panel into main 2026-06-20 14:55:02 +00:00
5 changed files with 95 additions and 6 deletions
Showing only changes of commit 91c521922f - Show all commits

View File

@@ -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_name AS source_case_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
JOIN case_law cl ON cl.id = pic.source_case_law_id
WHERE pic.cited_case_law_id = $1

View File

@@ -411,6 +411,28 @@ async def get_precedent(case_law_id: UUID | str) -> dict | None:
return None
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)
# 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

View File

@@ -236,7 +236,11 @@ export default function PrecedentDetailPage({
{/* side rail — citations + corroboration */}
<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>

View File

@@ -18,6 +18,7 @@ import {
useLinkRelatedCase,
useUnlinkRelatedCase,
RelatedCase,
IncomingCitation,
} from "@/lib/api/precedent-library";
const LEVEL_LABELS: Record<string, string> = {
@@ -133,19 +134,28 @@ function LinkDialog({ caseId, currentRelated, open, onOpenChange }: DialogProps)
type SectionProps = {
caseId: string;
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
* decisions as a navy-headed card with arrow-prefixed rows; keeps the full
* link/unlink logic. Used in the precedent-detail side rail. */
export function RelatedCasesSection({ caseId, related }: SectionProps) {
* link/unlink logic. Auto-detected incoming citations (decisions that cite this
* 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);
// 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 (
<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">
<h3 className="text-navy text-[0.92rem] font-semibold m-0">
ציטוטים מקושרים{related.length > 0 ? ` (${related.length})` : ""}
ציטוטים מקושרים{total > 0 ? ` (${total})` : ""}
</h3>
<Button
variant="outline"
@@ -157,10 +167,46 @@ export function RelatedCasesSection({ caseId, related }: SectionProps) {
</Button>
</div>
{related.length === 0 ? (
{total === 0 ? (
<p className="text-ink-muted text-[0.82rem] m-0">אין החלטות קשורות עדיין</p>
) : (
<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) => (
<li
key={r.id}

View File

@@ -143,10 +143,24 @@ export type RelatedCase = {
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 & {
full_text: string;
halachot: Halacha[];
related_cases: RelatedCase[];
incoming_citations: IncomingCitation[];
};
export type SearchHit =