Compare commits

...

19 Commits

Author SHA1 Message Date
38b3ffc587 Merge pull request 'feat(corpus): עיצוב-מחדש קורפוס-הפסיקה — ביטול תור-ההלכות, שכבת-מאומת-מאזכורים, דירוג-בזמן-אחזור (#153)' (#315) from worktree-canonical-synthesis into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m32s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
Merge PR #315: corpus redesign — no queue, verified-by-citation, rank-at-retrieval (#153)
2026-06-20 13:55:49 +00:00
2ae68c5896 Merge pull request 'fix(missing-precedents): load full set so accordion counts match the header' (#314) from worktree-mp-loadall into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 42s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-20 13:35:05 +00:00
dd0312e457 fix(missing-precedents): load full set so accordion counts match the header
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
The table fetched limit:200 while the header shows the true open count (~497,
via a separate COUNT). With accordion grouping this became visible: sections
summed to exactly 200 (e.g. 16 chair / 173 digest / 11 other) — and 90 of the
106 committee rows (the high-value "cited by Dafna" ones) were hidden past row
200. True buckets: chair 106 / digest 328 / other 63.

- web-ui: list limit 200 → 1000 so all open rows load; accordion section counts
  (computed from the loaded set) now equal the header total.
- backend: list cap 500 → 2000 to allow it (response stays a few hundred KB).

Logic/data only — no visual change (bug-fix exception to the design gate).
tsc --noEmit clean; py_compile clean.

Follow-up: when the backlog (digests grow daily) approaches the cap, move to
server-side per-bucket counts + pagination/virtualization (design gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 13:34:36 +00:00
eaf80dbe9a Merge pull request 'fix(citations): extract internal citations from discussion sections only (G1/G2)' (#313) from worktree-citation-sections into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m30s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-20 13:29:42 +00:00
f63bd4df0f fix(citations): extract internal citations from discussion sections only (G1/G2)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
extract_and_store read the WHOLE full_text, so a ruling a party cited in its
own arguments (block ז / appellant_claims / parties_claims) was recorded as the
deciding committee citing it — and surfaced as "צוטט ע״י דפנה". Example: ערר
130/10 appears only in the appellant_claims chunk of decision 1200-12-25, yet
was attributed to the chair.

Fix: build the extraction text from the discussion/ruling chunks via the
halacha extractor's _select_extractable_chunks (G2 — one definition of
"reasoning sections": legal_analysis/ruling/conclusion + discussion-anchor,
excluding facts/claims/intro). Falls back to full_text only for un-chunked rows.
A citation now reflects the deciding body's reliance, not a party's argument.

Existing data reconciled via a one-off (backup
data/audit/citation-edges-backup-*.csv): per source decision, dropped edges
whose cited number is absent from the discussion-only set — 82 of 398 removed
(incl. ערר 130/10 ↔ 1200-12-25); 316 genuine edges kept.

Invariants: G1 (correct at the source) · G2 (single reasoning-section selector,
shared with halacha extraction) · INV-LRN2 (quality-at-source).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 13:29:14 +00:00
25b41af6a3 Merge pull request 'feat(missing-precedents): accordion grouping by discovery source' (#312) from worktree-mp-accordion into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-20 12:53:41 +00:00
979ec17a45 feat(missing-precedents): accordion grouping by discovery source
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Chair-approved (Claude Design card 09): split the flat table into collapsible
sections by where the gap surfaced, so the high-value "cited by Dafna" rulings
aren't buried among the 330 digest rows.

- Three <details> accordion sections within the active status tab:
  · "צוטט ע״י דפנה" — rows with a resolved chair from the citation-graph bridge
    (cited_by_chairs non-empty); open by default.
  · "יומון" — discovery_source='digest'; collapsed.
  · "אחר" — party-cited / manual remainder; collapsed.
- Row JSX extracted to renderRow() and reused across sections; shared
  TableHeaderRow(); grouping via useMemo over the current result set.
- Empty sections are hidden; section header shows source chip + count.

No backend change — groups on existing cited_by_chairs / discovery_source.
tsc --noEmit clean.

Invariants: INV-IA* (task-oriented surface, single gate) — UI only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:53:08 +00:00
eb0182ecf8 Merge pull request 'fix(graph): normalize case_law subject_tags to underscore convention at write chokepoint' (#311) from worktree-subject-tag-normalize into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m30s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-20 12:39:50 +00:00
a401197204 fix(graph): normalize case_law subject_tags to underscore convention at write chokepoint
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
Topic hubs in /graph are built from case_law.subject_tags. The documented
extraction contract (precedent_library tool examples: קווי_בניין,
מועד_קביעת_שומה) and ~99% of the corpus store plain multi-word Hebrew tags
with underscores between words ("היטל_השבחה"). A single case (8126-03-25,
יעקב עמיאל) was tagged with spaces ("היטל השבחה"), which the graph renders as
a SECOND, distinct topic hub — a duplicate of the underscore form. The data
was normalized separately; this enforces the convention at the source so no
write path can re-introduce the split.

_normalize_subject_tags() is applied at the three (and only) case_law write
chokepoints in db.py — create_external_case_law, create_internal_committee_decision,
update_case_law — so the rule cannot be bypassed (G1: normalize at source, not
in the read/graph path). Tags carrying punctuation/digits/dashes
(e.g. "פטור מותנה — סעיף 19(ג)") are left untouched; only plain Hebrew word
phrases ([א-ת]+ separated by spaces) are converted. Also dedups post-normalize.

digests.subject_tags (TEXT[], a different graph layer) and canonical_halachot
are intentionally out of scope.

Invariants: maintains G1 (fix at source, not in the read projection),
G2 (graph_api stays a pure read projection — no parallel normalization there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:39:06 +00:00
688de7f842 Merge pull request 'feat(missing-precedents): cited-by-chair bridge + open/closed tabs + single-line citation' (#310) from worktree-missing-prec-page into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-20 12:29:10 +00:00
a18ed8ffb7 feat(missing-precedents): bridge cited-by-chair + decision number; open/closed tabs; single-line citation
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
Implements the chair-approved redesign of /missing-precedents (Claude Design
card 09). The "צוטט ע״י" and "תיק" columns were empty for ~98% of rows because
the corpus-decision reliance lives in precedent_internal_citations (keyed to
case_law), a different system than missing_precedents (keyed to cases).

Backend (G2 — read-time bridge, no stored duplication):
- list_missing_precedents: new cited_by_chairs / cited_by_decisions columns,
  resolved from the citation graph by normalized docket number (same
  normalization the relinker uses). For an open gap cited by committee
  decisions, surfaces the chair(s) (e.g. דפנה תמיר) + the deciding case numbers.

Frontend (matches approved mockup):
- "צוטט ע״י" shows the chair chip (bridge) with priority over the generic
  discovery-source chips; "תיק" shows the deciding decision number(s) + "+N".
- Status filter reduced to פתוח / נסגר (removed הועלה, לא-רלוונטי, הכל).
- "פסיקה" column collapses to a single line when case_name is empty — fixes the
  duplicated-citation two-row rendering.
- Lifecycle note simplified to open → closed.

Verified: bridge SQL returns chair+decisions on live data; tsc --noEmit clean;
py_compile clean.

Follow-up (data, separate): LLM backfill of legal_topic (נושא); create
missing_precedents rows for the 28 graph-cited rulings not yet tracked.

Invariants: G2 (single source of truth — bridge derived at read, no duplicated
column) · INV-AH context (closing source gaps).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:28:38 +00:00
086913ce8d Merge pull request 'fix(corpus): re-link orphan citations when a cited ruling is uploaded (G1)' (#309) from worktree-citation-relink into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m26s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-20 11:47:17 +00:00
f7bf437f67 fix(corpus): re-link orphan citations when a cited ruling is uploaded (G1)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
precedent_internal_citations resolves cited_case_law_id only at extraction
time. When a decision cites a ruling that isn't in the corpus yet, the edge is
stored with cited_case_law_id=NULL. The ruling is often uploaded days later —
but the orphan edge was never re-resolved, so the citation graph permanently
under-counted real connectivity (same stale-derived-value pattern as the
searchable flag). Worse, external rulings store case_number WITHOUT the court
prefix ("3213/97") while citations carry it ("ע\"א 3213/97"), so a large share
of "missing" citations were actually present-but-unlinked.

Fix:
- new citation_extractor.relink_orphan_citations(case_law_id=None): re-resolves
  orphan edges via the canonical _resolve_case_law_id (no parallel matching, G2);
  scoped to one row, or full sweep when None. Idempotent.
- ingest_document calls it on every upload (non-fatal) so the graph self-heals.

Existing backlog already re-linked via a one-off sweep against the live DB:
linked 128 edges → in-corpus distinct rulings 67→178, unlinked distinct
262→143. So real "cited but not uploaded" ≈ 143, not the 262 the stale graph
implied.

Invariants: G1 (re-resolve the derived link at the write) · G2 (single resolver).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 11:46:47 +00:00
473c54fc43 Merge pull request 'fix(corpus): refresh searchable flag on metadata patch (INV-DM1, G1)' (#308) from worktree-searchable-freshness into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m31s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-20 11:36:26 +00:00
92f0c7d208 fix(corpus): refresh searchable flag on metadata patch (INV-DM1, G1)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
The `searchable` completeness flag is recomputed only at ingest end and after
the Gemini metadata extractor. Internal-committee decisions skip Gemini, so
when their summary/subject_tags/metadata are filled after ingest the flag goes
stale and the row stays invisible to RAG despite being complete. Found 4 such
decisions (1049-06-21, 8126-03-25 [יעקב עמיאל], 8181-21 [האוניברסיטה העברית],
85074-04-25) — all complete (chunks+embeddings, extraction+halacha completed,
metadata present) yet searchable=false.

Fix: recompute_searchable() at the end of update_case_law — the single
chokepoint for metadata patches — so the derived flag never drifts from the
content (G1). Existing stale rows already corrected via a one-off canonical
recompute_searchable(None) run (corpus: 328 -> 332 searchable).

Invariants: INV-DM1 (completeness contract) · G1 (normalize derived value at
the write, no read-time patch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 11:35:59 +00:00
422d79f9e8 Merge pull request 'fix(corpus): חיפה is its own district, not folded into צפון (G2)' (#307) from worktree-haifa-district into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m32s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-20 11:25:46 +00:00
fb32c278e6 fix(corpus): חיפה is its own district, not folded into צפון (G2)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Haifa (מחוז חיפה) and the North (מחוז הצפון) are separate Israeli planning
districts, each with its own appeals committee. Two divergent bugs collapsed
Haifa into צפון:
- services/internal_decisions.py _VALID_DISTRICTS omitted "חיפה" (while the
  tools-layer VALID_DISTRICTS and db.py citation-abbrev map both include it —
  a G2 single-source-of-truth divergence)
- _COURT_TO_DISTRICT mapped "חיפה" -> "צפון", so Haifa courts derived צפון

Result: 2 Haifa committee decisions (1074-08-23, 8508-03-24) were mis-filed
under צפון, and "חיפה" never appeared as a district.

Changes:
- add "חיפה" to service _VALID_DISTRICTS
- _COURT_TO_DISTRICT: ("חיפה","צפון") -> ("חיפה","חיפה")
- SCHEMA_V43: reclassify legacy internal rows whose court mentions חיפה from
  צפון -> חיפה (normalize at source, G1; idempotent)

Verified against live DB (rollback txn): 2 rows move צפון(6)->צפון(4)+חיפה(2).

Invariants: G1 (normalize at source) · G2 (single source of truth for the
valid-district set — follow-up: consolidate the 3 divergent lists).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 11:25:20 +00:00
9e0f483e83 Merge pull request 'fix(corpus): committee decisions are persuasive, never binding (INV-DM7)' (#306) from worktree-committee-binding into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m35s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-20 10:53:27 +00:00
5a69980adf fix(corpus): committee decisions are persuasive, never binding (INV-DM7)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 9s
ועדת-ערר decisions (source_kind=internal_committee / source_type=
appeals_committee) are persuasive authority — they do not bind another
committee, nor the committee itself. The stored is_binding column wrongly
defaulted to True across the FastAPI form + service/db layers, so 46 of 92
committee rows were marked binding and got the BINDING halacha-extraction
prompt. Authority is structural for this source (INV-DM7) — normalize at the
source (G1), not trust the input.

Changes:
- db.create_internal_committee_decision: coerce is_binding=False (structural)
- db.create_external_case_law: coerce False when source_type=appeals_committee
- db.update_case_law: coerce False when a patch relabels source_type=
  appeals_committee (Gemini reclassification path)
- internal_decisions.migrate_from_external_corpus: set is_binding=FALSE on
  the external→internal reclassification UPDATE
- service + FastAPI-form defaults: True → False for the internal path
- SCHEMA_V42: backfill legacy committee rows (is_binding True→False) +
  CHECK constraint case_law_committee_not_binding_check so a binding
  committee row can never be written again ("so it doesn't recur")
- spec: X8-field-provenance aligned to INV-DM7

Verified against live DB in a rollback transaction: 46 rows flip to 0;
court_ruling (239) + cited_only (31) unaffected; binding-committee INSERT
rejected by the constraint.

Invariants: INV-DM7 (authority ⊥ rule-type) · G1 (normalize at source) ·
G2 (single source of truth, no parallel path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:52:30 +00:00
9 changed files with 496 additions and 181 deletions

View File

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

View File

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

View File

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

View File

@@ -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. חילוץ הלכות ומטא-דאטה ממתינים בתור.")

View File

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

View File

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

View File

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

View File

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

View File

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