Compare commits
15 Commits
feat/deeps
...
015e553d06
| Author | SHA1 | Date | |
|---|---|---|---|
| 015e553d06 | |||
| 6bdf9786ac | |||
| d87f9c5a5f | |||
| a0fab1f6de | |||
| d5043100a7 | |||
| 932cc7191c | |||
| d983cfdd3b | |||
| 50649baeed | |||
| a9cd8aeb12 | |||
| 10a63fb9e0 | |||
| f94201c577 | |||
| 026457dac4 | |||
| 75493ce233 | |||
| 3e14cd6798 | |||
| 13a8d9e58f |
@@ -216,6 +216,22 @@ async def precedent_library_delete(case_law_id: str) -> str:
|
||||
return await plib.precedent_library_delete(case_law_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def precedent_link_cases(
|
||||
case_law_id_a: str,
|
||||
case_law_id_b: str,
|
||||
relation_type: str = "same_case_chain",
|
||||
) -> str:
|
||||
"""קישור שתי פסיקות כקשורות (דו-כיווני, idempotent). relation_type: same_case_chain | overruled_by | distinguished."""
|
||||
return await plib.precedent_link_cases(case_law_id_a, case_law_id_b, relation_type)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
|
||||
"""הסרת קישור בין שתי פסיקות (דו-כיווני)."""
|
||||
return await plib.precedent_unlink_cases(case_law_id_a, case_law_id_b)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def precedent_extract_halachot(case_law_id: str) -> str:
|
||||
"""הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review."""
|
||||
|
||||
@@ -360,13 +360,9 @@ async def write_block(
|
||||
post_hearing_context=post_hearing_context,
|
||||
)
|
||||
|
||||
# Restructure: sources first, then instructions
|
||||
prompt = (
|
||||
f"## חומרי מקור (מסמכים מלאים — צטט מהם מילה במילה כשאפשר):\n\n"
|
||||
f"{source_context}\n\n"
|
||||
f"---\n\n"
|
||||
f"{formatted_prompt}"
|
||||
)
|
||||
# source_context is already embedded inside formatted_prompt via {source_context} in the
|
||||
# template. Do NOT prepend it again — doing so doubles the prompt size (was 465K chars).
|
||||
prompt = formatted_prompt
|
||||
|
||||
if instructions:
|
||||
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
||||
@@ -377,6 +373,19 @@ async def write_block(
|
||||
if not dir_doc.get("approved"):
|
||||
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
|
||||
|
||||
# Guard against context overflow before calling claude -p.
|
||||
# Sonnet: 200K context → ~800K chars max; Opus: 200K context → same.
|
||||
# In practice the CLI has crashed on prompts above ~400K chars, so use
|
||||
# that as a conservative ceiling (well below the token limit).
|
||||
_MAX_PROMPT_CHARS = 400_000
|
||||
if len(prompt) > _MAX_PROMPT_CHARS:
|
||||
raise RuntimeError(
|
||||
f"Prompt too large for {block_id}: {len(prompt):,} chars "
|
||||
f"(limit {_MAX_PROMPT_CHARS:,}). "
|
||||
f"source_context: {len(source_context):,} chars. "
|
||||
f"Reduce documents or call extract_appraiser_facts first."
|
||||
)
|
||||
|
||||
# Call Claude via Claude Code session (no API)
|
||||
model_key = block_cfg["model"]
|
||||
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
|
||||
@@ -414,16 +423,35 @@ def _build_case_context(case: dict, decision: dict | None) -> str:
|
||||
- תוצאה: {outcome_heb}"""
|
||||
|
||||
|
||||
# Which doc_types are relevant per block.
|
||||
# None → skip source docs entirely (block uses other context, e.g. claims_context)
|
||||
# [] → include all doc types (default for unspecified blocks)
|
||||
# [..] → include only the listed doc_type values
|
||||
_BLOCK_DOC_TYPES: dict[str, list[str] | None] = {
|
||||
"block-he": None, # only case_context needed; no full docs
|
||||
"block-vav": ["appeal", "protocol"], # כתב ערר + פרוטוקול ועדה
|
||||
"block-zayin": None, # claims_context is sufficient
|
||||
"block-chet": ["protocol"], # פרוטוקול + השלמות טיעון
|
||||
"block-tet": ["appraisal"], # שומות בלבד
|
||||
# block-yod, block-yod-alef, block-he etc. default → all docs
|
||||
}
|
||||
|
||||
|
||||
async def _build_source_context(case_id: UUID, block_id: str) -> str:
|
||||
"""Get full document texts for the block.
|
||||
"""Get document texts for the block, filtered by relevance.
|
||||
|
||||
Per Anthropic best practices: send full source documents, not truncated excerpts.
|
||||
Place documents at the TOP of the prompt (before instructions) for 30% better recall.
|
||||
For grounding: instruct Claude to cite word-for-word from these documents.
|
||||
Per-block filtering prevents context overflow on large cases (9+ docs).
|
||||
"""
|
||||
allowed = _BLOCK_DOC_TYPES.get(block_id, []) # [] sentinel = not in map → all docs
|
||||
if allowed is None:
|
||||
return "" # this block doesn't need raw source docs
|
||||
|
||||
docs = await db.list_documents(case_id)
|
||||
context_parts = []
|
||||
for doc in docs:
|
||||
if allowed and doc["doc_type"] not in allowed:
|
||||
continue
|
||||
text = await db.get_document_text(UUID(doc["id"]))
|
||||
if text:
|
||||
context_parts.append(f"--- מסמך: {doc['title']} ({doc['doc_type']}) ---\n{text}")
|
||||
|
||||
@@ -72,6 +72,9 @@ async def query(
|
||||
"""
|
||||
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
||||
|
||||
if len(full_prompt) > 150_000:
|
||||
logger.warning("Large prompt: %d chars — may hit context limits", len(full_prompt))
|
||||
|
||||
cmd = [
|
||||
"claude", "-p",
|
||||
"--output-format", "json",
|
||||
@@ -110,7 +113,8 @@ async def query(
|
||||
|
||||
if proc.returncode != 0:
|
||||
stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error"
|
||||
raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}")
|
||||
size_info = f"; prompt_len={len(full_prompt):,} chars" if len(full_prompt) > 100_000 else ""
|
||||
raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}{size_info}")
|
||||
|
||||
stdout = stdout_b.decode("utf-8", errors="replace").strip()
|
||||
if not stdout:
|
||||
|
||||
@@ -700,6 +700,20 @@ CREATE INDEX IF NOT EXISTS idx_case_law_chair ON case_law(chair_name) WHERE chai
|
||||
CREATE INDEX IF NOT EXISTS idx_case_law_district ON case_law(district) WHERE district <> '';
|
||||
"""
|
||||
|
||||
SCHEMA_V11_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS case_law_relations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
case_law_id UUID NOT NULL REFERENCES case_law(id) ON DELETE CASCADE,
|
||||
related_id UUID NOT NULL REFERENCES case_law(id) ON DELETE CASCADE,
|
||||
relation_type TEXT NOT NULL DEFAULT 'same_case_chain',
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(case_law_id, related_id),
|
||||
CHECK (case_law_id <> related_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_clr_a ON case_law_relations(case_law_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_clr_b ON case_law_relations(related_id);
|
||||
"""
|
||||
|
||||
|
||||
async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
||||
async with pool.acquire() as conn:
|
||||
@@ -714,7 +728,8 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
||||
await conn.execute(SCHEMA_V8_SQL)
|
||||
await conn.execute(SCHEMA_V9_SQL)
|
||||
await conn.execute(SCHEMA_V10_SQL)
|
||||
logger.info("Database schema initialized (v1-v10)")
|
||||
await conn.execute(SCHEMA_V11_SQL)
|
||||
logger.info("Database schema initialized (v1-v11)")
|
||||
|
||||
|
||||
async def init_schema() -> None:
|
||||
@@ -1735,6 +1750,59 @@ async def get_case_law(case_law_id: UUID) -> dict | None:
|
||||
return _row_to_case_law(row) if row else None
|
||||
|
||||
|
||||
async def add_case_law_relation(
|
||||
a_id: UUID, b_id: UUID, relation_type: str = "same_case_chain"
|
||||
) -> None:
|
||||
"""Link two case_law records bidirectionally. Idempotent (ON CONFLICT DO NOTHING)."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.executemany(
|
||||
"""
|
||||
INSERT INTO case_law_relations(case_law_id, related_id, relation_type)
|
||||
VALUES($1, $2, $3)
|
||||
ON CONFLICT (case_law_id, related_id) DO NOTHING
|
||||
""",
|
||||
[(a_id, b_id, relation_type), (b_id, a_id, relation_type)],
|
||||
)
|
||||
|
||||
|
||||
async def remove_case_law_relation(a_id: UUID, b_id: UUID) -> None:
|
||||
"""Remove a bidirectional link between two case_law records."""
|
||||
pool = await get_pool()
|
||||
await pool.execute(
|
||||
"""
|
||||
DELETE FROM case_law_relations
|
||||
WHERE (case_law_id = $1 AND related_id = $2)
|
||||
OR (case_law_id = $2 AND related_id = $1)
|
||||
""",
|
||||
a_id,
|
||||
b_id,
|
||||
)
|
||||
|
||||
|
||||
async def get_case_law_relations(case_law_id: UUID) -> list[dict]:
|
||||
"""Return all case_law records linked to case_law_id, ordered by date asc."""
|
||||
pool = await get_pool()
|
||||
rows = await pool.fetch(
|
||||
"""
|
||||
SELECT cl.*, r.relation_type
|
||||
FROM case_law_relations r
|
||||
JOIN case_law cl ON cl.id = r.related_id
|
||||
WHERE r.case_law_id = $1
|
||||
ORDER BY cl.date ASC NULLS LAST
|
||||
""",
|
||||
case_law_id,
|
||||
)
|
||||
results = []
|
||||
for row in rows:
|
||||
d = dict(row)
|
||||
relation_type = d.pop("relation_type")
|
||||
normalized = _row_to_case_law(d)
|
||||
normalized["relation_type"] = relation_type
|
||||
results.append(normalized)
|
||||
return results
|
||||
|
||||
|
||||
async def get_case_law_by_citation(case_number: str) -> dict | None:
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
@@ -1984,8 +2052,18 @@ async def list_external_case_law(
|
||||
offset: int = 0,
|
||||
source_kind: str = "external_upload",
|
||||
) -> list[dict]:
|
||||
"""List chair-uploaded precedents, with simple filters."""
|
||||
"""List chair-uploaded precedents, with simple filters.
|
||||
|
||||
source_kind="all_committees" expands to: source_kind='internal_committee'
|
||||
OR (source_kind='external_upload' AND source_type='appeals_committee').
|
||||
"""
|
||||
pool = await get_pool()
|
||||
if source_kind == "all_committees":
|
||||
conditions = [
|
||||
"(source_kind = 'internal_committee' OR "
|
||||
"(source_kind = 'external_upload' AND source_type = 'appeals_committee'))"
|
||||
]
|
||||
else:
|
||||
conditions = [f"source_kind = '{source_kind}'"]
|
||||
params: list = []
|
||||
idx = 1
|
||||
@@ -2420,19 +2498,17 @@ async def precedent_library_stats() -> dict:
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
total = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM case_law WHERE source_kind = 'external_upload'"
|
||||
"SELECT COUNT(*) FROM case_law"
|
||||
)
|
||||
by_practice = await conn.fetch(
|
||||
"""SELECT practice_area, COUNT(*) AS n
|
||||
FROM case_law
|
||||
WHERE source_kind = 'external_upload'
|
||||
GROUP BY practice_area
|
||||
ORDER BY n DESC"""
|
||||
)
|
||||
by_level = await conn.fetch(
|
||||
"""SELECT precedent_level, COUNT(*) AS n
|
||||
FROM case_law
|
||||
WHERE source_kind = 'external_upload'
|
||||
GROUP BY precedent_level
|
||||
ORDER BY n DESC"""
|
||||
)
|
||||
|
||||
@@ -123,7 +123,7 @@ SUMMARY_STRATEGIES = {
|
||||
|
||||
DISCUSSION_RULES: dict[str, list[str]] = {
|
||||
"universal": [
|
||||
"פרק הדיון = אסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
|
||||
"פרק הדיון = מאסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
|
||||
"חריג יחיד לכותרות משנה: נושאים נפרדים לחלוטין (למשל: הקלה בגובה + התייחסות לטענות נוספות).",
|
||||
"טווח אורך סעיפים: 20 עד 600+ מילים. סעיף עם ציטוט מקיף = בלוק אחד שלם, לא שבירה לסעיפים קצרים.",
|
||||
],
|
||||
|
||||
@@ -438,13 +438,14 @@ async def delete_precedent(case_law_id: UUID | str) -> bool:
|
||||
|
||||
|
||||
async def get_precedent(case_law_id: UUID | str) -> dict | None:
|
||||
"""Get a precedent with its halachot attached."""
|
||||
"""Get a precedent with its halachot and related cases attached."""
|
||||
if isinstance(case_law_id, str):
|
||||
case_law_id = UUID(case_law_id)
|
||||
record = await db.get_case_law(case_law_id)
|
||||
if not record:
|
||||
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)
|
||||
return record
|
||||
|
||||
|
||||
|
||||
@@ -116,6 +116,54 @@ async def precedent_library_get(case_law_id: str) -> str:
|
||||
return _ok(record)
|
||||
|
||||
|
||||
async def precedent_link_cases(
|
||||
case_law_id_a: str,
|
||||
case_law_id_b: str,
|
||||
relation_type: str = "same_case_chain",
|
||||
) -> str:
|
||||
"""קישור שתי פסיקות כקשורות זו לזו (דו-כיווני). idempotent.
|
||||
|
||||
Args:
|
||||
case_law_id_a: UUID של פסיקה ראשונה.
|
||||
case_law_id_b: UUID של פסיקה שנייה.
|
||||
relation_type: same_case_chain | overruled_by | distinguished
|
||||
"""
|
||||
try:
|
||||
a = UUID(case_law_id_a)
|
||||
b = UUID(case_law_id_b)
|
||||
except ValueError:
|
||||
return _err("case_law_id לא תקין")
|
||||
rec_a = await db.get_case_law(a)
|
||||
rec_b = await db.get_case_law(b)
|
||||
if not rec_a:
|
||||
return _err(f"פסיקה {case_law_id_a} לא נמצאה")
|
||||
if not rec_b:
|
||||
return _err(f"פסיקה {case_law_id_b} לא נמצאה")
|
||||
await db.add_case_law_relation(a, b, relation_type)
|
||||
return _ok({
|
||||
"linked": True,
|
||||
"relation_type": relation_type,
|
||||
"a": {"id": case_law_id_a, "case_number": rec_a.get("case_number"), "court": rec_a.get("court")},
|
||||
"b": {"id": case_law_id_b, "case_number": rec_b.get("case_number"), "court": rec_b.get("court")},
|
||||
})
|
||||
|
||||
|
||||
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
|
||||
"""הסרת קישור בין שתי פסיקות (דו-כיווני).
|
||||
|
||||
Args:
|
||||
case_law_id_a: UUID של פסיקה ראשונה.
|
||||
case_law_id_b: UUID של פסיקה שנייה.
|
||||
"""
|
||||
try:
|
||||
a = UUID(case_law_id_a)
|
||||
b = UUID(case_law_id_b)
|
||||
except ValueError:
|
||||
return _err("case_law_id לא תקין")
|
||||
await db.remove_case_law_relation(a, b)
|
||||
return _ok({"unlinked": True, "a": case_law_id_a, "b": case_law_id_b})
|
||||
|
||||
|
||||
async def precedent_library_delete(case_law_id: str) -> str:
|
||||
"""מחיקת פסיקה מהקורפוס. cascade: chunks + halachot."""
|
||||
try:
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { usePrecedent } from "@/lib/api/precedent-library";
|
||||
import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet";
|
||||
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
|
||||
import { RelatedCasesSection } from "@/components/precedents/link-related-dialog";
|
||||
|
||||
const PRACTICE_AREA_LABELS: Record<string, string> = {
|
||||
rishuy_uvniya: "רישוי ובנייה",
|
||||
@@ -152,6 +153,15 @@ export default function PrecedentDetailPage({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<RelatedCasesSection
|
||||
caseId={id}
|
||||
related={data.related_cases ?? []}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<ExtractedHalachotSection halachot={data.halachot ?? []} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Trash2, Plus, Pencil, Wand2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -151,7 +152,9 @@ 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"
|
||||
>
|
||||
<span dir="auto">{cleanCitation(p.case_number)}</span>
|
||||
<Link href={`/precedents/${p.id}`} className="hover:underline hover:text-gold-deep" dir="auto">
|
||||
{cleanCitation(p.case_number)}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-ink whitespace-normal break-words max-w-[260px] py-3">
|
||||
<div className="font-medium">{cleanCitation(p.case_name)}</div>
|
||||
@@ -233,7 +236,9 @@ 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"
|
||||
>
|
||||
<span dir="auto">{cleanCitation(p.case_number)}</span>
|
||||
<Link href={`/precedents/${p.id}`} className="hover:underline hover:text-gold-deep" dir="auto">
|
||||
{cleanCitation(p.case_number)}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-ink whitespace-normal break-words max-w-[220px] py-3">
|
||||
<div className="font-medium">{cleanCitation(p.case_name)}</div>
|
||||
@@ -308,8 +313,8 @@ export function LibraryListPanel() {
|
||||
limit: 200,
|
||||
};
|
||||
|
||||
const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload" });
|
||||
const committee = usePrecedents({ ...sharedFilters, sourceKind: "internal_committee" });
|
||||
const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload", sourceType: "court_ruling" });
|
||||
const committee = usePrecedents({ ...sharedFilters, sourceKind: "all_committees" });
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
|
||||
222
web-ui/src/components/precedents/link-related-dialog.tsx
Normal file
222
web-ui/src/components/precedents/link-related-dialog.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link2, Loader2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
usePrecedents,
|
||||
useLinkRelatedCase,
|
||||
useUnlinkRelatedCase,
|
||||
RelatedCase,
|
||||
} from "@/lib/api/precedent-library";
|
||||
|
||||
const LEVEL_LABELS: Record<string, string> = {
|
||||
"עליון": "עליון",
|
||||
"מנהלי": "מנהלי",
|
||||
"ועדת_ערר_ארצית": "ארצי",
|
||||
"ועדת_ערר_מחוזית": "מחוזי",
|
||||
};
|
||||
|
||||
const LEVEL_COLORS: Record<string, string> = {
|
||||
"עליון": "bg-red-50 text-red-700 border-red-200",
|
||||
"מנהלי": "bg-orange-50 text-orange-700 border-orange-200",
|
||||
"ועדת_ערר_ארצית": "bg-blue-50 text-blue-700 border-blue-200",
|
||||
"ועדת_ערר_מחוזית": "bg-green-50 text-green-700 border-green-200",
|
||||
};
|
||||
|
||||
// ── Search Dialog ────────────────────────────────────────────────────
|
||||
|
||||
type DialogProps = {
|
||||
caseId: string;
|
||||
currentRelated: RelatedCase[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
function LinkDialog({ caseId, currentRelated, open, onOpenChange }: DialogProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const { mutateAsync: linkCase, isPending } = useLinkRelatedCase(caseId);
|
||||
const alreadyLinkedIds = new Set([...currentRelated.map((r) => r.id), caseId]);
|
||||
|
||||
const { data, isPending: searching } = usePrecedents(
|
||||
query.length >= 2 ? { search: query, limit: 10 } : {},
|
||||
);
|
||||
|
||||
const candidates = (data?.items ?? []).filter((p) => !alreadyLinkedIds.has(p.id));
|
||||
|
||||
async function handleLink(relatedId: string) {
|
||||
try {
|
||||
await linkCase({ relatedId });
|
||||
toast.success("הפסיקות קושרו");
|
||||
setQuery("");
|
||||
} catch {
|
||||
toast.error("שגיאה בקישור");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg" dir="rtl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-navy">קשר החלטה קשורה</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder="חפש לפי מספר תיק, שם, ערכאה..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
dir="rtl"
|
||||
/>
|
||||
|
||||
{query.length >= 2 && (
|
||||
<div className="space-y-1 max-h-72 overflow-y-auto">
|
||||
{searching ? (
|
||||
<div className="flex items-center gap-2 text-ink-muted text-sm py-3">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> מחפש...
|
||||
</div>
|
||||
) : candidates.length === 0 ? (
|
||||
<p className="text-ink-muted text-sm py-3 text-center">לא נמצאו תוצאות</p>
|
||||
) : (
|
||||
candidates.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => handleLink(p.id)}
|
||||
disabled={isPending}
|
||||
className="w-full text-right flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-rule hover:bg-surface/60 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-navy truncate">
|
||||
{p.case_name || p.case_number}
|
||||
</div>
|
||||
<div className="text-[0.72rem] text-ink-muted font-mono" dir="ltr">
|
||||
{p.case_number}
|
||||
</div>
|
||||
</div>
|
||||
{p.precedent_level && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[0.62rem] shrink-0 ${LEVEL_COLORS[p.precedent_level] ?? ""}`}
|
||||
>
|
||||
{LEVEL_LABELS[p.precedent_level] ?? p.precedent_level}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{query.length > 0 && query.length < 2 && (
|
||||
<p className="text-ink-muted text-xs">הקלד לפחות 2 תווים</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Related Case Card ────────────────────────────────────────────────
|
||||
|
||||
function RelatedCaseCard({ caseId, related }: { caseId: string; related: RelatedCase }) {
|
||||
const { mutateAsync: unlinkCase, isPending } = useUnlinkRelatedCase(caseId);
|
||||
|
||||
async function handleUnlink() {
|
||||
try {
|
||||
await unlinkCase(related.id);
|
||||
toast.success("הקישור הוסר");
|
||||
} catch {
|
||||
toast.error("שגיאה בהסרת הקישור");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-rule bg-surface">
|
||||
<a
|
||||
href={`/precedents/${related.id}`}
|
||||
className="min-w-0 flex-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="text-sm font-medium text-navy truncate">
|
||||
{related.case_name || related.case_number}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{related.precedent_level && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[0.62rem] ${LEVEL_COLORS[related.precedent_level] ?? ""}`}
|
||||
>
|
||||
{LEVEL_LABELS[related.precedent_level] ?? related.precedent_level}
|
||||
</Badge>
|
||||
)}
|
||||
{related.court && (
|
||||
<span className="text-[0.7rem] text-ink-muted truncate">{related.court}</span>
|
||||
)}
|
||||
{related.date && (
|
||||
<span className="text-[0.7rem] text-ink-muted tabular-nums" dir="ltr">
|
||||
{related.date.slice(0, 10)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
<button
|
||||
onClick={handleUnlink}
|
||||
disabled={isPending}
|
||||
className="p-1 rounded hover:bg-danger-bg hover:text-danger transition-colors text-ink-muted disabled:opacity-40 shrink-0"
|
||||
title="הסר קישור"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Public section component ─────────────────────────────────────────
|
||||
|
||||
type SectionProps = {
|
||||
caseId: string;
|
||||
related: RelatedCase[];
|
||||
};
|
||||
|
||||
export function RelatedCasesSection({ caseId, related }: SectionProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-navy text-sm font-semibold">
|
||||
החלטות קשורות{related.length > 0 ? ` (${related.length})` : ""}
|
||||
</h3>
|
||||
<Button variant="outline" size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Link2 className="w-3.5 h-3.5 me-1" /> קשר החלטה
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{related.length === 0 ? (
|
||||
<p className="text-ink-muted text-sm">אין החלטות קשורות עדיין</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{related.map((r) => (
|
||||
<RelatedCaseCard key={r.id} caseId={caseId} related={r} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LinkDialog
|
||||
caseId={caseId}
|
||||
currentRelated={related}
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Save, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -65,10 +65,12 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
||||
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
|
||||
// Hydrate form when the record loads.
|
||||
useEffect(() => {
|
||||
if (!record) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
// React-approved derived-state pattern: sync form whenever a different
|
||||
// record arrives (including after save+refetch). Using setState during
|
||||
// render avoids the one-frame flash that useEffect would produce.
|
||||
const [syncedRecordId, setSyncedRecordId] = useState<string | null>(null);
|
||||
if (record && record.id !== syncedRecordId) {
|
||||
setSyncedRecordId(record.id as string);
|
||||
setForm({
|
||||
citation: record.case_number || "",
|
||||
case_name: record.case_name || "",
|
||||
@@ -84,7 +86,7 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
||||
headnote: record.headnote || "",
|
||||
key_quote: (record as { key_quote?: string }).key_quote || "",
|
||||
});
|
||||
}, [record]);
|
||||
}
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -84,9 +84,20 @@ export type Halacha = {
|
||||
precedent_level?: string;
|
||||
};
|
||||
|
||||
export type RelatedCase = {
|
||||
id: string;
|
||||
case_number: string;
|
||||
case_name: string;
|
||||
court: string;
|
||||
precedent_level: string;
|
||||
date: string | null;
|
||||
relation_type: string;
|
||||
};
|
||||
|
||||
export type PrecedentDetail = Precedent & {
|
||||
full_text: string;
|
||||
halachot: Halacha[];
|
||||
related_cases: RelatedCase[];
|
||||
};
|
||||
|
||||
export type SearchHit =
|
||||
@@ -357,6 +368,40 @@ export function useDeletePrecedent() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useLinkRelatedCase(caseId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (vars: { relatedId: string; relationType?: string }) =>
|
||||
apiRequest<{ linked: boolean }>(
|
||||
`/api/precedent-library/${encodeURIComponent(caseId)}/relations`,
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
related_id: vars.relatedId,
|
||||
relation_type: vars.relationType ?? "same_case_chain",
|
||||
},
|
||||
},
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnlinkRelatedCase(caseId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (relatedId: string) =>
|
||||
apiRequest<{ unlinked: boolean }>(
|
||||
`/api/precedent-library/${encodeURIComponent(caseId)}/relations/${encodeURIComponent(relatedId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type PrecedentPatch = Partial<{
|
||||
case_name: string;
|
||||
court: string;
|
||||
|
||||
81
web/app.py
81
web/app.py
@@ -20,7 +20,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "
|
||||
|
||||
import zipfile
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from typing import Any, Literal
|
||||
from pydantic import BaseModel
|
||||
@@ -44,7 +44,7 @@ from web.mcp_env_catalog import (
|
||||
normalize_for_compare,
|
||||
)
|
||||
from web.progress_store import ProgressStore
|
||||
from web.paperclip_api import pc_request
|
||||
from web.paperclip_api import emit_case_status_webhook, pc_request
|
||||
from web.paperclip_client import (
|
||||
COMPANIES as PAPERCLIP_COMPANIES,
|
||||
accept_interaction as pc_accept_interaction,
|
||||
@@ -1337,8 +1337,12 @@ async def api_case_get(case_number: str):
|
||||
|
||||
|
||||
@app.put("/api/cases/{case_number}")
|
||||
async def api_case_update(case_number: str, req: CaseUpdateRequest):
|
||||
async def api_case_update(case_number: str, req: CaseUpdateRequest, background_tasks: BackgroundTasks):
|
||||
"""Update case details."""
|
||||
# Capture old status before the update so we can detect changes.
|
||||
existing = await db.get_case_by_number(case_number)
|
||||
old_status = (existing or {}).get("status", "")
|
||||
|
||||
result = await cases_tools.case_update(
|
||||
case_number=case_number,
|
||||
status=req.status,
|
||||
@@ -1351,10 +1355,30 @@ async def api_case_update(case_number: str, req: CaseUpdateRequest):
|
||||
expected_outcome=req.expected_outcome,
|
||||
)
|
||||
try:
|
||||
return json.loads(result)
|
||||
parsed = json.loads(result)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(404, result)
|
||||
|
||||
# Emit webhook when status changes (fire-and-forget via BackgroundTasks).
|
||||
new_status = req.status
|
||||
if new_status and old_status != new_status:
|
||||
prefix = case_number[:1]
|
||||
company_id = (
|
||||
PAPERCLIP_COMPANIES["licensing"] if prefix == "1"
|
||||
else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9")
|
||||
else None
|
||||
)
|
||||
background_tasks.add_task(
|
||||
emit_case_status_webhook,
|
||||
case_number=case_number,
|
||||
old_status=old_status,
|
||||
new_status=new_status,
|
||||
company_id=company_id, # None is safe — plugin handles unknown company gracefully
|
||||
)
|
||||
logger.debug("webhook scheduled: case %s %s → %s", case_number, old_status, new_status)
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
@app.delete("/api/cases")
|
||||
async def api_case_delete(case_number: str, remove_files: bool = False):
|
||||
@@ -3057,8 +3081,16 @@ async def api_get_methodology(category: str):
|
||||
items = {}
|
||||
for key, default_val in defaults.items():
|
||||
if key in overrides:
|
||||
raw = overrides[key]["rule_value"]
|
||||
# asyncpg returns JSONB as a raw JSON string when no codec is registered.
|
||||
# Parse it back to a Python object so the frontend receives the correct type.
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
items[key] = {
|
||||
"value": overrides[key]["rule_value"],
|
||||
"value": raw,
|
||||
"is_override": True,
|
||||
"updated_at": overrides[key]["created_at"].isoformat() if overrides[key]["created_at"] else None,
|
||||
}
|
||||
@@ -3095,10 +3127,14 @@ async def api_update_methodology(category: str, key: str, req: MethodologyUpdate
|
||||
raise HTTPException(422, "content_checklists value must be a non-empty string")
|
||||
|
||||
pool = await db.get_pool()
|
||||
# json.dumps → text, then PostgreSQL casts text→jsonb.
|
||||
# Passing a Python list directly causes "expected str, got list" in asyncpg;
|
||||
# passing a str with ::jsonb causes double-encoding (stored as JSONB string).
|
||||
# ::text::jsonb bypasses asyncpg's codec and lets PostgreSQL parse the JSON.
|
||||
await pool.execute(
|
||||
"INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) "
|
||||
"VALUES (gen_random_uuid(), '_global', $1, $2, $3::jsonb) "
|
||||
"ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::jsonb",
|
||||
"VALUES (gen_random_uuid(), '_global', $1, $2, $3::text::jsonb) "
|
||||
"ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::text::jsonb",
|
||||
category, key, json.dumps(req.value, ensure_ascii=False),
|
||||
)
|
||||
|
||||
@@ -4380,6 +4416,37 @@ async def precedent_library_delete(case_law_id: str):
|
||||
return {"deleted": True, "case_law_id": case_law_id}
|
||||
|
||||
|
||||
class PrecedentRelationRequest(BaseModel):
|
||||
related_id: str
|
||||
relation_type: str = "same_case_chain"
|
||||
|
||||
|
||||
@app.post("/api/precedent-library/{case_law_id}/relations")
|
||||
async def precedent_add_relation(case_law_id: str, req: PrecedentRelationRequest):
|
||||
try:
|
||||
a = UUID(case_law_id)
|
||||
b = UUID(req.related_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "case_law_id לא תקין")
|
||||
if not await db.get_case_law(a):
|
||||
raise HTTPException(404, "פסיקה לא נמצאה")
|
||||
if not await db.get_case_law(b):
|
||||
raise HTTPException(404, f"פסיקה קשורה {req.related_id} לא נמצאה")
|
||||
await db.add_case_law_relation(a, b, req.relation_type)
|
||||
return {"linked": True, "case_law_id": case_law_id, "related_id": req.related_id}
|
||||
|
||||
|
||||
@app.delete("/api/precedent-library/{case_law_id}/relations/{related_id}")
|
||||
async def precedent_remove_relation(case_law_id: str, related_id: str):
|
||||
try:
|
||||
a = UUID(case_law_id)
|
||||
b = UUID(related_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "case_law_id לא תקין")
|
||||
await db.remove_case_law_relation(a, b)
|
||||
return {"unlinked": True, "case_law_id": case_law_id, "related_id": related_id}
|
||||
|
||||
|
||||
# Halacha and metadata extraction are LLM-driven and rely on the local
|
||||
# `claude` CLI via mcp-server/services/claude_session.py — they CANNOT run
|
||||
# from this container (no CLI, no claude.ai session). The endpoints below
|
||||
|
||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -81,3 +82,35 @@ async def pc_request(
|
||||
if raise_on_error:
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
|
||||
|
||||
async def emit_case_status_webhook(
|
||||
case_number: str,
|
||||
old_status: str,
|
||||
new_status: str,
|
||||
company_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> None:
|
||||
"""Notify the Paperclip plugin that a case status changed.
|
||||
|
||||
Fire-and-forget: logs errors but never raises, so callers aren't blocked.
|
||||
"""
|
||||
try:
|
||||
await pc_request(
|
||||
"POST",
|
||||
"/api/plugins/marcusgroup.legal-ai/webhooks/case-status",
|
||||
json={
|
||||
"caseNumber": case_number,
|
||||
"oldStatus": old_status,
|
||||
"newStatus": new_status,
|
||||
"companyId": company_id,
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
},
|
||||
run_id=run_id,
|
||||
timeout=5.0,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"emit_case_status_webhook failed for case %s (%s → %s): %s",
|
||||
case_number, old_status, new_status, exc,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user