Compare commits

..

25 Commits

Author SHA1 Message Date
b8c49a1269 Merge pull request 'fix(precedents): מראה-מקום never blank — seed at upload + inline Gemini enrichment' (#318) from worktree-citation-autofill-root 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 10s
2026-06-20 15:30:40 +00:00
b0bcdbeeef fix(precedents): מראה-מקום never blank — seed at upload + inline Gemini enrichment (root cause)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
Root cause: the upload form "מראה המקום" field is stored only as case_number;
citation_formatted was left empty and filled ONLY by the async metadata
extraction, which runs in the local drainer (not the container) because it was
lumped with halacha extraction. Result: the chair saw an empty מראה-מקום during
the window and re-typed it by hand.

Fix (hybrid — Option C):
- Seed: _create_external_record / create_external_case_law store the chair's typed
  citation as citation_formatted at INSERT, so the field is never blank. A
  cited_only→external promotion preserves an existing non-empty value (prior edit).
- Enrich: ingest_precedent runs metadata extraction INLINE in-container
  (gemini_session is direct REST, no local CLI) with force_citation=True, upgrading
  the seed to the canonical derived citation (parties + reporter + date) before the
  chair opens the page. Best-effort: on no key / API failure the seed remains and
  the queued metadata drain stays as the fallback. Halacha extraction is untouched
  (stays local — claude_session).
- apply_to_record / extract_and_apply gain force_citation: re-assemble even when
  citation_formatted is non-empty, writing only when assembly SUCCEEDS (seed
  preserved on abstention). The drainer keeps the default (False) so a chair's
  manual edit in /precedents/[id] is never clobbered.

Ops: GOOGLE_GEMINI_API_KEY added to the legal-ai Coolify app (runtime) — the SoT
value from Infisical nautilus:/external-apis/gemini, validated against the API.

Invariants: G2 (one citation-resolution + one metadata-extraction path, reused —
no parallel logic), INV-ID2/X1§3 (citation_formatted stays a derived field; the
seed is provisional and upgraded), INV-G10 (chair edits preserved). No schema change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:30:00 +00:00
2c515966c5 Merge pull request 'feat(precedents): surface auto-detected incoming citations in the "ציטוטים מקושרים" panel' (#317) from worktree-incoming-citations-panel into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m30s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-20 14:55:01 +00:00
91c521922f feat(precedents): surface auto-detected incoming citations in the "ציטוטים מקושרים" panel
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
The precedent-detail "ציטוטים מקושרים" panel rendered only MANUAL appeal-chain
links (case_law_relations), so it stayed empty even when decisions cite the
ruling — the automatic citation graph (precedent_internal_citations) was never
surfaced. e.g. עע"מ 317/10 (שפר) has 12 incoming citations in the DB, none shown.

Fills the existing panel from the citation graph (data/logic only, no new
visual design — gate-exempt per web-ui/AGENTS.md):
- citation_extractor.list_citations_to_case_law: return source precedent_level/
  court/date too (additive; reuses the one canonical incoming query — G2).
- precedent_library.get_precedent: add incoming_citations[], shaped like the
  RelatedCase row. No parallel resolution path.
- web-ui: render incoming citations in the same row style, read-only (no unlink),
  de-duped against manual links; manual "קשר" linking preserved alongside.

Invariants: G2 (single citation-resolution path, reused), G1 (graph self-heals
via existing relink_orphan_citations on upload). No schema change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:54:12 +00:00
ad29f6033f Merge pull request 'fix(graph): רצפת מסנן-השנה ל-1980 + תקרה דינמית' (#316) from worktree-graph-year-floor into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 45s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-20 14:36:29 +00:00
9d4960f28f fix(graph): lower year-filter floor to 1980, dynamic ceiling
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
The /graph "משנה / עד שנה" dropdown was hardcoded to 1994–2026, so dated
precedents below 1994 were unreachable by the year filter. After backfilling
decision dates, the corpus now has precedents from 1982 (ע"א 725/81), 1988
(910/86) and 1990 (בג"ץ 1578/90).

Floor → 1980 (covers the oldest); ceiling → current year via getFullYear()
so the range never ages out. Pure UI-logic change — same year_from/year_to
params, no new data path (G2), no backend touch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:35:49 +00:00
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
15 changed files with 653 additions and 196 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
@@ -393,7 +404,10 @@ async def list_citations_to_case_law(case_law_id: UUID) -> list[dict]:
cl.case_number AS source_case_number, cl.case_number AS source_case_number,
cl.case_name AS source_case_name, cl.case_name AS source_case_name,
cl.chair_name AS source_chair_name, cl.chair_name AS source_chair_name,
cl.district AS source_district cl.district AS source_district,
cl.precedent_level AS source_precedent_level,
cl.court AS source_court,
cl.date AS source_date
FROM precedent_internal_citations pic FROM precedent_internal_citations pic
JOIN case_law cl ON cl.id = pic.source_case_law_id JOIN case_law cl ON cl.id = pic.source_case_law_id
WHERE pic.cited_case_law_id = $1 WHERE pic.cited_case_law_id = $1
@@ -432,3 +446,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,
@@ -4270,15 +4334,27 @@ async def create_external_case_law(
precedent_level: str = "", precedent_level: str = "",
is_binding: bool = True, is_binding: bool = True,
document_id: UUID | None = None, document_id: UUID | None = None,
citation_formatted: str = "",
) -> dict: ) -> dict:
"""Insert a chair-uploaded external precedent into case_law. """Insert a chair-uploaded external precedent into case_law.
If a row with this ``case_number`` already exists with If a row with this ``case_number`` already exists with
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.
``citation_formatted`` seeds the מראה-מקום from the chair's typed upload-form
value so the field is NEVER blank between upload and metadata extraction (the
inline enrichment later UPGRADES it to the canonical derived form via
``apply_to_record(force_citation=True)``). On a cited_only→external promotion
an existing non-empty value (a prior chair edit) is preserved.
""" """
# 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'.
@@ -4295,11 +4371,12 @@ async def create_external_case_law(
summary, key_quote, full_text, source_url, summary, key_quote, full_text, source_url,
source_kind, document_id, extraction_status, source_kind, document_id, extraction_status,
halacha_extraction_status, practice_area, appeal_subtype, halacha_extraction_status, practice_area, appeal_subtype,
headnote, source_type, precedent_level, is_binding, content_hash headnote, source_type, precedent_level, is_binding, content_hash,
citation_formatted
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $1, $2, $3, $4, $5, $6, $7, $8, $9,
'external_upload', $10, 'processing', 'pending', 'external_upload', $10, 'processing', 'pending',
$11, $12, $13, $14, $15, $16, $17 $11, $12, $13, $14, $15, $16, $17, $18
) )
ON CONFLICT (case_number) WHERE source_kind <> 'internal_committee' ON CONFLICT (case_number) WHERE source_kind <> 'internal_committee'
DO UPDATE SET DO UPDATE SET
@@ -4321,14 +4398,16 @@ async def create_external_case_law(
source_kind = 'external_upload', source_kind = 'external_upload',
extraction_status = 'processing', extraction_status = 'processing',
halacha_extraction_status = 'pending', halacha_extraction_status = 'pending',
content_hash = EXCLUDED.content_hash content_hash = EXCLUDED.content_hash,
citation_formatted = COALESCE(
NULLIF(case_law.citation_formatted, ''), EXCLUDED.citation_formatted)
RETURNING * RETURNING *
""", """,
case_number, case_name, court, decision_date, tags_json, case_number, case_name, court, decision_date, tags_json,
summary, key_quote, full_text, source_url, summary, key_quote, full_text, source_url,
document_id, practice_area, appeal_subtype, headnote, document_id, practice_area, appeal_subtype, headnote,
source_type, precedent_level, is_binding, source_type, precedent_level, is_binding,
_content_hash(full_text), _content_hash(full_text), (citation_formatted or "").strip(),
) )
return _row_to_case_law(row) return _row_to_case_law(row)
@@ -4345,7 +4424,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 +4434,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 +4581,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 +4595,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 +8128,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

@@ -68,7 +68,11 @@ def _external_staging_subdir(inputs: dict) -> str:
async def _create_external_record(**kw) -> dict: async def _create_external_record(**kw) -> dict:
"""Adapter: maps canonical inputs (citation) to create_external_case_law(case_number).""" """Adapter: maps canonical inputs (citation) to create_external_case_law(case_number).
The chair's typed citation seeds ``citation_formatted`` so the מראה-מקום is never
blank before metadata extraction; the inline enrichment upgrades it to the
canonical form (see ``ingest_precedent``)."""
return await db.create_external_case_law( return await db.create_external_case_law(
case_number=kw["citation"].strip(), case_number=kw["citation"].strip(),
case_name=kw["case_name"], case_name=kw["case_name"],
@@ -84,6 +88,7 @@ async def _create_external_record(**kw) -> dict:
precedent_level=kw.get("precedent_level", ""), precedent_level=kw.get("precedent_level", ""),
is_binding=kw.get("is_binding", True), is_binding=kw.get("is_binding", True),
document_id=kw.get("document_id"), document_id=kw.get("document_id"),
citation_formatted=kw["citation"].strip(),
) )
@@ -126,10 +131,28 @@ async def ingest_precedent(
"appeal_subtype": appeal_subtype, "subject_tags": subject_tags, "appeal_subtype": appeal_subtype, "subject_tags": subject_tags,
"is_binding": is_binding, "headnote": headnote, "summary": summary, "is_binding": is_binding, "headnote": headnote, "summary": summary,
} }
return await ingest.ingest_document( result = await ingest.ingest_document(
_EXTERNAL_SPEC, inputs=inputs, file_path=file_path, _EXTERNAL_SPEC, inputs=inputs, file_path=file_path,
document_id=document_id, progress=progress, document_id=document_id, progress=progress,
) )
# Inline metadata enrichment (Gemini, in-container — gemini_session is direct
# REST, no local CLI). UPGRADES the seeded provisional citation_formatted to the
# canonical derived form (parties + reporter + date) so the מראה-מקום is correct
# the moment the chair opens /precedents/[id] — not deferred to the local drainer.
# force_citation=True overwrites the seed only; chair edits aren't reachable yet
# (row just created). Best-effort: on no key / API failure the seed remains and
# the queued metadata drain (request_metadata_extraction, already set) is the
# fallback. Halacha extraction is NOT touched here — it stays local (claude_session).
cid = result.get("case_law_id") if isinstance(result, dict) else None
if cid:
try:
from legal_mcp.services import precedent_metadata_extractor as _pme
r = await _pme.extract_and_apply(UUID(str(cid)), force_citation=True)
if r.get("status") == "completed":
await db.set_case_law_metadata_status(UUID(str(cid)), "completed")
except Exception as e: # noqa: BLE001 — enrichment is best-effort; drainer is fallback
logger.warning("inline metadata enrichment failed for %s (drainer will retry): %s", cid, e)
return result
async def reextract_halachot( async def reextract_halachot(
@@ -411,6 +434,28 @@ async def get_precedent(case_law_id: UUID | str) -> dict | None:
return None return None
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500) record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
record["related_cases"] = await db.get_case_law_relations(case_law_id) record["related_cases"] = await db.get_case_law_relations(case_law_id)
# Auto-detected citation graph: which decisions cite THIS ruling (incoming
# edges in precedent_internal_citations). Reuses the canonical query (G2) —
# no parallel resolution path. Shaped to match the front-end RelatedCase row
# so the existing "ציטוטים מקושרים" panel renders them with no visual change.
from legal_mcp.services import citation_extractor as _ce
raw = await _ce.list_citations_to_case_law(case_law_id)
record["incoming_citations"] = [
{
"id": r["source_case_law_id"],
"case_number": r.get("source_case_number") or r.get("cited_case_number") or "",
"case_name": r.get("source_case_name") or "",
"court": r.get("source_court") or "",
"precedent_level": r.get("source_precedent_level") or "",
"chair_name": r.get("source_chair_name") or "",
"date": (
r["source_date"].isoformat()
if r.get("source_date") is not None else None
),
"confidence": r.get("confidence"),
}
for r in raw
]
return record return record

View File

@@ -260,6 +260,7 @@ async def apply_to_record(
case_law_id: UUID | str, case_law_id: UUID | str,
suggested: dict, suggested: dict,
overwrite_case_number: bool = False, overwrite_case_number: bool = False,
force_citation: bool = False,
) -> dict: ) -> dict:
"""Merge suggested metadata into the case_law row, filling ONLY empty fields. """Merge suggested metadata into the case_law row, filling ONLY empty fields.
@@ -274,6 +275,13 @@ async def apply_to_record(
overwrite_case_number: when True, update case_number from case_number_clean overwrite_case_number: when True, update case_number from case_number_clean
even if the field already has a value (used for one-time migration enrichment). even if the field already has a value (used for one-time migration enrichment).
force_citation: when True, (re)assemble citation_formatted even if the field
is non-empty — used by the at-upload inline enrichment to UPGRADE the seeded
provisional citation (the raw chair input) to the canonical derived form. The
write still happens only when the deterministic assembly SUCCEEDS (a missing
component → no write → the seed is preserved). The drainer keeps the default
(False) so a chair's manual edit in /precedents/[id] is never clobbered.
""" """
if isinstance(case_law_id, str): if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id) case_law_id = UUID(case_law_id)
@@ -482,7 +490,7 @@ async def apply_to_record(
# source_type/district/proceeding_type/parties). Only fill when empty so chair # source_type/district/proceeding_type/parties). Only fill when empty so chair
# edits in /precedents/[id] are preserved; abstains (no write) when a component # edits in /precedents/[id] are preserved; abstains (no write) when a component
# is missing. # is missing.
if not (record.get("citation_formatted") or "").strip(): if force_citation or not (record.get("citation_formatted") or "").strip():
eff = {**record, **fields_to_update} eff = {**record, **fields_to_update}
eff_parties = ( eff_parties = (
fields_to_update.get("parties") or record.get("parties") or "" fields_to_update.get("parties") or record.get("parties") or ""
@@ -505,6 +513,7 @@ async def apply_to_record(
async def extract_and_apply( async def extract_and_apply(
case_law_id: UUID | str, case_law_id: UUID | str,
overwrite_case_number: bool = False, overwrite_case_number: bool = False,
force_citation: bool = False,
) -> dict: ) -> dict:
"""Convenience wrapper: extract → merge into row → return summary.""" """Convenience wrapper: extract → merge into row → return summary."""
suggested = await extract_metadata(case_law_id) suggested = await extract_metadata(case_law_id)
@@ -523,7 +532,11 @@ async def extract_and_apply(
"status": "extraction_failed" if has_text else "no_metadata", "status": "extraction_failed" if has_text else "no_metadata",
"fields": [], "fields": [],
} }
result = await apply_to_record(case_law_id, suggested, overwrite_case_number=overwrite_case_number) result = await apply_to_record(
case_law_id, suggested,
overwrite_case_number=overwrite_case_number,
force_citation=force_citation,
)
if result["updated"]: if result["updated"]:
await db.recompute_searchable(case_law_id) await db.recompute_searchable(case_law_id)
return { return {

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

@@ -236,7 +236,11 @@ export default function PrecedentDetailPage({
{/* side rail — citations + corroboration */} {/* side rail — citations + corroboration */}
<div className="space-y-4"> <div className="space-y-4">
<RelatedCasesSection caseId={id} related={data.related_cases ?? []} /> <RelatedCasesSection
caseId={id}
related={data.related_cases ?? []}
incoming={data.incoming_citations ?? []}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -48,7 +48,14 @@ export type GraphControls = {
}; };
const ALL = "__all__"; const ALL = "__all__";
const YEARS = Array.from({ length: 2026 - 1994 + 1 }, (_, i) => 2026 - i); // Floor covers the oldest dated precedent in the corpus (currently ע"א 725/81 →
// 1982); ceiling tracks the current year so the range never ages out.
const YEAR_FLOOR = 1980;
const CURRENT_YEAR = new Date().getFullYear();
const YEARS = Array.from(
{ length: CURRENT_YEAR - YEAR_FLOOR + 1 },
(_, i) => CURRENT_YEAR - i,
);
const COLOR_BY: { value: ColorBy; label: string }[] = [ const COLOR_BY: { value: ColorBy; label: string }[] = [
{ value: "type", label: "סוג נקודה" }, { value: "type", label: "סוג נקודה" },

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

@@ -18,6 +18,7 @@ import {
useLinkRelatedCase, useLinkRelatedCase,
useUnlinkRelatedCase, useUnlinkRelatedCase,
RelatedCase, RelatedCase,
IncomingCitation,
} from "@/lib/api/precedent-library"; } from "@/lib/api/precedent-library";
const LEVEL_LABELS: Record<string, string> = { const LEVEL_LABELS: Record<string, string> = {
@@ -133,19 +134,28 @@ function LinkDialog({ caseId, currentRelated, open, onOpenChange }: DialogProps)
type SectionProps = { type SectionProps = {
caseId: string; caseId: string;
related: RelatedCase[]; related: RelatedCase[];
/** Decisions that cite THIS ruling — auto-detected from the citation graph
* (read-only). Optional so existing call-sites stay valid. */
incoming?: IncomingCitation[];
}; };
/* Rail-styled citations card (mockup 08 side rail). Renders linked related /* Rail-styled citations card (mockup 08 side rail). Renders linked related
* decisions as a navy-headed card with arrow-prefixed rows; keeps the full * decisions as a navy-headed card with arrow-prefixed rows; keeps the full
* link/unlink logic. Used in the precedent-detail side rail. */ * link/unlink logic. Auto-detected incoming citations (decisions that cite this
export function RelatedCasesSection({ caseId, related }: SectionProps) { * ruling) render in the SAME row style, read-only (no unlink). Used in the
* precedent-detail side rail. */
export function RelatedCasesSection({ caseId, related, incoming = [] }: SectionProps) {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
// De-dupe: a decision already linked manually shouldn't appear twice.
const manualIds = new Set(related.map((r) => r.id));
const autoCites = incoming.filter((c) => !manualIds.has(c.id));
const total = related.length + autoCites.length;
return ( return (
<div className="rounded-lg border border-rule bg-surface shadow-sm px-4 py-3.5 space-y-2.5"> <div className="rounded-lg border border-rule bg-surface shadow-sm px-4 py-3.5 space-y-2.5">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h3 className="text-navy text-[0.92rem] font-semibold m-0"> <h3 className="text-navy text-[0.92rem] font-semibold m-0">
ציטוטים מקושרים{related.length > 0 ? ` (${related.length})` : ""} ציטוטים מקושרים{total > 0 ? ` (${total})` : ""}
</h3> </h3>
<Button <Button
variant="outline" variant="outline"
@@ -157,10 +167,46 @@ export function RelatedCasesSection({ caseId, related }: SectionProps) {
</Button> </Button>
</div> </div>
{related.length === 0 ? ( {total === 0 ? (
<p className="text-ink-muted text-[0.82rem] m-0">אין החלטות קשורות עדיין</p> <p className="text-ink-muted text-[0.82rem] m-0">אין החלטות קשורות עדיין</p>
) : ( ) : (
<ul className="list-none p-0 m-0"> <ul className="list-none p-0 m-0">
{autoCites.map((c) => (
<li
key={c.id}
className="flex items-start gap-2 py-2 border-b border-rule-soft last:border-b-0"
>
<span className="text-gold font-bold leading-6 shrink-0" aria-hidden></span>
<a
href={`/precedents/${c.id}`}
className="min-w-0 flex-1 group hover:opacity-90 transition-opacity"
>
<div className="text-[0.82rem] text-ink-soft leading-5 group-hover:text-navy">
{c.case_name || c.case_number}
</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{c.precedent_level && (
<Badge
variant="outline"
className={`text-[0.6rem] ${LEVEL_COLORS[c.precedent_level] ?? ""}`}
>
{LEVEL_LABELS[c.precedent_level] ?? c.precedent_level}
</Badge>
)}
{(c.chair_name || c.court) && (
<span className="text-[0.68rem] text-ink-muted truncate">
{c.chair_name || c.court}
</span>
)}
{c.date && (
<span className="text-[0.68rem] text-ink-muted tabular-nums" dir="ltr">
{c.date.slice(0, 10)}
</span>
)}
</div>
</a>
</li>
))}
{related.map((r) => ( {related.map((r) => (
<li <li
key={r.id} key={r.id}

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

@@ -143,10 +143,24 @@ export type RelatedCase = {
relation_type: string; relation_type: string;
}; };
/** A decision that cites THIS ruling — auto-detected from the citation graph
* (precedent_internal_citations incoming edges). Read-only (no unlink). */
export type IncomingCitation = {
id: string;
case_number: string;
case_name: string;
court: string;
precedent_level: string;
chair_name: string;
date: string | null;
confidence: number | null;
};
export type PrecedentDetail = Precedent & { export type PrecedentDetail = Precedent & {
full_text: string; full_text: string;
halachot: Halacha[]; halachot: Halacha[];
related_cases: RelatedCase[]; related_cases: RelatedCase[];
incoming_citations: IncomingCitation[];
}; };
export type SearchHit = export type SearchHit =

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.