Compare commits
19 Commits
worktree-c
...
38b3ffc587
| Author | SHA1 | Date | |
|---|---|---|---|
| 38b3ffc587 | |||
| 2ae68c5896 | |||
| dd0312e457 | |||
| eaf80dbe9a | |||
| f63bd4df0f | |||
| 25b41af6a3 | |||
| 979ec17a45 | |||
| eb0182ecf8 | |||
| a401197204 | |||
| 688de7f842 | |||
| a18ed8ffb7 | |||
| 086913ce8d | |||
| f7bf437f67 | |||
| 473c54fc43 | |||
| 92f0c7d208 | |||
| 422d79f9e8 | |||
| fb32c278e6 | |||
| 9e0f483e83 | |||
| 5a69980adf |
@@ -35,14 +35,14 @@
|
|||||||
| `case_number` (citation) | CHAIR (חובה) | מפתח idempotency |
|
| `case_number` (citation) | CHAIR (חובה) | מפתח idempotency |
|
||||||
| `full_text`, `extraction_status`, `source_kind` | DETERMINISTIC | — |
|
| `full_text`, `extraction_status`, `source_kind` | DETERMINISTIC | — |
|
||||||
| `case_name`, `court`, `date`, `headnote`, `summary`, `key_quote`, `subject_tags`, `appeal_subtype`, `precedent_level`, `source_type`, `citation_formatted` | CHAIR או OPUS | Opus ממלא רק אם ריק |
|
| `case_name`, `court`, `date`, `headnote`, `summary`, `key_quote`, `subject_tags`, `appeal_subtype`, `precedent_level`, `source_type`, `citation_formatted` | CHAIR או OPUS | Opus ממלא רק אם ריק |
|
||||||
| `is_binding` | CHAIR (default true) | קובע prompt-הלכה |
|
| `is_binding` | CHAIR (default true) **אך** DETERMINISTIC-false ל-`source_type=appeals_committee` | קובע prompt-הלכה. סמכות מבנית ([INV-DM7](02-data-model.md#inv-dm7-סיווג-הלכה--סמכות-נגזרת--תפקיד-כלל-מסווג-שני-צירים-לא-enum-אחד)): מקור-ועדה הוא תמיד persuasive — נכפה ל-false ב-`create_external_case_law` ונאכף ע"י `case_law_committee_not_binding_check` |
|
||||||
| chunks (`content`/`section_type`/`page_number`) | DETERMINISTIC | — |
|
| chunks (`content`/`section_type`/`page_number`) | DETERMINISTIC | — |
|
||||||
| `embedding` (chunks) | Voyage (לא-LLM-reasoning) | ⚠ לא-GENERATED ([gap-audit GAP-09](gap-audit.md)) |
|
| `embedding` (chunks) | Voyage (לא-LLM-reasoning) | ⚠ לא-GENERATED ([gap-audit GAP-09](gap-audit.md)) |
|
||||||
| כל `halachot` | OPUS | נכנס pending_review |
|
| כל `halachot` | OPUS | נכנס pending_review |
|
||||||
|
|
||||||
### 2ב. החלטה פנימית (`case_law`, source_kind=`internal_committee`)
|
### 2ב. החלטה פנימית (`case_law`, source_kind=`internal_committee`)
|
||||||
כמו 2א, ובנוסף: `case_number` **חובה**; `chair_name`/`district`/`proceeding_type` — CHAIR או OPUS או DERIVED;
|
כמו 2א, ובנוסף: `case_number` **חובה**; `chair_name`/`district`/`proceeding_type` — CHAIR או OPUS או DERIVED;
|
||||||
`source_type` = `appeals_committee` (DETERMINISTIC קבוע). placeholder `"(טרם חולץ)"` מסומן ל-chair_name/district
|
`source_type` = `appeals_committee` (DETERMINISTIC קבוע); `is_binding` = `false` (DETERMINISTIC קבוע — נכפה ב-`create_internal_committee_decision`, [INV-DM7](02-data-model.md#inv-dm7-סיווג-הלכה--סמכות-נגזרת--תפקיד-כלל-מסווג-שני-צירים-לא-enum-אחד): החלטת-ועדה אינה מחייבת ועדה אחרת ולא את עצמה — תמיד persuasive). placeholder `"(טרם חולץ)"` מסומן ל-chair_name/district
|
||||||
ריקים ומטופל כריק ע"י ה-extractor.
|
ריקים ומטופל כריק ע"י ה-extractor.
|
||||||
|
|
||||||
### 2ג. מסמך-תיק (`documents`)
|
### 2ג. מסמך-תיק (`documents`)
|
||||||
|
|||||||
@@ -236,7 +236,18 @@ async def extract_and_store(case_law_id: UUID) -> dict:
|
|||||||
if not row:
|
if not row:
|
||||||
return {"extracted": 0, "linked": 0, "new": 0, "skipped": 0, "error": "not_found"}
|
return {"extracted": 0, "linked": 0, "new": 0, "skipped": 0, "error": "not_found"}
|
||||||
|
|
||||||
text = row["full_text"] or ""
|
# A citation counts as the DECIDING body's reliance ONLY when it appears in
|
||||||
|
# the discussion/ruling sections — never where a party cites a ruling in its
|
||||||
|
# own arguments (block ז). Reuse the halacha extractor's section selection
|
||||||
|
# (G2: a single definition of "reasoning sections" — legal_analysis/ruling/
|
||||||
|
# conclusion + the discussion-anchor, excluding facts/claims/intro) so a
|
||||||
|
# ruling cited only in appellant_claims/parties_claims is never attributed to
|
||||||
|
# the chair. Fall back to full_text only for un-chunked rows.
|
||||||
|
from legal_mcp.services import halacha_extractor as _hx
|
||||||
|
disc_chunks, _used_fallback = await _hx._select_extractable_chunks(case_law_id)
|
||||||
|
text = "\n".join((c.get("content") or "") for c in disc_chunks).strip()
|
||||||
|
if not text:
|
||||||
|
text = row["full_text"] or ""
|
||||||
own_norm = _normalize_case_number(row["case_number"] or "")
|
own_norm = _normalize_case_number(row["case_number"] or "")
|
||||||
|
|
||||||
extracted = 0
|
extracted = 0
|
||||||
@@ -432,3 +443,42 @@ async def get_cited_case_law_ids(source_case_law_ids: list[UUID]) -> dict[str, l
|
|||||||
for r in rows:
|
for r in rows:
|
||||||
out.setdefault(r["source_id"], []).append(r["cited_id"])
|
out.setdefault(r["source_id"], []).append(r["cited_id"])
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def relink_orphan_citations(case_law_id: "UUID | None" = None) -> int:
|
||||||
|
"""Back-fill ``cited_case_law_id`` on orphan citation edges that now resolve.
|
||||||
|
|
||||||
|
Citations are resolved to a corpus row only at extraction time (see
|
||||||
|
``extract_and_store``). When a cited ruling is uploaded LATER, its existing
|
||||||
|
orphan edges (``cited_case_law_id IS NULL``) are never re-resolved, so the
|
||||||
|
citation graph under-counts real connectivity. Call on every precedent
|
||||||
|
upload (``case_law_id`` set → link only edges that resolve to that row) or as
|
||||||
|
a one-off sweep (``case_law_id=None`` → re-resolve every orphan). Reuses the
|
||||||
|
canonical resolver ``_resolve_case_law_id`` (no parallel matching logic, G2);
|
||||||
|
idempotent — a second run links nothing new.
|
||||||
|
"""
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
orphans = await conn.fetch(
|
||||||
|
"SELECT DISTINCT cited_case_number FROM precedent_internal_citations "
|
||||||
|
"WHERE cited_case_law_id IS NULL AND coalesce(cited_case_number, '') <> ''"
|
||||||
|
)
|
||||||
|
linked = 0
|
||||||
|
for r in orphans:
|
||||||
|
cited = await _resolve_case_law_id(_normalize_case_number(r["cited_case_number"]))
|
||||||
|
if cited is None:
|
||||||
|
continue
|
||||||
|
if case_law_id is not None and cited != case_law_id:
|
||||||
|
continue
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
res = await conn.execute(
|
||||||
|
"UPDATE precedent_internal_citations "
|
||||||
|
"SET cited_case_law_id = $1, confidence = GREATEST(confidence, 0.90) "
|
||||||
|
"WHERE cited_case_law_id IS NULL AND cited_case_number = $2",
|
||||||
|
cited, r["cited_case_number"],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
linked += int(res.split()[-1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
return linked
|
||||||
|
|||||||
@@ -1697,6 +1697,40 @@ CREATE INDEX IF NOT EXISTS idx_hcc_canonical
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_V42_SQL = """
|
||||||
|
-- INV-DM7: authority (binding/persuasive) is STRUCTURAL for committee sources.
|
||||||
|
-- An appeals-committee decision (source_kind='internal_committee', or any row
|
||||||
|
-- with source_type='appeals_committee') is persuasive, NEVER binding — a
|
||||||
|
-- committee's ruling does not bind another committee, nor itself. is_binding
|
||||||
|
-- stays a stored column (it selects the halacha-extraction prompt), but for
|
||||||
|
-- committee sources it is a faithful cache of the derived value, not a chair
|
||||||
|
-- guess. Mirrors 02-data-model.md §INV-DM7 / X8-field-provenance.md.
|
||||||
|
-- Step 1 — normalize legacy rows at the source (G1), idempotent.
|
||||||
|
UPDATE case_law SET is_binding = FALSE
|
||||||
|
WHERE is_binding IS TRUE
|
||||||
|
AND (source_kind = 'internal_committee' OR source_type = 'appeals_committee');
|
||||||
|
-- Step 2 — constrain so a binding committee row can never be written again.
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE case_law ADD CONSTRAINT case_law_committee_not_binding_check
|
||||||
|
CHECK (NOT ((source_kind = 'internal_committee'
|
||||||
|
OR source_type = 'appeals_committee') AND is_binding));
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_V43_SQL = """
|
||||||
|
-- חיפה (Haifa) is a distinct planning district, separate from הצפון (North).
|
||||||
|
-- A bug in _district_from_court mapped "חיפה" -> "צפון", and the service-layer
|
||||||
|
-- _VALID_DISTRICTS omitted "חיפה" entirely, so Haifa committee decisions were
|
||||||
|
-- mis-filed under צפון. Both fixed in internal_decisions.py; this reclassifies
|
||||||
|
-- the legacy rows at the source (G1) by their court text. Idempotent.
|
||||||
|
UPDATE case_law SET district = 'חיפה'
|
||||||
|
WHERE source_kind = 'internal_committee'
|
||||||
|
AND district = 'צפון'
|
||||||
|
AND court ILIKE '%חיפה%';
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# Stable, arbitrary key for the session-level advisory lock that serialises
|
# Stable, arbitrary key for the session-level advisory lock that serialises
|
||||||
# schema DDL across processes. Every short-lived process (cron drains, services)
|
# schema DDL across processes. Every short-lived process (cron drains, services)
|
||||||
# re-runs the idempotent migrations on startup; without this lock two processes
|
# re-runs the idempotent migrations on startup; without this lock two processes
|
||||||
@@ -1714,7 +1748,7 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
|||||||
await _apply_schema_ddl(conn)
|
await _apply_schema_ddl(conn)
|
||||||
finally:
|
finally:
|
||||||
await conn.execute("SELECT pg_advisory_unlock($1)", _MIGRATION_LOCK_KEY)
|
await conn.execute("SELECT pg_advisory_unlock($1)", _MIGRATION_LOCK_KEY)
|
||||||
logger.info("Database schema initialized (v1-v41)")
|
logger.info("Database schema initialized (v1-v43)")
|
||||||
|
|
||||||
|
|
||||||
async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
||||||
@@ -1760,6 +1794,8 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
|||||||
await conn.execute(SCHEMA_V39_SQL)
|
await conn.execute(SCHEMA_V39_SQL)
|
||||||
await conn.execute(SCHEMA_V40_SQL)
|
await conn.execute(SCHEMA_V40_SQL)
|
||||||
await conn.execute(SCHEMA_V41_SQL)
|
await conn.execute(SCHEMA_V41_SQL)
|
||||||
|
await conn.execute(SCHEMA_V42_SQL)
|
||||||
|
await conn.execute(SCHEMA_V43_SQL)
|
||||||
|
|
||||||
|
|
||||||
async def init_schema() -> None:
|
async def init_schema() -> None:
|
||||||
@@ -4253,6 +4289,34 @@ async def case_number_collides(case_number: str, exclude_id: UUID) -> bool:
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
# Subject-tag convention for case_law topic hubs (powers /graph topic nodes).
|
||||||
|
# A simple multi-word Hebrew phrase is stored with underscores between words
|
||||||
|
# ("היטל_השבחה") — the documented extraction contract (precedent_library tool
|
||||||
|
# examples: קווי_בניין, מועד_קביעת_שומה) that ~99% of the corpus already follows.
|
||||||
|
# The same phrase arriving with spaces ("היטל השבחה") is the same topic in
|
||||||
|
# disguise and spawns a duplicate graph hub. Normalize at the single case_law
|
||||||
|
# write chokepoint (G1) so no path can re-introduce the split. Tags carrying
|
||||||
|
# punctuation/digits/dashes (e.g. "פטור מותנה — סעיף 19(ג)") are left untouched —
|
||||||
|
# the convention covers only plain Hebrew word phrases.
|
||||||
|
_PURE_HEBREW_WORDS = re.compile(r"^[א-ת]+(?: [א-ת]+)+$")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_subject_tags(tags) -> list[str]:
|
||||||
|
"""Strip, apply the underscore convention to plain Hebrew phrases, dedup."""
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in tags or []:
|
||||||
|
t = str(raw).strip()
|
||||||
|
if not t:
|
||||||
|
continue
|
||||||
|
if _PURE_HEBREW_WORDS.match(t):
|
||||||
|
t = t.replace(" ", "_")
|
||||||
|
if t not in seen:
|
||||||
|
seen.add(t)
|
||||||
|
out.append(t)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def create_external_case_law(
|
async def create_external_case_law(
|
||||||
case_number: str,
|
case_number: str,
|
||||||
case_name: str,
|
case_name: str,
|
||||||
@@ -4277,8 +4341,13 @@ async def create_external_case_law(
|
|||||||
source_kind='cited_only' (auto-discovered), promote it to
|
source_kind='cited_only' (auto-discovered), promote it to
|
||||||
source_kind='external_upload' and fill in the missing fields.
|
source_kind='external_upload' and fill in the missing fields.
|
||||||
"""
|
"""
|
||||||
|
# INV-DM7: an appeals-committee source is persuasive even when uploaded via
|
||||||
|
# the external path — coerce to non-binding so it matches the committee
|
||||||
|
# invariant and case_law_committee_not_binding_check (G1).
|
||||||
|
if source_type == "appeals_committee":
|
||||||
|
is_binding = False
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
tags_json = json.dumps(subject_tags or [], ensure_ascii=False)
|
tags_json = json.dumps(_normalize_subject_tags(subject_tags), ensure_ascii=False)
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
# Atomic upsert on the V15 partial unique index
|
# Atomic upsert on the V15 partial unique index
|
||||||
# uq_case_law_external_number (case_number) WHERE source_kind <> 'internal_committee'.
|
# uq_case_law_external_number (case_number) WHERE source_kind <> 'internal_committee'.
|
||||||
@@ -4345,7 +4414,7 @@ async def create_internal_committee_decision(
|
|||||||
appeal_subtype: str = "",
|
appeal_subtype: str = "",
|
||||||
subject_tags: list[str] | None = None,
|
subject_tags: list[str] | None = None,
|
||||||
summary: str = "",
|
summary: str = "",
|
||||||
is_binding: bool = True,
|
is_binding: bool = False,
|
||||||
document_id: UUID | None = None,
|
document_id: UUID | None = None,
|
||||||
proceeding_type: str = "ערר",
|
proceeding_type: str = "ערר",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -4355,9 +4424,13 @@ async def create_internal_committee_decision(
|
|||||||
exist as both 'ערר' and 'בל"מ' (an extension-of-time request can be
|
exist as both 'ערר' and 'בל"מ' (an extension-of-time request can be
|
||||||
filed against an existing appeal with the same number).
|
filed against an existing appeal with the same number).
|
||||||
"""
|
"""
|
||||||
|
# INV-DM7: authority is structural for this source — an appeals-committee
|
||||||
|
# decision is persuasive, never binding. Coerce regardless of caller so the
|
||||||
|
# value can never violate case_law_committee_not_binding_check (G1).
|
||||||
|
is_binding = False
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
case_number = _canonical_case_number(case_number)
|
case_number = _canonical_case_number(case_number)
|
||||||
tags_json = json.dumps(subject_tags or [], ensure_ascii=False)
|
tags_json = json.dumps(_normalize_subject_tags(subject_tags), ensure_ascii=False)
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
# Atomic upsert on V15 partial unique index
|
# Atomic upsert on V15 partial unique index
|
||||||
# uq_case_law_internal_number_proc (case_number, proceeding_type)
|
# uq_case_law_internal_number_proc (case_number, proceeding_type)
|
||||||
@@ -4498,6 +4571,12 @@ async def update_case_law(case_law_id: UUID, **fields) -> dict | None:
|
|||||||
"proceeding_type", "citation_formatted", "parties",
|
"proceeding_type", "citation_formatted", "parties",
|
||||||
}
|
}
|
||||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||||
|
# INV-DM7: reclassifying a row to an appeals-committee source makes it
|
||||||
|
# persuasive — coerce is_binding in the same patch so the UPDATE can't
|
||||||
|
# violate case_law_committee_not_binding_check (e.g. the Gemini metadata
|
||||||
|
# extractor relabels an external court_ruling as appeals_committee).
|
||||||
|
if updates.get("source_type") == "appeals_committee":
|
||||||
|
updates["is_binding"] = False
|
||||||
if not updates:
|
if not updates:
|
||||||
return await get_case_law(case_law_id)
|
return await get_case_law(case_law_id)
|
||||||
|
|
||||||
@@ -4506,12 +4585,21 @@ async def update_case_law(case_law_id: UUID, **fields) -> dict | None:
|
|||||||
params: list = [case_law_id]
|
params: list = [case_law_id]
|
||||||
for i, (k, v) in enumerate(updates.items(), start=2):
|
for i, (k, v) in enumerate(updates.items(), start=2):
|
||||||
if k == "subject_tags":
|
if k == "subject_tags":
|
||||||
v = json.dumps(v or [], ensure_ascii=False)
|
v = json.dumps(_normalize_subject_tags(v), ensure_ascii=False)
|
||||||
set_parts.append(f"{k} = ${i}")
|
set_parts.append(f"{k} = ${i}")
|
||||||
params.append(v)
|
params.append(v)
|
||||||
sql = f"UPDATE case_law SET {', '.join(set_parts)} WHERE id = $1 RETURNING *"
|
sql = f"UPDATE case_law SET {', '.join(set_parts)} WHERE id = $1 RETURNING *"
|
||||||
row = await pool.fetchrow(sql, *params)
|
row = await pool.fetchrow(sql, *params)
|
||||||
return _row_to_case_law(row) if row else None
|
if row is None:
|
||||||
|
return None
|
||||||
|
# `searchable` is a DERIVED completeness flag (INV-DM1). It is computed at
|
||||||
|
# ingest end and after the Gemini metadata extractor — but internal-committee
|
||||||
|
# decisions skip Gemini, so when their summary/tags/metadata are filled later
|
||||||
|
# (manual edit, deterministic enrichment) the flag would otherwise go stale
|
||||||
|
# and the row stays invisible to RAG. Recompute at this write so the derived
|
||||||
|
# value never drifts from the content (G1: normalize at the source).
|
||||||
|
await recompute_searchable(case_law_id)
|
||||||
|
return await get_case_law(case_law_id)
|
||||||
|
|
||||||
|
|
||||||
async def set_case_law_extraction_status(case_law_id: UUID, status: str) -> None:
|
async def set_case_law_extraction_status(case_law_id: UUID, status: str) -> None:
|
||||||
@@ -8030,7 +8118,33 @@ _MP_PROVENANCE_COLS = """,
|
|||||||
WHERE pic.cited_case_law_id = mp.linked_case_law_id
|
WHERE pic.cited_case_law_id = mp.linked_case_law_id
|
||||||
AND COALESCE(src.case_number, '') <> ''
|
AND COALESCE(src.case_number, '') <> ''
|
||||||
) AS cited_by_precedents,
|
) AS cited_by_precedents,
|
||||||
substring(mp.notes from 'מס''?\\s*([0-9]+)') AS yomon_number"""
|
substring(mp.notes from 'מס''?\\s*([0-9]+)') AS yomon_number,
|
||||||
|
-- Bridge to the corpus citation graph (G2: read-time, no stored
|
||||||
|
-- duplication). For an OPEN gap (no linked_case_law_id yet) find
|
||||||
|
-- which committee DECISIONS cite this ruling, matched on the
|
||||||
|
-- normalized docket number (same normalization the relinker uses:
|
||||||
|
-- strip to digits/dashes, slash->dash). Surfaces "צוטט ע\"י <יו\"ר>"
|
||||||
|
-- + the deciding case number in the UI.
|
||||||
|
(SELECT array_agg(DISTINCT src.chair_name ORDER BY src.chair_name)
|
||||||
|
FROM precedent_internal_citations picc
|
||||||
|
JOIN case_law src ON src.id = picc.source_case_law_id
|
||||||
|
AND src.source_kind = 'internal_committee'
|
||||||
|
WHERE split_part(mp.citation_norm, '|', 2) <> ''
|
||||||
|
AND regexp_replace(replace(picc.cited_case_number, '/', '-'),
|
||||||
|
'[^0-9-]', '', 'g')
|
||||||
|
= split_part(mp.citation_norm, '|', 2)
|
||||||
|
AND COALESCE(src.chair_name, '') <> ''
|
||||||
|
) AS cited_by_chairs,
|
||||||
|
(SELECT array_agg(DISTINCT src.case_number ORDER BY src.case_number)
|
||||||
|
FROM precedent_internal_citations picd
|
||||||
|
JOIN case_law src ON src.id = picd.source_case_law_id
|
||||||
|
AND src.source_kind = 'internal_committee'
|
||||||
|
WHERE split_part(mp.citation_norm, '|', 2) <> ''
|
||||||
|
AND regexp_replace(replace(picd.cited_case_number, '/', '-'),
|
||||||
|
'[^0-9-]', '', 'g')
|
||||||
|
= split_part(mp.citation_norm, '|', 2)
|
||||||
|
AND COALESCE(src.case_number, '') <> ''
|
||||||
|
) AS cited_by_decisions"""
|
||||||
|
|
||||||
|
|
||||||
async def list_missing_precedents(
|
async def list_missing_precedents(
|
||||||
|
|||||||
@@ -233,6 +233,17 @@ async def ingest_document(
|
|||||||
await db.request_metadata_extraction(case_law_id)
|
await db.request_metadata_extraction(case_law_id)
|
||||||
await db.request_halacha_extraction(case_law_id)
|
await db.request_halacha_extraction(case_law_id)
|
||||||
await db.recompute_searchable(case_law_id)
|
await db.recompute_searchable(case_law_id)
|
||||||
|
# Citations resolve to a corpus row only at extraction time. A ruling
|
||||||
|
# uploaded AFTER it was first cited leaves orphan edges (NULL link),
|
||||||
|
# so re-link them to this freshly-ingested row now — the citation
|
||||||
|
# graph self-heals instead of permanently under-counting (G1). Non-fatal.
|
||||||
|
try:
|
||||||
|
from legal_mcp.services import citation_extractor as _ce
|
||||||
|
relinked = await _ce.relink_orphan_citations(case_law_id)
|
||||||
|
if relinked:
|
||||||
|
logger.info("relinked %d orphan citation(s) -> %s", relinked, case_law_id)
|
||||||
|
except Exception as e: # noqa: BLE001 — relink is best-effort
|
||||||
|
logger.warning("citation relink failed (non-fatal): %s", e)
|
||||||
|
|
||||||
await progress("completed", 100,
|
await progress("completed", 100,
|
||||||
f"נקלט: {stored_chunks} chunks. חילוץ הלכות ומטא-דאטה ממתינים בתור.")
|
f"נקלט: {stored_chunks} chunks. חילוץ הלכות ומטא-דאטה ממתינים בתור.")
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ logger = logging.getLogger(__name__)
|
|||||||
INTERNAL_DECISIONS_DIR = Path(config.DATA_DIR) / "internal-decisions"
|
INTERNAL_DECISIONS_DIR = Path(config.DATA_DIR) / "internal-decisions"
|
||||||
|
|
||||||
_VALID_PRACTICE_AREAS = frozenset({"", "rishuy_uvniya", "betterment_levy", "compensation_197"})
|
_VALID_PRACTICE_AREAS = frozenset({"", "rishuy_uvniya", "betterment_levy", "compensation_197"})
|
||||||
_VALID_DISTRICTS = frozenset({"", "ירושלים", "מרכז", "תל אביב", "צפון", "דרום", "ארצי"})
|
_VALID_DISTRICTS = frozenset({"", "ירושלים", "מרכז", "תל אביב", "חיפה", "צפון", "דרום", "ארצי"})
|
||||||
|
|
||||||
_COURT_TO_DISTRICT = [
|
_COURT_TO_DISTRICT = [
|
||||||
("ירושלים", "ירושלים"),
|
("ירושלים", "ירושלים"),
|
||||||
("תל אביב", "תל אביב"),
|
("תל אביב", "תל אביב"),
|
||||||
('ת"א', "תל אביב"),
|
('ת"א', "תל אביב"),
|
||||||
("מרכז", "מרכז"),
|
("מרכז", "מרכז"),
|
||||||
("חיפה", "צפון"),
|
("חיפה", "חיפה"),
|
||||||
("צפון", "צפון"),
|
("צפון", "צפון"),
|
||||||
("דרום", "דרום"),
|
("דרום", "דרום"),
|
||||||
("ארצי", "ארצי"),
|
("ארצי", "ארצי"),
|
||||||
@@ -77,7 +77,7 @@ async def _create_internal_record(**kw) -> dict:
|
|||||||
appeal_subtype=(kw.get("appeal_subtype") or "").strip(),
|
appeal_subtype=(kw.get("appeal_subtype") or "").strip(),
|
||||||
subject_tags=list(kw.get("subject_tags") or []),
|
subject_tags=list(kw.get("subject_tags") or []),
|
||||||
summary=(kw.get("summary") or "").strip(),
|
summary=(kw.get("summary") or "").strip(),
|
||||||
is_binding=kw.get("is_binding", True),
|
is_binding=kw.get("is_binding", False), # INV-DM7: committee = persuasive
|
||||||
document_id=kw.get("document_id"),
|
document_id=kw.get("document_id"),
|
||||||
proceeding_type=kw.get("proceeding_type") or "ערר",
|
proceeding_type=kw.get("proceeding_type") or "ערר",
|
||||||
)
|
)
|
||||||
@@ -108,7 +108,7 @@ async def ingest_internal_decision(
|
|||||||
appeal_subtype: str = "",
|
appeal_subtype: str = "",
|
||||||
subject_tags: list[str] | None = None,
|
subject_tags: list[str] | None = None,
|
||||||
summary: str = "",
|
summary: str = "",
|
||||||
is_binding: bool = True,
|
is_binding: bool = False, # INV-DM7: committee sources are persuasive
|
||||||
file_path: str | Path | None = None,
|
file_path: str | Path | None = None,
|
||||||
text: str | None = None,
|
text: str | None = None,
|
||||||
document_id: UUID | None = None,
|
document_id: UUID | None = None,
|
||||||
@@ -229,6 +229,7 @@ async def migrate_from_external_corpus(dry_run: bool = False) -> dict:
|
|||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""UPDATE case_law
|
"""UPDATE case_law
|
||||||
SET source_kind = 'internal_committee',
|
SET source_kind = 'internal_committee',
|
||||||
|
is_binding = FALSE, -- INV-DM7: committee = persuasive
|
||||||
district = CASE WHEN $2 <> '' THEN $2 ELSE district END
|
district = CASE WHEN $2 <> '' THEN $2 ELSE district END
|
||||||
WHERE id = $1""",
|
WHERE id = $1""",
|
||||||
row["id"], district,
|
row["id"], district,
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ import { MissingPrecedentsTable } from "@/components/missing-precedents/missing-
|
|||||||
|
|
||||||
type StatusFilter = MissingPrecedentStatus | "all";
|
type StatusFilter = MissingPrecedentStatus | "all";
|
||||||
|
|
||||||
|
// Only the two states the chair acts on: open gaps to fill, and closed gaps for
|
||||||
|
// reference. "הועלה" (transient) and "לא-רלוונטי" were dropped from the filter,
|
||||||
|
// and "הכל" with them (chair's request, design 09).
|
||||||
const STATUS_CHIPS: { value: StatusFilter; label: string }[] = [
|
const STATUS_CHIPS: { value: StatusFilter; label: string }[] = [
|
||||||
{ value: "open", label: "פתוח" },
|
{ value: "open", label: "פתוח" },
|
||||||
{ value: "uploaded", label: "הועלה" },
|
|
||||||
{ value: "closed", label: "נסגר" },
|
{ value: "closed", label: "נסגר" },
|
||||||
{ value: "irrelevant", label: "לא-רלוונטי" },
|
|
||||||
{ value: "all", label: "הכל" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function MissingPrecedentsPage() {
|
export default function MissingPrecedentsPage() {
|
||||||
@@ -145,15 +145,13 @@ export default function MissingPrecedentsPage() {
|
|||||||
<div className="rounded-lg border border-rule bg-parchment px-5 py-3.5 text-[0.82rem] text-ink-muted leading-7">
|
<div className="rounded-lg border border-rule bg-parchment px-5 py-3.5 text-[0.82rem] text-ink-muted leading-7">
|
||||||
<b className="text-ink-soft">מחזור-חיים:</b>{" "}
|
<b className="text-ink-soft">מחזור-חיים:</b>{" "}
|
||||||
<LifecycleChip tone="open">פתוח</LifecycleChip> →{" "}
|
<LifecycleChip tone="open">פתוח</LifecycleChip> →{" "}
|
||||||
<LifecycleChip tone="up">הועלה</LifecycleChip> →{" "}
|
|
||||||
<LifecycleChip tone="closed">נסגר</LifecycleChip>. פריט נפתח אוטומטית
|
<LifecycleChip tone="closed">נסגר</LifecycleChip>. פריט נפתח אוטומטית
|
||||||
בעת חילוץ ציטוט שאין לו תקדים בקורפוס; בהעלאת פסק-הדין הוא מקושר לרשומת
|
בעת חילוץ ציטוט שאין לו תקדים בקורפוס; בהעלאת פסק-הדין הוא מקושר לרשומת
|
||||||
הפסיקה דרך{" "}
|
הפסיקה דרך{" "}
|
||||||
<code className="rounded border border-rule bg-surface px-1.5 py-0.5 text-[0.75rem] text-gold-deep" dir="ltr">
|
<code className="rounded border border-rule bg-surface px-1.5 py-0.5 text-[0.75rem] text-gold-deep" dir="ltr">
|
||||||
linked_case_law_id
|
linked_case_law_id
|
||||||
</code>{" "}
|
</code>{" "}
|
||||||
ונסגר. פריט שאינו רלוונטי מסומן{" "}
|
ונסגר.
|
||||||
<LifecycleChip tone="na">לא-רלוונטי</LifecycleChip> מבלי שתידרש העלאה.
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Trash2, Upload, Pencil, ExternalLink } from "lucide-react";
|
import { Trash2, Upload, Pencil, ExternalLink, ChevronDown } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
@@ -63,6 +63,24 @@ function SourceChip({ party }: { party: CitedByParty | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COLS = 7;
|
||||||
|
|
||||||
|
function TableHeaderRow() {
|
||||||
|
return (
|
||||||
|
<TableHeader className="bg-parchment">
|
||||||
|
<TableRow className="border-rule hover:bg-transparent">
|
||||||
|
<TableHead className="text-ink-muted text-right font-medium text-xs">פסיקה</TableHead>
|
||||||
|
<TableHead className="text-ink-muted text-right font-medium text-xs">נושא</TableHead>
|
||||||
|
<TableHead className="text-ink-muted text-right font-medium text-xs">תיק</TableHead>
|
||||||
|
<TableHead className="text-ink-muted text-right font-medium text-xs">צוטט ע״י</TableHead>
|
||||||
|
<TableHead className="text-ink-muted text-right font-medium text-xs">סטטוס</TableHead>
|
||||||
|
<TableHead className="text-ink-muted text-right font-medium text-xs">נוצר</TableHead>
|
||||||
|
<TableHead className="text-navy" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TableSkeleton({ cols }: { cols: number }) {
|
function TableSkeleton({ cols }: { cols: number }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -79,6 +97,38 @@ function TableSkeleton({ cols }: { cols: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Accordion section meta — groups rows by discovery source (chair's request).
|
||||||
|
* "צוטט ע״י דפנה" (corpus-decision reliance) is the high-value group and opens
|
||||||
|
* by default; the noisy יומון group and the misc "אחר" group start collapsed. */
|
||||||
|
type SectionKey = "chair" | "digest" | "other";
|
||||||
|
const SECTION_META: Record<
|
||||||
|
SectionKey,
|
||||||
|
{ label: string; title: string; desc: string; chip: string; defaultOpen: boolean }
|
||||||
|
> = {
|
||||||
|
chair: {
|
||||||
|
label: "צוטט ע״י דפנה",
|
||||||
|
title: "פסיקה שהוועדה נסמכת עליה",
|
||||||
|
desc: "מצוטטת בהחלטות-הקורפוס (גרף-הציטוטים)",
|
||||||
|
chip: "bg-success-bg text-success",
|
||||||
|
defaultOpen: true,
|
||||||
|
},
|
||||||
|
digest: {
|
||||||
|
label: "יומון",
|
||||||
|
title: "זוהתה ביומון יומי",
|
||||||
|
desc: "מצביע, טרם צוטט בהחלטה",
|
||||||
|
chip: "bg-teal-bg text-teal",
|
||||||
|
defaultOpen: false,
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
label: "אחר",
|
||||||
|
title: "כתב-טענות / ידני",
|
||||||
|
desc: "צוטט בערר חי או נרשם ידנית",
|
||||||
|
chip: "bg-info-bg text-info",
|
||||||
|
defaultOpen: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const SECTION_ORDER: SectionKey[] = ["chair", "digest", "other"];
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
status?: MissingPrecedentStatus | "";
|
status?: MissingPrecedentStatus | "";
|
||||||
q?: string;
|
q?: string;
|
||||||
@@ -91,7 +141,11 @@ export function MissingPrecedentsTable({ status, q, legalTopic }: Props) {
|
|||||||
status: status === "" ? undefined : status,
|
status: status === "" ? undefined : status,
|
||||||
q,
|
q,
|
||||||
legalTopic,
|
legalTopic,
|
||||||
limit: 200,
|
// Load the full set so the accordion section counts (chair/digest/other)
|
||||||
|
// reflect the real totals — at limit:200 the page showed only the first
|
||||||
|
// page (e.g. 16 of 106 committee rows) while the header showed the true
|
||||||
|
// count, so the sections didn't sum to it. Backend caps at 2000.
|
||||||
|
limit: 1000,
|
||||||
});
|
});
|
||||||
const del = useDeleteMissingPrecedent();
|
const del = useDeleteMissingPrecedent();
|
||||||
|
|
||||||
@@ -108,6 +162,196 @@ export function MissingPrecedentsTable({ status, q, legalTopic }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Partition the current result set by discovery source so each accordion
|
||||||
|
* section renders only its own rows. A row "cited by the committee" (has a
|
||||||
|
* resolved chair from the citation-graph bridge) wins over its raw
|
||||||
|
* discovery_source — that's the group the chair cares about. */
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const g: Record<SectionKey, MissingPrecedent[]> = { chair: [], digest: [], other: [] };
|
||||||
|
for (const mp of data?.items ?? []) {
|
||||||
|
if (mp.cited_by_chairs?.length) g.chair.push(mp);
|
||||||
|
else if (mp.discovery_source === "digest") g.digest.push(mp);
|
||||||
|
else g.other.push(mp);
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const renderRow = (mp: MissingPrecedent) => (
|
||||||
|
<TableRow
|
||||||
|
key={mp.id}
|
||||||
|
className="border-rule hover:bg-rule-soft/30 cursor-pointer"
|
||||||
|
onClick={() => setOpenId(mp.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="max-w-[440px]">
|
||||||
|
{/* Single line when there's no distinct case_name — the old two-line
|
||||||
|
layout repeated the citation (name fell back to a truncation of the
|
||||||
|
same citation). Show the name row only when it adds information. */}
|
||||||
|
{mp.case_name && mp.case_name.trim() ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-navy font-semibold truncate">
|
||||||
|
{mp.case_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.72rem] text-ink-muted truncate" dir="rtl">
|
||||||
|
{mp.citation}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-navy font-semibold truncate" dir="rtl">
|
||||||
|
{mp.citation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm text-ink">{mp.legal_topic || "—"}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{mp.cited_by_decisions?.length ? (
|
||||||
|
/* committee decision(s) that cite this missing ruling
|
||||||
|
(corpus citation-graph bridge) */
|
||||||
|
<span className="inline-flex items-baseline gap-1.5">
|
||||||
|
<span className="text-sm text-navy font-semibold tabular-nums" dir="ltr">
|
||||||
|
{mp.cited_by_decisions[0]}
|
||||||
|
</span>
|
||||||
|
{mp.cited_by_decisions.length > 1 ? (
|
||||||
|
<span className="text-[0.7rem] text-ink-muted">
|
||||||
|
+{mp.cited_by_decisions.length - 1}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
) : mp.cited_in_case_number ? (
|
||||||
|
<Link
|
||||||
|
href={`/cases/${encodeURIComponent(mp.cited_in_case_number)}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-sm text-navy hover:text-gold-deep inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{mp.cited_in_case_number}
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-ink-muted text-sm">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-ink">
|
||||||
|
{mp.cited_by_chairs?.length ? (
|
||||||
|
/* cited by a committee DECISION in the corpus → show the chair who
|
||||||
|
relied on it (e.g. דפנה תמיר). Bridge from
|
||||||
|
precedent_internal_citations; takes priority over the generic
|
||||||
|
discovery-source chips. */
|
||||||
|
<>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full whitespace-nowrap bg-success-bg text-success border-transparent"
|
||||||
|
>
|
||||||
|
{mp.cited_by_chairs[0]}
|
||||||
|
</Badge>
|
||||||
|
{mp.cited_by_chairs.length > 1 ? (
|
||||||
|
<div className="text-[0.7rem] text-ink-muted mt-1">
|
||||||
|
+{mp.cited_by_chairs.length - 1} יו״ר
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : mp.discovery_source === "cited_only" ? (
|
||||||
|
<>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full whitespace-nowrap bg-plum-bg text-plum border-transparent"
|
||||||
|
>
|
||||||
|
פסיקה בקורפוס
|
||||||
|
</Badge>
|
||||||
|
{mp.cited_by_precedents?.length ? (
|
||||||
|
<div className="text-[0.7rem] text-ink-muted truncate max-w-[180px] mt-1">
|
||||||
|
מצוטט ע״י: {mp.cited_by_precedents.join(", ")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : mp.discovery_source === "digest" ? (
|
||||||
|
<>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full whitespace-nowrap bg-teal-bg text-teal border-transparent"
|
||||||
|
>
|
||||||
|
יומון
|
||||||
|
</Badge>
|
||||||
|
{mp.yomon_number ? (
|
||||||
|
<div className="text-[0.7rem] text-ink-muted mt-1">
|
||||||
|
מס׳ {mp.yomon_number}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SourceChip party={mp.cited_by_party} />
|
||||||
|
{mp.cited_by_party_name ? (
|
||||||
|
<div className="text-[0.7rem] text-ink-muted truncate max-w-[160px] mt-1">
|
||||||
|
{mp.cited_by_party_name}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={mp.status} />
|
||||||
|
{mp.linked_case_law_number ? (
|
||||||
|
<div className="text-[0.7rem] text-success mt-1">
|
||||||
|
↳ {mp.linked_case_law_name || mp.linked_case_law_number}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-[0.78rem] text-ink-muted">
|
||||||
|
{formatDate(mp.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-end">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{mp.status === "open" ? (
|
||||||
|
/* gold "העלה והשלם" CTA (mockup 09 `.btn`) */
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpenId(mp.id);
|
||||||
|
}}
|
||||||
|
className="h-7 bg-gold text-white hover:bg-gold-deep border-transparent text-[0.78rem] font-semibold"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5 me-1" />
|
||||||
|
העלה והשלם
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
/* passive "done" label for non-open rows */
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-md bg-rule-soft text-ink-muted text-[0.78rem] font-medium px-2.5 py-1">
|
||||||
|
{mp.status === "closed" ? "קושר" : STATUS_LABELS[mp.status]} ✓
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{mp.status !== "open" ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpenId(mp.id);
|
||||||
|
}}
|
||||||
|
title="פרטים"
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(mp);
|
||||||
|
}}
|
||||||
|
disabled={del.isPending}
|
||||||
|
className="text-danger hover:text-danger"
|
||||||
|
title="מחיקה"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-4 text-danger text-center text-sm">
|
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-4 text-danger text-center text-sm">
|
||||||
@@ -116,168 +360,61 @@ export function MissingPrecedentsTable({ status, q, legalTopic }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (isPending) {
|
||||||
<>
|
return (
|
||||||
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="bg-parchment">
|
<TableHeaderRow />
|
||||||
<TableRow className="border-rule hover:bg-transparent">
|
|
||||||
<TableHead className="text-ink-muted text-right font-medium text-xs">פסיקה</TableHead>
|
|
||||||
<TableHead className="text-ink-muted text-right font-medium text-xs">נושא</TableHead>
|
|
||||||
<TableHead className="text-ink-muted text-right font-medium text-xs">תיק</TableHead>
|
|
||||||
<TableHead className="text-ink-muted text-right font-medium text-xs">צוטט ע״י</TableHead>
|
|
||||||
<TableHead className="text-ink-muted text-right font-medium text-xs">סטטוס</TableHead>
|
|
||||||
<TableHead className="text-ink-muted text-right font-medium text-xs">נוצר</TableHead>
|
|
||||||
<TableHead className="text-navy" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isPending ? (
|
<TableSkeleton cols={COLS} />
|
||||||
<TableSkeleton cols={7} />
|
|
||||||
) : !data?.items.length ? (
|
|
||||||
<TableRow className="border-rule">
|
|
||||||
<TableCell colSpan={7} className="text-center text-ink-muted py-8">
|
|
||||||
אין פסיקות חסרות בקריטריונים הנוכחיים.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
data.items.map((mp) => (
|
|
||||||
<TableRow
|
|
||||||
key={mp.id}
|
|
||||||
className="border-rule hover:bg-rule-soft/30 cursor-pointer"
|
|
||||||
onClick={() => setOpenId(mp.id)}
|
|
||||||
>
|
|
||||||
<TableCell className="max-w-[440px]">
|
|
||||||
<div className="text-sm text-navy font-semibold truncate">
|
|
||||||
{mp.case_name || mp.citation.split(" ").slice(0, 6).join(" ")}
|
|
||||||
</div>
|
|
||||||
<div className="text-[0.72rem] text-ink-muted truncate" dir="rtl">
|
|
||||||
{mp.citation}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="text-sm text-ink">{mp.legal_topic || "—"}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{mp.cited_in_case_number ? (
|
|
||||||
<Link
|
|
||||||
href={`/cases/${encodeURIComponent(mp.cited_in_case_number)}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="text-sm text-navy hover:text-gold-deep inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{mp.cited_in_case_number}
|
|
||||||
<ExternalLink className="w-3 h-3" />
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className="text-ink-muted text-sm">—</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-ink">
|
|
||||||
{mp.discovery_source === "cited_only" ? (
|
|
||||||
<>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-full whitespace-nowrap bg-plum-bg text-plum border-transparent"
|
|
||||||
>
|
|
||||||
פסיקה בקורפוס
|
|
||||||
</Badge>
|
|
||||||
{mp.cited_by_precedents?.length ? (
|
|
||||||
<div className="text-[0.7rem] text-ink-muted truncate max-w-[180px] mt-1">
|
|
||||||
מצוטט ע״י: {mp.cited_by_precedents.join(", ")}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : mp.discovery_source === "digest" ? (
|
|
||||||
<>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-full whitespace-nowrap bg-teal-bg text-teal border-transparent"
|
|
||||||
>
|
|
||||||
יומון
|
|
||||||
</Badge>
|
|
||||||
{mp.yomon_number ? (
|
|
||||||
<div className="text-[0.7rem] text-ink-muted mt-1">
|
|
||||||
מס׳ {mp.yomon_number}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<SourceChip party={mp.cited_by_party} />
|
|
||||||
{mp.cited_by_party_name ? (
|
|
||||||
<div className="text-[0.7rem] text-ink-muted truncate max-w-[160px] mt-1">
|
|
||||||
{mp.cited_by_party_name}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<StatusBadge status={mp.status} />
|
|
||||||
{mp.linked_case_law_number ? (
|
|
||||||
<div className="text-[0.7rem] text-success mt-1">
|
|
||||||
↳ {mp.linked_case_law_name || mp.linked_case_law_number}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-[0.78rem] text-ink-muted">
|
|
||||||
{formatDate(mp.created_at)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-end">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
{mp.status === "open" ? (
|
|
||||||
/* gold "העלה והשלם" CTA (mockup 09 `.btn`) */
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setOpenId(mp.id);
|
|
||||||
}}
|
|
||||||
className="h-7 bg-gold text-white hover:bg-gold-deep border-transparent text-[0.78rem] font-semibold"
|
|
||||||
>
|
|
||||||
<Upload className="w-3.5 h-3.5 me-1" />
|
|
||||||
העלה והשלם
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
/* passive "done" label for non-open rows */
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-md bg-rule-soft text-ink-muted text-[0.78rem] font-medium px-2.5 py-1">
|
|
||||||
{mp.status === "closed" ? "קושר" : STATUS_LABELS[mp.status]} ✓
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{mp.status !== "open" ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setOpenId(mp.id);
|
|
||||||
}}
|
|
||||||
title="פרטים"
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDelete(mp);
|
|
||||||
}}
|
|
||||||
disabled={del.isPending}
|
|
||||||
className="text-danger hover:text-danger"
|
|
||||||
title="מחיקה"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data?.items.length) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-rule bg-surface shadow-sm px-6 py-10 text-center text-ink-muted text-sm">
|
||||||
|
אין פסיקות חסרות בקריטריונים הנוכחיים.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3.5">
|
||||||
|
{SECTION_ORDER.map((key) => {
|
||||||
|
const items = groups[key];
|
||||||
|
if (!items.length) return null;
|
||||||
|
const meta = SECTION_META[key];
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
key={key}
|
||||||
|
open={meta.defaultOpen}
|
||||||
|
className="group rounded-lg border border-rule bg-surface shadow-sm overflow-hidden"
|
||||||
|
>
|
||||||
|
<summary className="flex items-center gap-3 px-4 py-3.5 bg-parchment cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
|
||||||
|
<ChevronDown className="w-4 h-4 text-ink-muted transition-transform -rotate-90 group-open:rotate-0 shrink-0" />
|
||||||
|
<span className={`rounded-full px-2.5 py-0.5 text-[0.72rem] font-semibold whitespace-nowrap ${meta.chip}`}>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-bold text-navy text-sm">{meta.title}</span>
|
||||||
|
<span className="hidden sm:inline text-ink-muted text-[0.78rem]">
|
||||||
|
— {meta.desc}
|
||||||
|
</span>
|
||||||
|
<span className="ms-auto tabular-nums text-[0.8rem] text-ink-soft bg-surface border border-rule rounded-full px-2.5 py-0.5 font-semibold">
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<Table>
|
||||||
|
<TableHeaderRow />
|
||||||
|
<TableBody>{items.map(renderRow)}</TableBody>
|
||||||
|
</Table>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<MissingPrecedentDetailDrawer
|
<MissingPrecedentDetailDrawer
|
||||||
id={openId}
|
id={openId}
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ export type MissingPrecedent = {
|
|||||||
discovery_source: string | null; // manual | cited_only | digest | court_fetch
|
discovery_source: string | null; // manual | cited_only | digest | court_fetch
|
||||||
cited_by_precedents: string[] | null; // corpus precedents citing a cited_only stub
|
cited_by_precedents: string[] | null; // corpus precedents citing a cited_only stub
|
||||||
yomon_number: string | null; // digest gap's source yomon number
|
yomon_number: string | null; // digest gap's source yomon number
|
||||||
|
// Bridge to the corpus citation graph: committee decisions (and their chairs)
|
||||||
|
// that cite this still-missing ruling — fills "צוטט ע״י <יו״ר>" + "תיק".
|
||||||
|
cited_by_chairs: string[] | null;
|
||||||
|
cited_by_decisions: string[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MissingPrecedentListResponse = {
|
export type MissingPrecedentListResponse = {
|
||||||
|
|||||||
@@ -7141,7 +7141,7 @@ async def internal_decisions_upload(
|
|||||||
practice_area: str = Form(""),
|
practice_area: str = Form(""),
|
||||||
appeal_subtype: str = Form(""),
|
appeal_subtype: str = Form(""),
|
||||||
subject_tags: str = Form("[]"),
|
subject_tags: str = Form("[]"),
|
||||||
is_binding: bool = Form(True),
|
is_binding: bool = Form(False), # INV-DM7: committee = persuasive (coerced at db too)
|
||||||
summary: str = Form(""),
|
summary: str = Form(""),
|
||||||
):
|
):
|
||||||
"""Upload a planning appeals-committee decision to the internal corpus.
|
"""Upload a planning appeals-committee decision to the internal corpus.
|
||||||
@@ -7877,7 +7877,7 @@ async def missing_precedents_list(
|
|||||||
case_id=case_uuid,
|
case_id=case_uuid,
|
||||||
legal_topic=legal_topic.strip() or None,
|
legal_topic=legal_topic.strip() or None,
|
||||||
q=q.strip() or None,
|
q=q.strip() or None,
|
||||||
limit=max(1, min(int(limit), 500)),
|
limit=max(1, min(int(limit), 2000)),
|
||||||
offset=max(0, int(offset)),
|
offset=max(0, int(offset)),
|
||||||
)
|
)
|
||||||
# Counters useful for the sidebar badge.
|
# Counters useful for the sidebar badge.
|
||||||
|
|||||||
Reference in New Issue
Block a user