15 Commits

Author SHA1 Message Date
015e553d06 fix: add debug log and null company_id comment to webhook scheduling
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 4m16s
2026-05-16 17:13:07 +00:00
6bdf9786ac feat: emit case-status webhook on status change in PUT /api/cases/:case 2026-05-16 17:10:30 +00:00
d87f9c5a5f fix: include case details in webhook failure warning log 2026-05-16 17:08:33 +00:00
a0fab1f6de feat: add emit_case_status_webhook helper 2026-05-16 17:06:37 +00:00
d5043100a7 fix: json.loads JSONB overrides on GET — asyncpg has no codec registered
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
asyncpg returns JSONB columns as raw JSON strings when no type codec is
configured (only pgvector is registered in _init_connection). The stored
value is a correct JSONB array (jsonb_typeof=array confirmed), but
asyncpg decodes it as str. Parse it explicitly in the GET handler so
the frontend receives the correct Python list/dict.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:54:44 +00:00
932cc7191c fix: use ::text::jsonb to store methodology overrides correctly
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
asyncpg cannot encode a Python list as JSONB directly (expects str).
Passing str with ::jsonb causes double-encoding (stored as JSONB string).
Solution: json.dumps() the value → pass as text → PostgreSQL parses
with ::text::jsonb cast, storing it as the correct JSONB array/object.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:38:05 +00:00
d983cfdd3b Merge pull request 'fix: prevent JSONB double-encoding on methodology save' (#6) from fix/methodology-jsonb-double-encoding into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m39s
2026-05-10 18:34:03 +00:00
50649baeed fix: prevent JSONB double-encoding on methodology save
Pass req.value directly to asyncpg instead of json.dumps(req.value).
When a Python string was passed with ::jsonb, asyncpg encoded it as a
JSONB string (not an array), causing the frontend spread operator to
split it into individual characters — one textarea per character.

Also fix typo in DISCUSSION_RULES default: "אסה" → "מאסה".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:30:49 +00:00
a9cd8aeb12 fix: prevent write_interim_draft context overflow (465K → ≤300K chars)
Two bugs caused all 5 interim blocks to fail with "Claude CLI failed
(exit 1): unknown error":

1. source_context was embedded BOTH inside the prompt template (via
   {source_context}) AND prepended again in write_block — doubling every
   block's context size (232K chars × 2 = 465K chars).

2. _build_source_context loaded all 9 case documents for every block
   regardless of relevance.

Fixes:
- Remove the duplicate source_context prepend in write_block; the
  template already contains it via {source_context}
- Add per-block document filtering (_BLOCK_DOC_TYPES): block-he/zayin →
  empty, block-chet → protocol only, block-tet → appraisals only
- Add 400K char guard before calling claude -p with a descriptive error
  (vs opaque "exit 1: unknown error")
- Add prompt-size warning and size info in claude_session error messages

Result: block-he 0 chars, block-zayin 0 chars, block-vav ~172K,
block-chet ~45K, block-tet ~300K (all under 400K limit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 10:49:47 +00:00
10a63fb9e0 fix(precedents): separate court rulings from committee decisions correctly
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m37s
- DB: add 'all_committees' virtual source_kind covering internal_committee
  + external_upload appeals_committee rows in one query
- DB: stats now count all case_law rows (not just external_upload),
  fixing the precedents_total that excluded 44 internal-committee records
- UI: courts table filters to source_type=court_ruling only;
  committees table uses the new all_committees query

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 09:59:30 +00:00
f94201c577 feat(precedents): make citation link to detail page
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 34s
Both CourtRow and CommitteeRow citation cells are now Next.js Links
→ /precedents/{id}, letting users navigate directly from the list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 09:01:26 +00:00
026457dac4 fix(precedent-edit): sync form from record without useEffect flash
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 36s
Replace useEffect-based form hydration with React's approved derived-state
pattern (setState-during-render). This eliminates the one-frame flash where
the precedent_level Select showed "—" before useEffect fired, and fixes
cases where the same record reference returned from TanStack cache caused
useEffect to not re-run after save+invalidate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:35:04 +00:00
75493ce233 Merge pull request 'feat: link related precedents across court instances (SCHEMA_V11)' (#4) from feat/related-precedents-v11 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m41s
Reviewed-on: #4
2026-05-10 07:54:37 +00:00
3e14cd6798 feat: link related precedents across court instances (SCHEMA_V11)
Add ability to mark case_law records as related (e.g. same appeal
through ועדת ערר → מנהלי → עליון):
- DB: case_law_relations join table (bidirectional, V11 migration)
- DB CRUD: add/remove/get_case_law_relations
- Service: get_precedent() now returns related_cases[]
- MCP: precedent_link_cases + precedent_unlink_cases tools
- REST: POST/DELETE /api/precedent-library/{id}/relations
- UI: RelatedCasesSection on detail page with search dialog and unlink

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 07:52:29 +00:00
13a8d9e58f Merge pull request 'feat(curator): switch Hermes Curator to DeepSeek V4-Pro via deepseek_local adapter' (#3) from feat/deepseek-curator-adapter into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m53s
2026-05-10 06:21:28 +00:00
14 changed files with 593 additions and 36 deletions

View File

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

View File

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

View File

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

View File

@@ -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,9 +2052,19 @@ 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()
conditions = [f"source_kind = '{source_kind}'"]
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
if practice_area:
@@ -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"""
)

View File

@@ -123,7 +123,7 @@ SUMMARY_STRATEGIES = {
DISCUSSION_RULES: dict[str, list[str]] = {
"universal": [
"פרק הדיון = אסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
"פרק הדיון = מאסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
"חריג יחיד לכותרות משנה: נושאים נפרדים לחלוטין (למשל: הקלה בגובה + התייחסות לטענות נוספות).",
"טווח אורך סעיפים: 20 עד 600+ מילים. סעיף עם ציטוט מקיף = בלוק אחד שלם, לא שבירה לסעיפים קצרים.",
],

View File

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

View File

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

View File

@@ -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 ?? []} />

View File

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

View 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>
);
}

View File

@@ -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();

View File

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

View File

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

View File

@@ -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,
)