Compare commits
7 Commits
feat/deeps
...
fix/write-
| Author | SHA1 | Date | |
|---|---|---|---|
| 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)
|
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()
|
@mcp.tool()
|
||||||
async def precedent_extract_halachot(case_law_id: str) -> str:
|
async def precedent_extract_halachot(case_law_id: str) -> str:
|
||||||
"""הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review."""
|
"""הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review."""
|
||||||
|
|||||||
@@ -360,13 +360,9 @@ async def write_block(
|
|||||||
post_hearing_context=post_hearing_context,
|
post_hearing_context=post_hearing_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Restructure: sources first, then instructions
|
# source_context is already embedded inside formatted_prompt via {source_context} in the
|
||||||
prompt = (
|
# template. Do NOT prepend it again — doing so doubles the prompt size (was 465K chars).
|
||||||
f"## חומרי מקור (מסמכים מלאים — צטט מהם מילה במילה כשאפשר):\n\n"
|
prompt = formatted_prompt
|
||||||
f"{source_context}\n\n"
|
|
||||||
f"---\n\n"
|
|
||||||
f"{formatted_prompt}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if instructions:
|
if instructions:
|
||||||
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
||||||
@@ -377,6 +373,19 @@ async def write_block(
|
|||||||
if not dir_doc.get("approved"):
|
if not dir_doc.get("approved"):
|
||||||
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
|
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)
|
# Call Claude via Claude Code session (no API)
|
||||||
model_key = block_cfg["model"]
|
model_key = block_cfg["model"]
|
||||||
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
|
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}"""
|
- תוצאה: {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:
|
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.
|
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.
|
Per-block filtering prevents context overflow on large cases (9+ docs).
|
||||||
For grounding: instruct Claude to cite word-for-word from these documents.
|
|
||||||
"""
|
"""
|
||||||
|
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)
|
docs = await db.list_documents(case_id)
|
||||||
context_parts = []
|
context_parts = []
|
||||||
for doc in docs:
|
for doc in docs:
|
||||||
|
if allowed and doc["doc_type"] not in allowed:
|
||||||
|
continue
|
||||||
text = await db.get_document_text(UUID(doc["id"]))
|
text = await db.get_document_text(UUID(doc["id"]))
|
||||||
if text:
|
if text:
|
||||||
context_parts.append(f"--- מסמך: {doc['title']} ({doc['doc_type']}) ---\n{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
|
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 = [
|
cmd = [
|
||||||
"claude", "-p",
|
"claude", "-p",
|
||||||
"--output-format", "json",
|
"--output-format", "json",
|
||||||
@@ -110,7 +113,8 @@ async def query(
|
|||||||
|
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error"
|
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()
|
stdout = stdout_b.decode("utf-8", errors="replace").strip()
|
||||||
if not stdout:
|
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 <> '';
|
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 def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
||||||
async with pool.acquire() as conn:
|
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_V8_SQL)
|
||||||
await conn.execute(SCHEMA_V9_SQL)
|
await conn.execute(SCHEMA_V9_SQL)
|
||||||
await conn.execute(SCHEMA_V10_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:
|
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
|
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:
|
async def get_case_law_by_citation(case_number: str) -> dict | None:
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
row = await pool.fetchrow(
|
row = await pool.fetchrow(
|
||||||
@@ -1984,9 +2052,19 @@ async def list_external_case_law(
|
|||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
source_kind: str = "external_upload",
|
source_kind: str = "external_upload",
|
||||||
) -> list[dict]:
|
) -> 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()
|
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 = []
|
params: list = []
|
||||||
idx = 1
|
idx = 1
|
||||||
if practice_area:
|
if practice_area:
|
||||||
@@ -2420,19 +2498,17 @@ async def precedent_library_stats() -> dict:
|
|||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
total = await conn.fetchval(
|
total = await conn.fetchval(
|
||||||
"SELECT COUNT(*) FROM case_law WHERE source_kind = 'external_upload'"
|
"SELECT COUNT(*) FROM case_law"
|
||||||
)
|
)
|
||||||
by_practice = await conn.fetch(
|
by_practice = await conn.fetch(
|
||||||
"""SELECT practice_area, COUNT(*) AS n
|
"""SELECT practice_area, COUNT(*) AS n
|
||||||
FROM case_law
|
FROM case_law
|
||||||
WHERE source_kind = 'external_upload'
|
|
||||||
GROUP BY practice_area
|
GROUP BY practice_area
|
||||||
ORDER BY n DESC"""
|
ORDER BY n DESC"""
|
||||||
)
|
)
|
||||||
by_level = await conn.fetch(
|
by_level = await conn.fetch(
|
||||||
"""SELECT precedent_level, COUNT(*) AS n
|
"""SELECT precedent_level, COUNT(*) AS n
|
||||||
FROM case_law
|
FROM case_law
|
||||||
WHERE source_kind = 'external_upload'
|
|
||||||
GROUP BY precedent_level
|
GROUP BY precedent_level
|
||||||
ORDER BY n DESC"""
|
ORDER BY n DESC"""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
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):
|
if isinstance(case_law_id, str):
|
||||||
case_law_id = UUID(case_law_id)
|
case_law_id = UUID(case_law_id)
|
||||||
record = await db.get_case_law(case_law_id)
|
record = await db.get_case_law(case_law_id)
|
||||||
if not record:
|
if not record:
|
||||||
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)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,54 @@ async def precedent_library_get(case_law_id: str) -> str:
|
|||||||
return _ok(record)
|
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:
|
async def precedent_library_delete(case_law_id: str) -> str:
|
||||||
"""מחיקת פסיקה מהקורפוס. cascade: chunks + halachot."""
|
"""מחיקת פסיקה מהקורפוס. cascade: chunks + halachot."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import { usePrecedent } from "@/lib/api/precedent-library";
|
import { usePrecedent } from "@/lib/api/precedent-library";
|
||||||
import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet";
|
import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet";
|
||||||
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
|
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
|
||||||
|
import { RelatedCasesSection } from "@/components/precedents/link-related-dialog";
|
||||||
|
|
||||||
const PRACTICE_AREA_LABELS: Record<string, string> = {
|
const PRACTICE_AREA_LABELS: Record<string, string> = {
|
||||||
rishuy_uvniya: "רישוי ובנייה",
|
rishuy_uvniya: "רישוי ובנייה",
|
||||||
@@ -152,6 +153,15 @@ export default function PrecedentDetailPage({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
<CardContent className="px-6 py-5">
|
<CardContent className="px-6 py-5">
|
||||||
<ExtractedHalachotSection halachot={data.halachot ?? []} />
|
<ExtractedHalachotSection halachot={data.halachot ?? []} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { Trash2, Plus, Pencil, Wand2 } from "lucide-react";
|
import { Trash2, Plus, Pencil, Wand2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
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"
|
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[280px] max-w-[420px] py-3"
|
||||||
dir="rtl"
|
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>
|
||||||
<TableCell className="text-ink whitespace-normal break-words max-w-[260px] py-3">
|
<TableCell className="text-ink whitespace-normal break-words max-w-[260px] py-3">
|
||||||
<div className="font-medium">{cleanCitation(p.case_name)}</div>
|
<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"
|
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[200px] max-w-[320px] py-3"
|
||||||
dir="rtl"
|
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>
|
||||||
<TableCell className="text-ink whitespace-normal break-words max-w-[220px] py-3">
|
<TableCell className="text-ink whitespace-normal break-words max-w-[220px] py-3">
|
||||||
<div className="font-medium">{cleanCitation(p.case_name)}</div>
|
<div className="font-medium">{cleanCitation(p.case_name)}</div>
|
||||||
@@ -308,8 +313,8 @@ export function LibraryListPanel() {
|
|||||||
limit: 200,
|
limit: 200,
|
||||||
};
|
};
|
||||||
|
|
||||||
const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload" });
|
const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload", sourceType: "court_ruling" });
|
||||||
const committee = usePrecedents({ ...sharedFilters, sourceKind: "internal_committee" });
|
const committee = usePrecedents({ ...sharedFilters, sourceKind: "all_committees" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Save, Sparkles } from "lucide-react";
|
import { Save, Sparkles } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -65,10 +65,12 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
|||||||
|
|
||||||
const [form, setForm] = useState<FormState>(EMPTY);
|
const [form, setForm] = useState<FormState>(EMPTY);
|
||||||
|
|
||||||
// Hydrate form when the record loads.
|
// React-approved derived-state pattern: sync form whenever a different
|
||||||
useEffect(() => {
|
// record arrives (including after save+refetch). Using setState during
|
||||||
if (!record) return;
|
// render avoids the one-frame flash that useEffect would produce.
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
const [syncedRecordId, setSyncedRecordId] = useState<string | null>(null);
|
||||||
|
if (record && record.id !== syncedRecordId) {
|
||||||
|
setSyncedRecordId(record.id as string);
|
||||||
setForm({
|
setForm({
|
||||||
citation: record.case_number || "",
|
citation: record.case_number || "",
|
||||||
case_name: record.case_name || "",
|
case_name: record.case_name || "",
|
||||||
@@ -84,7 +86,7 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
|||||||
headnote: record.headnote || "",
|
headnote: record.headnote || "",
|
||||||
key_quote: (record as { key_quote?: string }).key_quote || "",
|
key_quote: (record as { key_quote?: string }).key_quote || "",
|
||||||
});
|
});
|
||||||
}, [record]);
|
}
|
||||||
|
|
||||||
const onSubmit = async (e: React.FormEvent) => {
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -84,9 +84,20 @@ export type Halacha = {
|
|||||||
precedent_level?: string;
|
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 & {
|
export type PrecedentDetail = Precedent & {
|
||||||
full_text: string;
|
full_text: string;
|
||||||
halachot: Halacha[];
|
halachot: Halacha[];
|
||||||
|
related_cases: RelatedCase[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SearchHit =
|
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<{
|
export type PrecedentPatch = Partial<{
|
||||||
case_name: string;
|
case_name: string;
|
||||||
court: string;
|
court: string;
|
||||||
|
|||||||
31
web/app.py
31
web/app.py
@@ -4380,6 +4380,37 @@ async def precedent_library_delete(case_law_id: str):
|
|||||||
return {"deleted": True, "case_law_id": case_law_id}
|
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
|
# Halacha and metadata extraction are LLM-driven and rely on the local
|
||||||
# `claude` CLI via mcp-server/services/claude_session.py — they CANNOT run
|
# `claude` CLI via mcp-server/services/claude_session.py — they CANNOT run
|
||||||
# from this container (no CLI, no claude.ai session). The endpoints below
|
# from this container (no CLI, no claude.ai session). The endpoints below
|
||||||
|
|||||||
Reference in New Issue
Block a user