Compare commits

...

51 Commits

Author SHA1 Message Date
331b8c4249 Merge pull request 'feat(agents): reset button + fix comment routing fallback' (#331) from worktree-agent-reset into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 47s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-24 14:54:25 +00:00
82844a63c2 feat(agents): reset button + endpoint to clear stuck agent error state
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Adds a one-click 'reset agents' action for cases where writer/QA agents
are stuck in Paperclip's error state (triggered by recovery loop or
failed run). Addresses the root cause documented in
reference_paperclip_recovery_loops: reassigns open issues from agents
back to the chair user, and calls reset_agent_session for each error
agent to clear wedged runtime sessions.

Changes:
- paperclip_client.py: reset_case_agents() — reassigns stuck issues +
  clears error status in Paperclip DB + calls reset_agent_session
- agent_platform_port.py: exports pc_reset_case_agents (G12 gate)
- app.py: POST /api/cases/{case_number}/agents/reset endpoint
- agents.ts: useResetCaseAgents mutation hook + AgentResetResult type
- agent-status-widget.tsx: 'אפס' button (shown only when error agents
  exist) with Dialog confirmation + loading state + toast feedback

Invariants: G12 (Paperclip only via port), G2 (no parallel path —
uses existing reset_agent_session + pc_get_case_issues).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 14:53:50 +00:00
a9d04c1e9f Merge pull request 'fix(arguments): validate claim_ids + per-row savepoint in argument_aggregator (#156)' (#330) from worktree-fix-argument-aggregator-savepoint into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m12s
G12 Leak-Guard / leak-guard (push) Successful in 3s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-24 12:59:33 +00:00
c9d83431e0 fix(arguments): validate claim_ids + per-row savepoint in argument_aggregator
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
aggregate_claims_to_arguments crashed with "current transaction is aborted,
commands ignored until end of transaction block" on large cases (confirmed on
1027-04-26, 195 claims; reported via CMP-186).

Root cause: the proposition INSERT (legal_argument_propositions) was wrapped in
a broad except Exception with no savepoint. When the LLM echoes a
syntactically-valid-but-nonexistent claim_id, the FK violation
(legal_argument_propositions_claim_id_fkey) puts the asyncpg transaction into
aborted state. The except caught only that INSERT's error but never issued
ROLLBACK TO SAVEPOINT, so the next statement (the following argument's INSERT
into legal_arguments) raised InFailedSQLTransactionError uncaught and crashed
the whole call. With many claims the bad-UUID probability is high -> consistent
failure.

Fix:
- Validate each claim_id against the known set of claim ids fetched for the
  case before INSERT, so a hallucinated id never reaches the DB (G1: fix at
  source). Malformed UUIDs are already dropped in _normalize_argument; this
  catches the valid-but-nonexistent ones.
- Wrap the INSERT in a per-row savepoint (async with conn.transaction()) as
  defense-in-depth, so any unexpected constraint failure rolls back to the
  savepoint instead of poisoning the surrounding transaction.

Invariants: G1 (normalize at source, not symptom-catch on read). No parallel
path (G2). No silent swallow — skips are logged.

TaskMaster: legal-ai #156

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:58:03 +00:00
b57cd17408 Merge pull request 'fix(case-page): resolve full-page review findings (bugs + RTL + resilience)' (#329) from worktree-case-page-fixes 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 11s
2026-06-20 18:58:21 +00:00
2b591f5018 fix(case-page): resolve full-page review findings (bugs + RTL + resilience)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Full code review of the case-detail page (14 components) surfaced these,
all fixed here:

Logic bugs
- case-edit-dialog: form.reset ran on the 5s useCase refetch while the dialog
  was open, clobbering in-progress edits. Now resets only on open→true.
- status-changer: `selected` never synced to async/external `currentStatus`
  (stale dropdown). Reworked to track currentStatus until an explicit pick;
  resets to tracking after save.
- decision-blocks-panel: `block.content`/`word_count` accessed without null
  guards (endpoint has no response model) → potential render crash. Coerced
  with `?? ""` / `?? 0`. `STATUS_LABELS[status]` now falls back to the raw
  status instead of rendering literal "undefined".
- document-type-editor: `await mutateAsync()` in async click handlers without
  try/catch → unhandled promise rejection. Wrapped (errors still surface via
  isError).

Resilience / hygiene
- page.tsx: a transient 5xx on the background poll flipped the WHOLE page to
  the error card and discarded loaded data. Now gated on `!data`, plus a
  "נסה שוב" retry.
- cases.ts useUpdateCase: invalidated casesKeys.all, which re-invalidated the
  detail it had just optimistically patched. Scoped to the list prefix.

RTL correctness (logical properties)
- agent-status-widget `mr-auto`→`ms-auto`; agent-activity-feed `mr-auto`→
  `me-auto`, icon `ml-1/ml-2`→`me-1/me-2`, required `*` `mr-1`→`ms-1`;
  document-type-editor list `pr-4`→`ps-4`.

Minors
- drafts-panel: `<a href>`→`next/link` (operations + citation links) for SPA
  nav. agent-activity-feed: issueMap memoized; comment Textarea aria-label.
  upload-sheet: `unknown` status no longer shown as green success (neutral
  icon + "רענן לאישור"). citations.ts: case_name typed `string | null`.

Design gate: visual-touching items (RTL gap side, retry button, neutral
upload icon) were chair-authorized via the reviewed-findings approval ("fix
all"); none alter an approved page layout — they are correctness fixes.
tsc clean; eslint clean (1 pre-existing form.watch warning, untouched line).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:57:43 +00:00
148b4b9bf6 Merge pull request 'feat(arguments): inline status banners + fix double-card nesting' (#328) from worktree-arguments-banners 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 11s
2026-06-20 18:36:21 +00:00
31029b2d43 feat(arguments): inline status banners + fix double-card nesting
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
Implements the Claude-Design-approved inline status banners for the
"חשב טיעונים" panel (mockup 25-legal-arguments-panel): the four endpoint
states (queued / exists / no_claims / skipped) now render as tone-coded
banners below the header instead of a transient toast. Toast is kept only
for hard transport errors.

Also fixes a pre-existing double-card bug found while reviewing the page:
the page's "arguments" tab already wraps the panel in <Card><CardContent>
(page.tsx:151-155), yet LegalArgumentsPanel rendered its OWN <Card> too —
unlike its sibling tab panels (DecisionBlocksPanel/DraftsPanel/
AgentActivityFeed) which render a plain <div>. The panel now matches that
convention (single card, no double border/padding), consistent with the
approved single-card mockup.

Design gate: card 25 approved by חיים. tsc + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:35:55 +00:00
c9970a5955 Merge pull request 'fix(operations): contain agent run-log text inside the "פלט" popup' (#327) from worktree-fix-runlog-overflow 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 11s
2026-06-20 18:32:17 +00:00
38234d9b4f fix(operations): contain agent run-log text inside the "פלט" popup
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 RunLogDialog rendered the live agent log inside a Radix ScrollArea.
Radix wraps Viewport children in an inner `display:table` div that
shrink-wraps to the widest unbreakable token — the long `toolu_…` IDs and
`/home/chaim/…` file paths in the raw JSON log. That table cell expands
instead of constraining width, so `whitespace-pre-wrap break-words` never
had a width to wrap against and the text spilled out past the dialog
borders.

Replace the ScrollArea with a plain width-constrained scrollable div and
switch break-words → break-all so the long unbreakable IDs/paths wrap too.
Pure presentational fix (no invariant surface).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:31:17 +00:00
a0b158b2c8 Merge pull request 'fix(arguments): route "חשב טיעונים" through the legal-analyst agent' (#326) from worktree-fix-aggregate-btn into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 41s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-20 18:20:48 +00:00
a3df05e067 fix(arguments): route "חשב טיעונים" through the legal-analyst agent
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 /aggregate-arguments endpoint ran an in-container BackgroundTask that
called claude_session (the local `claude` CLI) — which does not exist in the
FastAPI container. The button silently produced nothing, and on `force` it
destructively DELETEd existing arguments *before* the doomed LLM call.

Replace the inline task with the established delegation pattern used by
"חלץ עובדות שמאיות" (extract-appraiser-facts): a cheap in-container DB
pre-check (no_claims / exists), then a Paperclip wakeup of the company's
legal-analyst, which runs mcp__legal-ai__aggregate_claims_to_arguments
locally (where the CLI lives) and reports back. `force` now runs locally too,
so delete+recompute are atomic on the host — no more destructive failure.

Frontend: AggregateArgumentsResult becomes a discriminated union
(queued | no_claims | exists | skipped) and the toast is status-accurate
instead of the misleading fixed "refresh in a minute".

Invariants: G12 (Paperclip touch confined to paperclip_client behind the
agent_platform_port), G2 (replaces the broken path, no parallel capability),
engineering §6 (explicit statuses, no silent swallow).

UI change is logic/toast only (no visual-layout change) — within the
Claude-Design-gate bug-fix exemption. Richer inline status panels deferred
to the gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:19:57 +00:00
a3e612f22b Merge pull request 'feat(citation-verify): frontend "אימות פסיקה" tab (#154)' (#325) from worktree-citation-verify-frontend 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 18:19:28 +00:00
5108c854cf feat(citation-verify): frontend "אימות פסיקה" tab in the decision editor (#154)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
Frontend half of the citation-verification panel (backend in #323), as the agreed
third tab in /compose — matching the approved X17 mockup 24-citation-verification:

- lib/api/citation-verification.ts: hand-written types + useCitationVerification
  query + useVerifyCitation mutation (POST verify, invalidates the view).
- components/compose/citation-verification-panel.tsx: per legal argument →
  supporting corpus precedents with the cited_by authority chips (אומץ ×N /
  אובחן ×N), the exact supporting_quote, ✓ מאמת / ✗ לא רלוונטי verify actions, a
  per-citation chair-note field, and the per-issue 📡 radar (unlinked digests,
  pointer-only). Summary strip + INV-AH/INV-DIG1 reminders.
- compose page: third tab "אימות פסיקה" alongside עורך הבלוקים / עמדות וטענות.

No api:types regen needed (endpoints return assembled dicts; types hand-written
per the lib/api convention). tsc + eslint clean.

Invariants: INV-AH (writer cites only verified), INV-DIG1 (digest never cited),
G2 (consumes the one backend view). Visual matches the gate-approved mockup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:19:03 +00:00
d7855f6284 Merge pull request 'fix(ui): case header — metadata parallel to title, parties clamped to 2 lines' (#324) from worktree-case-header-layout 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 11s
2026-06-20 18:11:55 +00:00
80809ca406 fix(ui): case header — keep metadata parallel to title, clamp parties to 2 lines
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 hearing-date/updated/sync metadata dl dropped below the title (taking rows and
pushing the band down) when the appellants/respondents line was long, because the
outer row used flex-wrap. Fix:

- Drop flex-wrap on the title↔metadata row (and flex-1 on the title block) so the
  metadata dl stays parallel to the H1; the title block shrinks instead.
- Clamp the parties line (and the subject fallback) to 2 lines with line-clamp-2 +
  title tooltip, so a long party list no longer grows the band.

Chair-directed layout fix; existing components, no new design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:11:32 +00:00
abe4c53df1 Merge pull request 'feat(citation-verify): backend for "אימות פסיקה" — per-argument support + verify gate (#154)' (#323) from worktree-citation-verify-backend into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-20 18:08:46 +00:00
5ede8a9653 feat(citation-verify): backend for the "אימות פסיקה" panel — per-argument support + verify gate (#154)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 12s
Backend half of the citation-verification tab (frontend follows in a separate PR):

- Schema V44: case_precedents gains argument_id (the legal argument it supports),
  case_law_id (the corpus ruling — for cited_by + dedup), verified (the INV-AH gate
  the writer respects) + verified_at. All nullable; chair_note already existed.
- db: create_case_precedent(+argument_id/case_law_id/verified),
  set_case_precedent_verified(id, verified, chair_note), list_case_precedents(+cols).
- services/case_citation_verification.build_view(case): per legal_argument →
  in-corpus suggestions (search_library per issue) each with the cited_by authority
  breakdown (db.citation_authority, X11) + merged attach/verify state + per-issue
  radar (case_digest_radar grouped by matched issue). Pure read/assembly; reuses the
  one corpus search + one authority query + one radar (G2).
- endpoints: GET /api/cases/{n}/citation-verification (the view) and
  POST .../verify (upsert verify/un-verify + chair note).

Validated on 1044-03-26: 14 arguments, all with support; 317/10 surfaces with
אומץ×11/אובחן×1; radar leads attributed per issue. Auto-approval untouched (chair
gate, INV-G10). Verify defaults to false — nothing is authoritative until the chair
marks it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:08:21 +00:00
33c10e4147 Merge pull request 'fix(ui): nav & layout — decision-editor in band, compose back-link, wider cases table' (#322) from worktree-case-nav-fixes into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 53s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-20 17:56:44 +00:00
81050181d7 fix(ui): nav & layout — decision-editor in band, compose back-link, wider cases table
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
Chair-directed navigation/layout fixes (existing components, no new design):

1. "פתח עורך החלטה" moved into the case-page band actions (right after "העלאת
   מסמכים") so it's reachable from EVERY tab, not only סקירה. Removed the now-
   redundant full-width CTA from the overview tab.
2. Prominent "→ חזרה לדף התיק" back-link added to the /compose header (the
   breadcrumb link was too subtle).
3. Home cases table: rail trimmed 360→280px and the title cell made min-w-0 so the
   table gets the width it needs and no longer shows a horizontal scrollbar.

No backend / API change. The larger NEW citation-verification panel stays gated
behind Claude Design (preview 24-citation-verification already pushed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:56:21 +00:00
00c8083cc9 Merge pull request 'feat(digests): calibrate case radar to distilled issues + per-issue attribution' (#321) from worktree-digest-radar-calibrate into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-20 17:07:58 +00:00
21ff52aff9 feat(digests): calibrate case radar to the analyst's distilled issues + per-issue attribution
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 12s
The radar's query was built from dozens of raw claims (procedural-heavy noise), so
matches were thematic but imprecise and gave no reason WHY a lead is relevant. Now:

- Prefer the analyst's distilled legal_arguments (argument_title + legal_topic — one
  crisp CREAC issue per row) over raw claims.
- Search EACH issue separately and MERGE, so every lead is attributed to the case
  issue(s) it answers (`matched_issues`) — the chair sees "this ruling is for your
  'זכות עמידה' issue", not just a blended score.
- Fall back to the raw-claims blended query pre-aggregation; `source` reports the path.
- Shared `_radar_enrich` helper (gap status + action + matched_issues), bounded to 25
  issues to cap the per-issue fan-out.

Validated: 8124-09-24 (32 args → per-issue) surfaces betterment rulings each tagged to
its issue (היעדר השבחה / זהות הנישום / סעיף 7(ב)); 1044-03-26 (0 args) falls back to
claims unchanged. No tool/endpoint signature change (new fields pass through the dict).

Invariants: G2 (reuses the one digest search + arg accessor), INV-DIG1 (radar only).
No schema change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:07:15 +00:00
cc8fd0b853 Merge pull request 'feat(digests): case-contextual digest radar — surface unlinked digests as chair leads (X12)' (#320) from worktree-digest-radar into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m53s
G12 Leak-Guard / leak-guard (push) Successful in 7s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-20 16:45:48 +00:00
70f93c3bd4 feat(digests): case-contextual digest radar — surface unlinked digests as chair leads (X12)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
Lint — undefined names / undefined-names (pull_request) Successful in 14s
A digest pointing at a ruling we don't hold yet ("unlinked") was captured globally
(missing_precedents inbox) but never surfaced IN THE CONTEXT of the case being
decided — so a relevant ruling known only via a digest could fall through the cracks
at the moment it matters. Adds the case-contextual radar:

- db.search_digests_semantic: new `linked_only` filter (False = unlinked-only target set).
- digest_library.case_digest_radar(case_number): builds the case topic from title +
  appeal_subtype + the analyst's claims, embeds once, matches against UNLINKED digests,
  and returns leads enriched with the underlying ruling's gap status + suggested action
  (new_lead / gap_open / fetched / available_link).
- MCP tool `digest_radar` + endpoint GET /api/cases/{n}/digest-radar (for the agent and
  the future case-page lead).

INV-DIG1 preserved: radar only — every lead points at the underlying RULING (fetch /
upload / link), never cites the digest. Read-only.

Validated on 8124-09-24 (היטל השבחה): 5 on-topic unlinked digests, scores 0.64–0.72.
The visible case-page panel is a separate UI change → goes through the design gate.

Invariants: G2 (reuses the one digest semantic-search + gap/citation resolvers),
INV-DIG1 (no digest citation), INV-DIG3 (gap surfacing). No schema change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:45:14 +00:00
4f45fa416b Merge pull request 'feat(x11): treatment-aware citation authority wired into research agents (#154)' (#319) from worktree-x11-treatment-wiring 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 16:04:36 +00:00
ccc5a73bc8 feat(x11): treatment-aware citation authority wired into research agents (#154)
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 internal citation graph fed only RANKING (raw in-degree), and the per-citation
TREATMENT was never classified — so a precedent distinguished N times got the same
authority boost as one followed N times (INV-COR2 violation), and the signal never
reached the agents' reasoning. Wires the full path:

Phase 1 — scripts/classify_citation_treatments.py: classify each linked edge's
  treatment (followed/distinguished/…) from its match_context via
  corroboration.classify_treatment (Opus 4.8 @ xhigh, local), filling
  precedent_internal_citations.treatment. Idempotent.
Phase 2 — db.refresh_verified_layer: count only NON-negative treatments toward
  verified/cite_count (INV-COR2/COR4). Unclassified counts as neutral-positive so
  the signal degrades gracefully before classification runs.
Phase 3 — db.citation_authority(ids): per-precedent {total, positive, negative,
  unclassified, by_treatment}. Surfaced as `cited_by` in search_precedent_library
  hits and precedent_library_get, and `treatment` per incoming citation.
Phase 4 — legal-researcher/analyst/writer prompts: weigh & ARGUE authority
  ("הלכה שאומצה ב-N החלטות ועדת-ערר"), flag distinguished/overruled, never invent
  the count (INV-AH; writer is read-only of the analyst).

Auto-approval stays kill-switched off (chair gate preserved, INV-G10). No schema
change (treatment column already existed). Operational: run the classifier +
refresh_verified_layer over the 379 edges, then sync agents across companies.

Invariants: G2 (one classifier + one authority query, reused), INV-COR2/COR3/COR4
(negative never corroborates; point-specific; ≥N), INV-G10 (no auto-approval),
INV-AH (no invented numbers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:04:04 +00:00
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
49 changed files with 2364 additions and 423 deletions

View File

@@ -166,7 +166,7 @@ tools:
10. **הכנה ל-CREAC** — לכל סוגיה רשום:
- כלל (Rule): הכלל המשפטי/תכנוני שיעמוד בבסיס הדיון
- עובדות מפתח (Facts): העובדות שיופיעו בשלב היישום
- תקדים מבהיר (אם נדרש): רק אם הכלל דורש הבהרה
- תקדים מבהיר (אם נדרש): רק אם הכלל דורש הבהרה. **שקלל סמכות (X11):** כל תוצאת `search_precedent_library` נושאת `cited_by`/`verified`/`cite_count` — כמה ועדות-ערר אימצו את התקדים. העדף תקדים עם `cited_by.positive` גבוה, וסַמֵּן אותו ל-writer במפורש: *"אומץ ב-N החלטות ועדת-ערר"* (כדי שיבסס סמכות). תקדים עם `cited_by.negative>0` (אובחן/בוטל) — סמן זהירות, אל תציגו כ-good-law.
11. **שאלות משפטיות** — 1-3 שאלות לפי הצורך (ראה שלב 4)
12. **עמדת ועדת הערר** — שדה ריק שיו"ר הוועדה ימלא ידנית. **חובה להוסיף לכל סוגיה!** עמדה זו תשמש כהנחיה מחייבת לסוכן הכתיבה.

View File

@@ -304,6 +304,15 @@ search_internal_decisions(
**מינימום:** queries לקורפוס הסמכותי = מספר סוגיות מרכזיות שזוהו.
#### 2ב.4ב — שקלול סמכות לפי טיפול-שיפוטי-מצטבר (X11) ⚠️
כל תוצאה מ-`search_precedent_library` נושאת שדה **`cited_by`** + `verified`/`cite_count`**כמה החלטות ועדת-ערר אחרות הסתמכו על אותו תקדים ואיך טיפלו בו**. זהו אות-סמכות אנושי-מצטבר (לא ניחוש-מודל), וחובה לשקלל אותו:
- **`cited_by.positive` גבוה (אומץ/הוסבר ע"י הרבה ועדות)** → תקדים-עוגן. **טַען את הסמכות במפורש** בפלט-המחקר: *"הלכת [שם] (עע"מ X/YY) — אומצה ב-N החלטות ועדת-ערר"*, כדי שה-writer יוכל לבסס עליה. בין שני תקדימים שקולים-תוכן — העדף את בעל ה-`positive` הגבוה.
- **`cited_by.negative` > 0 (אובחן/בוקר/בוטל)** → **דגל זהירות**. אל תציג תקדים שבוטל (`overruled`) כ"good law"; ציין את האבחנה. תקדים שאובחן שוב-ושוב אינו ראיה חזקה לסוגיה — בדוק את ההקשר.
- **`unclassified`** → הטיפול טרם סווג; התייחס ל-`cite_count` הגולמי בלבד.
- האות הזה **מחדד תיעדוף, לא מחליף קריאה** — עדיין קרא את ההלכה ואת ההקשר (INV-COR3/COR5: סמכות לסוגיה הספציפית, לא לפסק כולו).
#### 2ב.4א — איתור החלטה ספציפית לפי שם — פרוטוקול לפני "לא בקורפוס" ⚠️
שם תיק לבדו (למשל `"אגסי"`) **אינו מפתח חיפוש אמין**. ההטמעה הסמנטית והאינדקס הלקסיקלי בנויים על תוכן ההלכה/הפסקה — כך ששאילתת-שם עלולה להחזיר דווקא החלטות ש**מצטטות** את התיק, ולא את התיק עצמו. לפני שמכריזים שהחלטה אינה בקורפוס:

View File

@@ -274,7 +274,7 @@ case_update(case_number, status="drafted")
### שלב ג: לכל סוגיה — מבנה סילוגיסטי (CREAC) בקול דפנה
1. **מסקנה** — פתח בתשובה (בקול "אנחנו" — ראה טבלה למטה)
2. **כלל** — ציטוט סעיף החוק במלואו (לא תמצית). אם רלוונטי — סעיפי משנה כולם.
3. **הרחבה** — תקדים רלוונטי אחד **בציטוט מלא** (לא תמצית). דפנה תמיד מצטטת בני 4-15 שורות עם הפניה `(פורסם בנבו)`.
3. **הרחבה** — תקדים רלוונטי אחד **בציטוט מלא** (לא תמצית). דפנה תמיד מצטטת בני 4-15 שורות עם הפניה `(פורסם בנבו)`. **כשהמנתח סימן תקדים כ"אומץ ב-N החלטות ועדת-ערר" (אות-סמכות X11)** — חזק את ההסתמכות בלשון-סמכות ("הלכה מושרשת שאומצה בשורת החלטות"); זהו טיפול-שיפוטי-מצטבר אמיתי, לא ניחוש. **אל** תמציא מספר בעצמך — השתמש רק במה שהמנתח מסר (INV-AH, read-only מהמנתח).
4. **יישום** — החל את הכלל על העובדות. הפרד ממצא עובדתי ממסקנה משפטית. השתמש בנתונים (מספרים, מידות, אחוזים).
5. **אישור-לפני-דחייה (חובה)** — הצג את הטענה הטובה ביותר של הצד המפסיד: **"אכן [נקודה תקפה]... אולם [למה לא מכריע]"**. השימוש ב-"אכן" (לא "אמנם") הוא הסטנדרט.
6. **למעלה מן הצורך** (חובה לטענות מרכזיות) — "גם אם היינו מקבלים את פרשנות העורר... התוצאה הייתה זהה". סוגר חלון לערעור.

View File

@@ -35,14 +35,14 @@
| `case_number` (citation) | CHAIR (חובה) | מפתח idempotency |
| `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 ממלא רק אם ריק |
| `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 | — |
| `embedding` (chunks) | Voyage (לא-LLM-reasoning) | ⚠ לא-GENERATED ([gap-audit GAP-09](gap-audit.md)) |
| כל `halachot` | OPUS | נכנס pending_review |
### 2ב. החלטה פנימית (`case_law`, source_kind=`internal_committee`)
כמו 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.
### 2ג. מסמך-תיק (`documents`)

View File

@@ -418,6 +418,12 @@ async def digest_process_pending(limit: int = 20) -> str:
return await digest_tools.digest_process_pending(_clamp_limit(limit))
@mcp.tool()
async def digest_radar(case_number: str, limit: int = 5, min_score: float = 0.45) -> str:
"""רדאר-יומונים הקשרי-לתיק (X12) — מחזיר יומונים לא-מקושרים (פס"ד שעוד אין לנו) שהנושא שלהם קרוב סמנטית לתיק, כדי שלא ייפול פס"ד רלוונטי בזמן ההכרעה. כל ליד מצביע על מראה-המקום של הפס"ד המקורי + סטטוס-הפער + פעולה מוצעת. ⚠️ radar בלבד — היומון אינו מצוטט (INV-DIG1)."""
return await digest_tools.digest_radar(case_number, _clamp_limit(limit), min_score)
@mcp.tool()
async def halacha_review(
halacha_id: str,

View File

@@ -230,6 +230,15 @@ async def aggregate_claims_to_arguments(
party = "unknown"
by_party.setdefault(party, []).append(dict(r))
# Valid claim_ids for this case == the ids of the claims we just fetched.
# The LLM is asked to echo back supporting claim_ids, but it may hallucinate
# a syntactically-valid-but-nonexistent UUID (malformed ones are already
# dropped in ``_normalize_argument``). Validating against this known set at
# source keeps a doomed INSERT — which would poison the surrounding asyncpg
# transaction (FK violation -> "current transaction is aborted") — out of
# the transaction entirely (G1: fix at source, not symptom).
valid_claim_ids: set[UUID] = {r["id"] for r in rows}
party_counts: dict[str, int] = {}
inserted = 0
errors: list[str] = []
@@ -275,17 +284,30 @@ async def aggregate_claims_to_arguments(
arg["priority"],
)
for cid in arg["claim_ids"]:
try:
await conn.execute(
"""INSERT INTO legal_argument_propositions
(argument_id, claim_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING""",
arg_id, cid,
if cid not in valid_claim_ids:
# Hallucinated claim_id that doesn't belong to this
# case. Skip it rather than letting the FK violation
# abort the whole transaction.
logger.warning(
"argument_aggregator: skipped unknown claim_id %s for arg %s",
cid, arg_id,
)
continue
try:
# Per-row savepoint: even after the validation above,
# wrap the INSERT so any unexpected constraint failure
# rolls back to the savepoint instead of poisoning the
# surrounding transaction (asyncpg nests transaction()
# as SAVEPOINT when already inside one).
async with conn.transaction():
await conn.execute(
"""INSERT INTO legal_argument_propositions
(argument_id, claim_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING""",
arg_id, cid,
)
except Exception as e: # noqa: BLE001
# Likely FK violation if the LLM hallucinated
# a claim_id. Log and continue.
logger.warning(
"argument_aggregator: skipped bad claim_id %s for arg %s: %s",
cid, arg_id, e,

View File

@@ -0,0 +1,134 @@
"""Citation-verification view (X11 Phase 2 / #154) — the chair's "אימות פסיקה" tab.
Assembles, per legal ARGUMENT of a case, the supporting precedents the chair should
verify before the writer cites them:
• in-corpus suggestions — per-issue semantic retrieval over the authoritative
precedent library (``search_library``), each carrying the cumulative authority
signal (``cited_by``: followed/distinguished — db.citation_authority, X11).
• attached/verified state — any ``case_precedents`` row already attached to the
argument (verified flag + chair_note), merged onto the matching suggestion.
• radar — UNLINKED digests relevant to the same issue (rulings we don't hold yet),
from ``case_digest_radar`` grouped by matched issue.
Pure read/assembly — never writes, never cites (INV-DIG1/INV-AH). The chair verifies
through ``db.set_case_precedent_verified`` / attach; the writer consumes only verified
rows. Reuses the one corpus search + the one authority query + the one radar (G2).
"""
from __future__ import annotations
import logging
from uuid import UUID
from legal_mcp.services import (
argument_aggregator,
db,
digest_library,
precedent_library,
)
logger = logging.getLogger(__name__)
_SUGGEST_PER_ISSUE = 4
_SUGGEST_FLOOR = 0.45
async def build_view(case_number: str) -> dict:
case = await db.get_case_by_number(case_number)
if not case:
return {"status": "case_not_found", "case_number": case_number, "arguments": []}
case_id = case["id"]
if isinstance(case_id, str):
case_id = UUID(case_id)
ctx = " ".join(x for x in [case.get("title") or "", case.get("appeal_subtype") or ""] if x).strip()
args = await argument_aggregator.get_legal_arguments(case_id)
# Attached precedents already on the case → grouped by argument_id, keyed by
# the resolved corpus ruling so we can merge verify-state onto a suggestion.
attached = await db.list_case_precedents(case_id)
attached_by_arg: dict[str, dict[str, dict]] = {}
for p in attached:
aid = str(p.get("argument_id") or "")
clid = str(p.get("case_law_id") or "")
if aid and clid:
attached_by_arg.setdefault(aid, {})[clid] = p
# Radar (unlinked digests) once, grouped by the issue label it matched.
radar_by_issue: dict[str, list[dict]] = {}
try:
radar = await digest_library.case_digest_radar(case_number, limit=12, min_score=0.42)
for lead in radar.get("leads", []):
for label in (lead.get("matched_issues") or [""]):
radar_by_issue.setdefault(label, []).append(lead)
except Exception as e: # noqa: BLE001 — radar is best-effort
logger.warning("citation_verification radar failed for %s: %s", case_number, e)
out_args: list[dict] = []
n_verified = 0
for a in args:
aid = str(a["id"])
title = (a.get("argument_title") or "").strip()
topic = (a.get("legal_topic") or "").strip()
query = f"{ctx} {title}. {topic}".strip()
hits = []
try:
hits = await precedent_library.search_library(
query=query, limit=_SUGGEST_PER_ISSUE, include_halachot=True)
except Exception as e: # noqa: BLE001
logger.warning("citation_verification search failed (%s): %s", title[:30], e)
# Resolve the authority breakdown for the hit set in one batched query.
clids = [UUID(str(h["case_law_id"])) for h in hits
if h.get("case_law_id") and float(h.get("score", 0) or 0) >= _SUGGEST_FLOOR]
authority = await db.citation_authority(clids) if clids else {}
seen: set[str] = set()
supporting: list[dict] = []
for h in hits:
clid = str(h.get("case_law_id") or "")
if not clid or clid in seen:
continue
if float(h.get("score", 0) or 0) < _SUGGEST_FLOOR:
continue
seen.add(clid)
att = attached_by_arg.get(aid, {}).get(clid)
if att and att.get("verified"):
n_verified += 1
supporting.append({
"case_law_id": clid,
"case_number": h.get("case_number") or "",
"case_name": h.get("case_name") or "",
"quote": h.get("supporting_quote") or h.get("rule_statement") or "",
"score": round(float(h.get("score", 0) or 0), 3),
"cited_by": authority.get(clid, {"total": 0, "positive": 0,
"negative": 0, "unclassified": 0,
"by_treatment": {}}),
"attached_id": str(att["id"]) if att else None,
"verified": bool(att.get("verified")) if att else False,
"chair_note": (att.get("chair_note") or "") if att else "",
})
out_args.append({
"argument_id": aid,
"title": title,
"legal_topic": topic,
"priority": a.get("priority") or "",
"party": a.get("party") or "",
"supporting": supporting,
"radar": radar_by_issue.get(title, []),
})
return {
"status": "ok",
"case_number": case_number,
"arguments": out_args,
"summary": {
"arguments_total": len(out_args),
"arguments_with_support": sum(1 for x in out_args if x["supporting"]),
"verified": n_verified,
"radar_leads": sum(len(x["radar"]) for x in out_args),
},
}

View File

@@ -236,7 +236,18 @@ async def extract_and_store(case_law_id: UUID) -> dict:
if not row:
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 "")
extracted = 0
@@ -388,12 +399,16 @@ async def list_citations_to_case_law(case_law_id: UUID) -> list[dict]:
pic.cited_case_number,
pic.match_context,
pic.match_pattern,
pic.treatment,
pic.confidence::float AS confidence,
pic.created_at,
cl.case_number AS source_case_number,
cl.case_name AS source_case_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
JOIN case_law cl ON cl.id = pic.source_case_law_id
WHERE pic.cited_case_law_id = $1
@@ -432,3 +447,42 @@ async def get_cited_case_law_ids(source_case_law_ids: list[UUID]) -> dict[str, l
for r in rows:
out.setdefault(r["source_id"], []).append(r["cited_id"])
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,57 @@ 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 '%חיפה%';
"""
SCHEMA_V44_SQL = """
-- Citation-verification panel (#154): the chair verifies each corpus precedent
-- against the specific legal ARGUMENT before the writer may cite it (INV-AH gate).
-- case_precedents gains: the argument it supports, the resolved case_law row
-- (for the cited_by authority signal + dedup), a verified flag (the gate), and a
-- verification timestamp. chair_note already exists. All nullable so legacy
-- section-scoped rows stay valid.
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS argument_id UUID
REFERENCES legal_arguments(id) ON DELETE SET NULL;
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS case_law_id UUID
REFERENCES case_law(id) ON DELETE SET NULL;
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS verified BOOLEAN DEFAULT false;
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS verified_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_case_precedents_argument ON case_precedents(argument_id);
"""
# Stable, arbitrary key for the session-level advisory lock that serialises
# schema DDL across processes. Every short-lived process (cron drains, services)
# re-runs the idempotent migrations on startup; without this lock two processes
@@ -1714,7 +1765,7 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
await _apply_schema_ddl(conn)
finally:
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:
@@ -1760,6 +1811,9 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
await conn.execute(SCHEMA_V39_SQL)
await conn.execute(SCHEMA_V40_SQL)
await conn.execute(SCHEMA_V41_SQL)
await conn.execute(SCHEMA_V42_SQL)
await conn.execute(SCHEMA_V43_SQL)
await conn.execute(SCHEMA_V44_SQL)
async def init_schema() -> None:
@@ -3295,28 +3349,58 @@ async def create_case_precedent(
chair_note: str = "",
pdf_document_id: UUID | None = None,
practice_area: str | None = None,
argument_id: UUID | None = None,
case_law_id: UUID | None = None,
verified: bool = False,
) -> dict:
"""Insert a new precedent attached to a case."""
"""Insert a new precedent attached to a case.
``argument_id``/``case_law_id``/``verified`` (X11 #154) link the attachment to
the specific legal argument it supports and the corpus ruling, and mark whether
the chair verified it (the INV-AH gate the writer respects)."""
pool = await get_pool()
row = await pool.fetchrow(
"""
INSERT INTO case_precedents
(case_id, section_id, quote, citation, chair_note, pdf_document_id, practice_area)
VALUES ($1, $2, $3, $4, $5, $6, $7)
(case_id, section_id, quote, citation, chair_note, pdf_document_id,
practice_area, argument_id, case_law_id, verified, verified_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
CASE WHEN $10 THEN now() ELSE NULL END)
RETURNING *
""",
case_id, section_id, quote, citation, chair_note, pdf_document_id, practice_area,
case_id, section_id, quote, citation, chair_note, pdf_document_id,
practice_area, argument_id, case_law_id, verified,
)
return dict(row)
async def set_case_precedent_verified(
precedent_id: UUID, verified: bool, chair_note: str | None = None,
) -> dict | None:
"""Toggle the chair-verification gate on an attached precedent (#154), optionally
updating the chair note. Sets verified_at when verifying, clears it when un-verifying."""
pool = await get_pool()
sets = ["verified = $2", "verified_at = CASE WHEN $2 THEN now() ELSE NULL END",
"updated_at = now()"]
params: list = [precedent_id, verified]
if chair_note is not None:
sets.append(f"chair_note = ${len(params) + 1}")
params.append(chair_note)
row = await pool.fetchrow(
f"UPDATE case_precedents SET {', '.join(sets)} WHERE id = $1 RETURNING *",
*params,
)
return dict(row) if row else None
async def list_case_precedents(case_id: UUID) -> list[dict]:
"""List all precedents attached to a case, ordered by section then creation time."""
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT id, case_id, section_id, quote, citation, chair_note,
pdf_document_id, practice_area, created_at, updated_at
pdf_document_id, practice_area, argument_id, case_law_id,
verified, verified_at, created_at, updated_at
FROM case_precedents
WHERE case_id = $1
ORDER BY section_id NULLS LAST, created_at
@@ -4253,6 +4337,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(
case_number: str,
case_name: str,
@@ -4270,15 +4382,27 @@ async def create_external_case_law(
precedent_level: str = "",
is_binding: bool = True,
document_id: UUID | None = None,
citation_formatted: str = "",
) -> dict:
"""Insert a chair-uploaded external precedent into case_law.
If a row with this ``case_number`` already exists with
source_kind='cited_only' (auto-discovered), promote it to
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()
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:
# Atomic upsert on the V15 partial unique index
# uq_case_law_external_number (case_number) WHERE source_kind <> 'internal_committee'.
@@ -4295,11 +4419,12 @@ async def create_external_case_law(
summary, key_quote, full_text, source_url,
source_kind, document_id, extraction_status,
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 (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
'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'
DO UPDATE SET
@@ -4321,14 +4446,16 @@ async def create_external_case_law(
source_kind = 'external_upload',
extraction_status = 'processing',
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 *
""",
case_number, case_name, court, decision_date, tags_json,
summary, key_quote, full_text, source_url,
document_id, practice_area, appeal_subtype, headnote,
source_type, precedent_level, is_binding,
_content_hash(full_text),
_content_hash(full_text), (citation_formatted or "").strip(),
)
return _row_to_case_law(row)
@@ -4345,7 +4472,7 @@ async def create_internal_committee_decision(
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
summary: str = "",
is_binding: bool = True,
is_binding: bool = False,
document_id: UUID | None = None,
proceeding_type: str = "ערר",
) -> dict:
@@ -4355,9 +4482,13 @@ async def create_internal_committee_decision(
exist as both 'ערר' and 'בל"מ' (an extension-of-time request can be
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()
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:
# Atomic upsert on V15 partial unique index
# uq_case_law_internal_number_proc (case_number, proceeding_type)
@@ -4498,6 +4629,12 @@ async def update_case_law(case_law_id: UUID, **fields) -> dict | None:
"proceeding_type", "citation_formatted", "parties",
}
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:
return await get_case_law(case_law_id)
@@ -4506,12 +4643,21 @@ async def update_case_law(case_law_id: UUID, **fields) -> dict | None:
params: list = [case_law_id]
for i, (k, v) in enumerate(updates.items(), start=2):
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}")
params.append(v)
sql = f"UPDATE case_law SET {', '.join(set_parts)} WHERE id = $1 RETURNING *"
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:
@@ -4920,14 +5066,24 @@ async def search_digests_semantic(
subject_tag: str = "",
concept_tag: str = "",
limit: int = 10,
linked_only: bool | None = None,
) -> list[dict]:
"""Pure-semantic search over the digests radar (X12). Single vector per row
(no chunks/halachot), so no RRF here — see X12 §6. Joins the linked ruling's
citation when present so the researcher sees the pointer target directly."""
citation when present so the researcher sees the pointer target directly.
``linked_only``: None = all digests (default); False = only UNLINKED digests
(``linked_case_law_id IS NULL`` — rulings we don't hold yet, the case radar's
target set); True = only linked digests.
"""
pool = await get_pool()
conditions = ["d.embedding IS NOT NULL"]
params: list = [query_embedding, limit]
idx = 3
if linked_only is True:
conditions.append("d.linked_case_law_id IS NOT NULL")
elif linked_only is False:
conditions.append("d.linked_case_law_id IS NULL")
if practice_area:
conditions.append(f"d.practice_area = ${idx}")
params.append(practice_area)
@@ -6438,8 +6594,13 @@ async def refresh_verified_layer() -> dict:
"""Recompute the verified/cite_count layer from chair citations (#153).
'verified' = the principle's SOURCE precedent was cited by a chair (any
committee decision). 'cite_count' = # distinct chair decisions citing it. This
is the ONLY trust signal — never human review. Idempotent (full recompute).
committee decision) WITHOUT a negative treatment. 'cite_count' = # distinct
chair decisions citing it whose treatment is NOT negative (X11 §4 / INV-COR2:
a precedent *distinguished*/*criticized*/*questioned*/*overruled* must never
gain authority from those citations). Unclassified edges (treatment='') count
as neutral-positive until ``classify_citation_treatments.py`` labels them, so
the signal degrades gracefully before classification has run. This is the ONLY
trust signal — never human review. Idempotent (full recompute).
Returns {verified_principles, verified_precedents}.
"""
pool = await get_pool()
@@ -6456,6 +6617,8 @@ async def refresh_verified_layer() -> dict:
" JOIN case_law src ON src.id = pic.source_case_law_id "
" WHERE src.source_kind='internal_committee' "
" AND pic.cited_case_law_id IS NOT NULL "
" AND coalesce(pic.treatment,'') NOT IN "
" ('distinguished','criticized','questioned','overruled') "
" GROUP BY pic.cited_case_law_id) "
"UPDATE halachot h SET verified=true, cite_count=cc.n, updated_at=now() "
"FROM cc WHERE h.case_law_id = cc.id")
@@ -6466,6 +6629,55 @@ async def refresh_verified_layer() -> dict:
return {"verified_principles": row["vp"], "verified_precedents": row["vc"]}
# X11 §4 treatment buckets (mirrors corroboration.TREATMENT_POSITIVE/NEGATIVE) —
# kept here so the SQL layer can label a breakdown without importing the service.
_TREATMENT_POSITIVE = ("followed", "explained")
_TREATMENT_NEGATIVE = ("distinguished", "criticized", "questioned", "overruled")
async def citation_authority(case_law_ids: list["UUID"]) -> dict[str, dict]:
"""Per-precedent incoming-citation breakdown by treatment (X11 Phase 2, #154).
For each precedent id → how many DISTINCT committee decisions cite it, split
into positive (followed/explained), negative (distinguished/criticized/
questioned/overruled) and unclassified (treatment not yet labelled). This is the
'cited_by N (X אומצו, Y אובחנו)' authority signal surfaced to research agents so
they can argue authority — and avoid leaning on a precedent that was repeatedly
distinguished/overruled. Counts distinct sources; a source with no treatment yet
falls in 'unclassified'. Returns {} for ids with no incoming committee citations.
"""
if not case_law_ids:
return {}
pool = await get_pool()
rows = await pool.fetch(
"SELECT pic.cited_case_law_id::text AS id, "
" coalesce(NULLIF(pic.treatment, ''), 'unclassified') AS t, "
" count(DISTINCT pic.source_case_law_id) AS n "
"FROM precedent_internal_citations pic "
"JOIN case_law src ON src.id = pic.source_case_law_id "
"WHERE src.source_kind = 'internal_committee' "
" AND pic.cited_case_law_id = ANY($1::uuid[]) "
"GROUP BY 1, 2",
case_law_ids,
)
out: dict[str, dict] = {}
for r in rows:
d = out.setdefault(r["id"], {
"total": 0, "positive": 0, "negative": 0, "unclassified": 0,
"by_treatment": {},
})
t, n = r["t"], int(r["n"])
d["by_treatment"][t] = d["by_treatment"].get(t, 0) + n
d["total"] += n
if t in _TREATMENT_POSITIVE:
d["positive"] += n
elif t in _TREATMENT_NEGATIVE:
d["negative"] += n
else:
d["unclassified"] += n
return out
async def list_canonical_instances(canonical_id: "UUID") -> list[dict]:
"""List all halachot (instances) sharing a canonical_id — used by the UI accordion."""
pool = await get_pool()
@@ -8030,7 +8242,33 @@ _MP_PROVENANCE_COLS = """,
WHERE pic.cited_case_law_id = mp.linked_case_law_id
AND COALESCE(src.case_number, '') <> ''
) 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(

View File

@@ -381,6 +381,119 @@ async def link_digest(digest_id: UUID | str, case_law_id: UUID | str) -> dict:
}
async def _radar_enrich(h: dict, score: float, matched_issues: list[str]) -> dict:
"""Shape one radar hit into a chair lead: gap status + suggested action +
which case ISSUE(s) it answers. The action points at the underlying RULING,
never the digest (INV-DIG1)."""
cit = (h.get("underlying_citation") or "").strip()
gap = await db.find_missing_precedent_by_citation(cit) if cit else None
in_corpus = await db.find_case_law_by_citation_fuzzy(cit) if cit else None
if in_corpus:
action = "available_link" # ruling actually IS in the corpus → just link the digest
elif gap and (gap.get("status") in ("uploaded", "closed")):
action = "fetched" # already obtained
elif gap:
action = "gap_open" # flagged as missing — can request a fetch
else:
action = "new_lead" # not even flagged yet — the highest-value alert
return {
"digest_id": str(h["id"]),
"yomon_number": h.get("yomon_number"),
"headline": h.get("headline_holding") or h.get("summary") or "",
"underlying_citation": cit,
"underlying_court": h.get("underlying_court") or "",
"score": round(score, 3),
"matched_issues": matched_issues,
"missing_precedent_id": str(gap["id"]) if gap else None,
"missing_precedent_status": gap.get("status") if gap else None,
"action": action,
}
async def case_digest_radar(
case_number: str,
limit: int = 5,
min_score: float = 0.45,
) -> dict:
"""Case-contextual digest radar (X12) — the chair-facing "שים לב" lead.
Surfaces UNLINKED digests (``linked_case_law_id IS NULL`` — rulings we don't hold
yet) whose topic is semantically close to THIS case, so a relevant ruling we only
know about via a digest doesn't fall through the cracks while the case is decided.
Query calibration: prefer the analyst's DISTILLED legal arguments (one crisp issue
per row — ``argument_title`` + ``legal_topic``) over the dozens of raw claims. Each
issue is searched separately and the leads are MERGED, so every lead is attributed
to the case issue(s) it answers (``matched_issues``) — far higher precision than
one blended query of noisy claims. Falls back to raw claims pre-aggregation
(``source`` reports which path ran). Each lead carries the underlying ruling's gap
status + a suggested action.
INV-DIG1: this is RADAR — the digest is never cited; the lead points at the
underlying *ruling* (fetch / upload / link), never the digest itself. Read-only.
"""
case = await db.get_case_by_number(case_number)
if not case:
return {"status": "case_not_found", "case_number": case_number, "leads": [], "count": 0}
case_id = case["id"]
if isinstance(case_id, str):
case_id = UUID(case_id)
ctx = " ".join(x for x in [case.get("title") or "", case.get("appeal_subtype") or ""] if x).strip()
# Preferred source: the analyst's distilled CREAC issues (one per legal_argument).
issues: list[tuple[str, str]] = [] # (label, embed_text)
try:
from legal_mcp.services import argument_aggregator
for a in await argument_aggregator.get_legal_arguments(case_id):
label = (a.get("argument_title") or a.get("legal_topic") or "").strip()
topic = (a.get("legal_topic") or "").strip()
body = f"{label}. {topic}".strip(". ").strip()
if body:
issues.append((label, f"{ctx} {body}".strip()))
except Exception as e: # noqa: BLE001 — arguments are optional; fall back to claims
logger.warning("case_digest_radar: get_legal_arguments failed for %s: %s", case_number, e)
merged: dict[str, dict] = {} # digest_id -> {"hit", "best", "issues": set}
if issues:
source = "legal_arguments"
issues = issues[:25] # already distilled — bound the per-issue search fan-out
vecs = await embeddings.embed_texts([t for _, t in issues], input_type="query")
for (label, _), vec in zip(issues, vecs):
for h in await db.search_digests_semantic(vec, limit=6, linked_only=False):
s = float(h.get("score", 0) or 0)
if s < min_score:
continue
m = merged.setdefault(str(h["id"]), {"hit": h, "best": s, "issues": set()})
m["best"] = max(m["best"], s)
if label:
m["issues"].add(label)
else:
# Fallback: blended query from raw claims (pre-aggregation), one search.
source = "claims"
parts = [ctx]
try:
claims = await db.get_claims(case_id)
parts += [(c.get("claim_text") or "") for c in claims[:20]]
except Exception as e: # noqa: BLE001
logger.warning("case_digest_radar: get_claims failed for %s: %s", case_number, e)
query = " ".join(p for p in parts if p).strip()
if not query:
return {"status": "no_topic", "case_number": case_number,
"source": source, "leads": [], "count": 0}
vec = (await embeddings.embed_texts([query], input_type="query"))[0]
for h in await db.search_digests_semantic(vec, limit=max(limit * 4, 20), linked_only=False):
s = float(h.get("score", 0) or 0)
if s < min_score:
continue
merged.setdefault(str(h["id"]), {"hit": h, "best": s, "issues": set()})
ordered = sorted(merged.values(), key=lambda m: -m["best"])[:max(1, limit)]
leads = [await _radar_enrich(m["hit"], m["best"], sorted(m["issues"])) for m in ordered]
return {"status": "ok", "case_number": case_number, "source": source,
"issues_used": [lbl for lbl, _ in issues] if source == "legal_arguments" else None,
"leads": leads, "count": len(leads)}
async def relink_digest(digest_id: UUID | str) -> dict:
"""Re-run autolink for an unlinked digest. No-op if already linked / no match."""
digest = await db.get_digest(digest_id)

View File

@@ -233,6 +233,17 @@ async def ingest_document(
await db.request_metadata_extraction(case_law_id)
await db.request_halacha_extraction(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,
f"נקלט: {stored_chunks} chunks. חילוץ הלכות ומטא-דאטה ממתינים בתור.")

View File

@@ -28,14 +28,14 @@ logger = logging.getLogger(__name__)
INTERNAL_DECISIONS_DIR = Path(config.DATA_DIR) / "internal-decisions"
_VALID_PRACTICE_AREAS = frozenset({"", "rishuy_uvniya", "betterment_levy", "compensation_197"})
_VALID_DISTRICTS = frozenset({"", "ירושלים", "מרכז", "תל אביב", "צפון", "דרום", "ארצי"})
_VALID_DISTRICTS = frozenset({"", "ירושלים", "מרכז", "תל אביב", "חיפה", "צפון", "דרום", "ארצי"})
_COURT_TO_DISTRICT = [
("ירושלים", "ירושלים"),
("תל אביב", "תל אביב"),
('ת"א', "תל אביב"),
("מרכז", "מרכז"),
("חיפה", "צפון"),
("חיפה", "חיפה"),
("צפון", "צפון"),
("דרום", "דרום"),
("ארצי", "ארצי"),
@@ -77,7 +77,7 @@ async def _create_internal_record(**kw) -> dict:
appeal_subtype=(kw.get("appeal_subtype") or "").strip(),
subject_tags=list(kw.get("subject_tags") or []),
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"),
proceeding_type=kw.get("proceeding_type") or "ערר",
)
@@ -108,7 +108,7 @@ async def ingest_internal_decision(
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
summary: str = "",
is_binding: bool = True,
is_binding: bool = False, # INV-DM7: committee sources are persuasive
file_path: str | Path | None = None,
text: str | 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(
"""UPDATE case_law
SET source_kind = 'internal_committee',
is_binding = FALSE, -- INV-DM7: committee = persuasive
district = CASE WHEN $2 <> '' THEN $2 ELSE district END
WHERE id = $1""",
row["id"], district,

View File

@@ -68,7 +68,11 @@ def _external_staging_subdir(inputs: dict) -> str:
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(
case_number=kw["citation"].strip(),
case_name=kw["case_name"],
@@ -84,6 +88,7 @@ async def _create_external_record(**kw) -> dict:
precedent_level=kw.get("precedent_level", ""),
is_binding=kw.get("is_binding", True),
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,
"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,
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(
@@ -411,6 +434,37 @@ async def get_precedent(case_law_id: UUID | str) -> dict | None:
return None
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
record["related_cases"] = await db.get_case_law_relations(case_law_id)
# 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
),
"treatment": r.get("treatment") or "",
"confidence": r.get("confidence"),
}
for r in raw
]
# Authority signal (X11 Phase 2, #154): how the citing committee decisions
# TREATED this ruling (followed/distinguished/…) — surfaced so the chair (and
# research agents via the tool output) can argue authority and avoid leaning on
# a repeatedly-distinguished precedent.
record["cited_by"] = (await db.citation_authority([case_law_id])).get(
str(case_law_id),
{"total": 0, "positive": 0, "negative": 0, "unclassified": 0, "by_treatment": {}},
)
return record

View File

@@ -260,6 +260,7 @@ async def apply_to_record(
case_law_id: UUID | str,
suggested: dict,
overwrite_case_number: bool = False,
force_citation: bool = False,
) -> dict:
"""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
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):
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
# edits in /precedents/[id] are preserved; abstains (no write) when a component
# 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_parties = (
fields_to_update.get("parties") or record.get("parties") or ""
@@ -505,6 +513,7 @@ async def apply_to_record(
async def extract_and_apply(
case_law_id: UUID | str,
overwrite_case_number: bool = False,
force_citation: bool = False,
) -> dict:
"""Convenience wrapper: extract → merge into row → return summary."""
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",
"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"]:
await db.recompute_searchable(case_law_id)
return {

View File

@@ -170,3 +170,30 @@ async def digest_process_pending(limit: int = 20) -> str:
except Exception as e:
return _err(str(e))
return _ok(result)
async def digest_radar(case_number: str, limit: int = 5, min_score: float = 0.45) -> str:
"""רדאר-יומונים הקשרי-לתיק (X12) — "שים לב" ליו"ר.
מחזיר יומונים **לא-מקושרים** (פס"ד שעוד אין לנו בקורפוס) שהנושא שלהם קרוב
סמנטית לתיק הזה — כדי שפס"ד רלוונטי שמוכר רק דרך יומון לא ייפול בין הכיסאות
בזמן הכרעת התיק. נושא-התיק נבנה מ-title + appeal_subtype + טענות-הצדדים, ומותאם
מול יומונים לא-מקושרים. כל ליד נושא את מראה-המקום של הפס"ד המקורי, סטטוס-הפער
(new_lead / gap_open / fetched / available_link) ופעולה מוצעת.
INV-DIG1: זהו radar — היומון לעולם אינו מצוטט; הליד מצביע על **הפס"ד** (להזמין
משיכה / להעלות / לקשר), לא על היומון. read-only.
Args:
case_number: מספר התיק (למשל "8124-09-24").
limit: מספר לידים מקסימלי.
min_score: סף-דמיון סמנטי (0-1) לסינון רעש.
"""
if not case_number.strip():
return _err("case_number חובה")
try:
result = await digest_library.case_digest_radar(
case_number.strip(), limit=max(1, int(limit)), min_score=float(min_score))
except Exception as e:
return _err(str(e))
return _ok(result)

View File

@@ -300,6 +300,20 @@ async def search_precedent_library(
limit=limit,
include_halachot=include_halachot,
)
# X11 Phase 2 (#154): attach the incoming-citation authority breakdown so the
# research agent can WEIGH and ARGUE authority ("הלכה שאומצה ב-N החלטות ועדת-ערר")
# — and steer clear of a precedent that committees repeatedly distinguished /
# overruled. Batched: one query for the whole result page.
try:
ids = {str(r.get("case_law_id")) for r in results if r.get("case_law_id")}
if ids:
auth = await db.citation_authority([UUID(i) for i in ids])
for r in results:
cb = auth.get(str(r.get("case_law_id")))
if cb:
r["cited_by"] = cb
except Exception: # noqa: BLE001 — authority is an additive signal; never break search
pass
elapsed_ms = int((time.perf_counter() - t0) * 1000)
telemetry.log_search_bg(
search_type="precedent_library",

View File

@@ -15,6 +15,7 @@
| `pc.sh` | bash | **wrapper לכל קריאות Paperclip API מסוכנים** — מוסיף Authorization, X-Paperclip-Run-Id (audit trail), Content-Type, base URL. תחביר: `pc.sh <METHOD> <PATH> [BODY_JSON]`. אסור `curl` ישיר ל-`$PAPERCLIP_API_URL`. ראה `HEARTBEAT.md §0`. counterpart ב-Python: `web/paperclip_api.py`. | נקרא ע"י סוכנים |
| `sync_agents_across_companies.py` | python | **סנכרון סוכנים מ-CMP (1xxx, master) ל-CMPA (8xxx, mirror)** — Gap #25. משווה adapter_config (model/timeout/instructions/skills/etc), runtime_config (heartbeat), ושדות top-level (budget/metadata/icon/title/role). מסנן אוטומטית local skills שלא קיימים ב-mirror. לוגיקת subset (mirror יכול להחזיק יותר skills כי ה-API מוסיף required runtime skills). תומך `--verify`/`--dry-run`/`--apply [--only NAME]`. גיבוי אוטומטי. דורש `PAPERCLIP_BOARD_API_KEY`. **להריץ אחרי כל שינוי הגדרות ב-CMP.** **⚠ אם `adapter_type` שונה בין CMP ל-CMPA — `--apply` מדלג על הסוכן; `--verify` מדווח אותו רם כ-DRIFT.** בעת מעבר adapter (למשל ל-`deepseek_local`) חובה לעדכן ידנית בשתי החברות. **`--verify` יוצא exit≠0 על כל drift** (needs-sync / adapter-mismatch / missing-in-mirror) — שמיש כ-gate ל-cron/CI (GAP-21/FU-8a). | ידני אחרי כל שינוי |
| `fix_paperclipai_skills_drift.py` | python | סקריפט חד-פעמי (בוצע 2026-05-04) שניקה drift על `paperclipai/*` skills בין CMP ל-CMPA. הסיר `paperclip-dev` מכל 14 הסוכנים, ודאג ש-`paperclip-converting-plans-to-tasks` קיים רק על CEO ו-analyst. תומך `--apply` (ברירת מחדל: dry-run). דורש `PAPERCLIP_BOARD_API_KEY`. נשמר לרפרנס למקרה שhdrift חוזר. | חד-פעמי (בוצע) |
| `classify_citation_treatments.py` | python | **סיווג-טיפול לקצוות-ציטוט (X11 Phase 2, #154)** — לכל קצה ב-`precedent_internal_citations` (החלטת-ועדה מצטטת תקדים) מסווג את ה-`treatment` מתוך ה-`match_context` דרך `corroboration.classify_treatment` (Opus 4.8 @ xhigh, claude_session **מקומי** — לא בקונטיינר): followed/explained=חיובי, distinguished/criticized/questioned/overruled=שלילי. ממלא `precedent_internal_citations.treatment` כך ש-`refresh_verified_layer` לא יספור ציטוט שלילי כסמכות (INV-COR2) ו-`db.citation_authority` יציג פירוק לסוכנים. אידמפוטנטי (מדלג על מסווגים). `--apply`/`--limit N`/`--case-law-id UUID`. **אחרי `--apply` הרץ `build_verified_layer.py`.** דורש `HOME=/home/chaim`. | ידני / אחרי גלי-ציטוט חדשים |
| `adapter_profiles.py` | python (module) | **רישום-פרופילי-אדפטר** — מקור-אמת יחיד ל-3 צירי-הכשל של מעבר-אדפטר: provider/default_model, instructions_mode (`file_path` בטוח-frontmatter מול `content_arg` ששובר `---`), ו-tool_config (`gemini_global` excludeTools / `frontmatter` / `hermes` / `codex_home`). כולל `codex_local` עם משפחת מודלי OpenAI/Codex (`gpt-*`, `o3*`, `o4*`, `codex-*`). מיובא ע"י `migrate_agent_adapter.py`. הוספת אדפטר עתידי = רשומה אחת. לא מורץ ישירות. | תשתית |
| `migrate_agent_adapter.py` | python | **מעבר-אדפטר בטוח לכל סוכן ← כל אדפטר, בשתי החברות יחד (INV-MC1)**. מיישב model↔provider, גורס frontmatter לעותק `.generated/<name>.nofm.md` ל-content_arg adapters (אחרת קריסת `gemini --prompt`/`hermes -q` על `---`), ומשחרר excludeTools גלובלי של gemini (`--relax-tools`). `--check` (preflight בלבד, exit≠0 על שגיאה — שער FU-8a) / `--apply` / `--revert` (שחזור מדויק מ-sidecar `data/adapter-migration-state.json`) / `--verify` (מסמן מצב לא-תואם/א-סימטרי, exit≠0). `--agent "<שם>"\|all --to <adapter> [--model X] [--relax-tools]`. PATCH דרך `/api/agents/{id}` (לא DB). דורש `PAPERCLIP_BOARD_API_KEY`. הרץ עם `mcp-server/.venv/bin/python`. **fallback-חירום כשנגמרים טוקני-Claude; החזר ל-claude_local כשחוזרים.** | ידני לפי צורך |

View File

@@ -0,0 +1,99 @@
"""Classify the TREATMENT of each internal citation edge (X11 Phase 2, #154).
Each row in ``precedent_internal_citations`` records that a committee decision
cited a precedent, with the surrounding ``match_context``. Until now the edge's
``treatment`` column was empty, so the verified/authority layer counted every
citation as if it were positive — a precedent *distinguished* N times got the
same authority boost as one *followed* N times (an INV-COR2 violation).
This script fills ``treatment`` per edge by classifying the context with
``corroboration.classify_treatment`` (Opus 4.8 @ xhigh via the local
claude_session bridge — LOCAL ONLY, the claude CLI is not in the container):
followed | explained → positive (counts toward authority)
distinguished | criticized |
questioned | overruled → negative (never counts; overruled = demote)
Scope: only LINKED edges (``cited_case_law_id IS NOT NULL``) with an empty
``treatment`` and a non-empty ``match_context``. Idempotent — a second run skips
rows already classified. After applying, run ``scripts/build_verified_layer.py``
(or ``db.refresh_verified_layer``) so the treatment-aware count takes effect.
Run (dry-run, default — classifies and PRINTS, writes nothing):
HOME=/home/chaim mcp-server/.venv/bin/python scripts/classify_citation_treatments.py
Apply:
HOME=/home/chaim mcp-server/.venv/bin/python scripts/classify_citation_treatments.py --apply
Options:
--limit N classify at most N edges (smoke test)
--case-law-id UUID restrict to citations TO this one precedent
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
from uuid import UUID
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
from legal_mcp.services import corroboration, db # noqa: E402
async def _pending(limit: int | None, case_law_id: str | None) -> list[dict]:
pool = await db.get_pool()
where = ["cited_case_law_id IS NOT NULL", "coalesce(treatment,'') = ''",
"coalesce(match_context,'') <> ''"]
params: list = []
if case_law_id:
params.append(UUID(case_law_id))
where.append(f"cited_case_law_id = ${len(params)}")
sql = (f"SELECT id, cited_case_number, match_context "
f"FROM precedent_internal_citations WHERE {' AND '.join(where)} "
f"ORDER BY created_at")
if limit:
sql += f" LIMIT {int(limit)}"
rows = await pool.fetch(sql, *params)
return [dict(r) for r in rows]
async def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true", help="write changes (default: dry-run)")
ap.add_argument("--limit", type=int, default=None)
ap.add_argument("--case-law-id", type=str, default=None)
args = ap.parse_args()
rows = await _pending(args.limit, args.case_law_id)
print(f"קצוות לא-מסווגים לעיבוד: {len(rows)}\n")
pool = await db.get_pool()
counts: dict[str, int] = {}
errors = 0
for r in rows:
try:
t = await corroboration.classify_treatment(
r["cited_case_number"] or "", r["match_context"] or "")
except Exception as e: # noqa: BLE001 — one bad row must not abort the batch
errors += 1
print(f" ✗ [error] {r['cited_case_number']}: {type(e).__name__}: {e}")
continue
counts[t] = counts.get(t, 0) + 1
sign = "" if corroboration.is_positive(t) else ("" if corroboration.is_negative(t) else "·")
print(f" {sign} {t:<14} {r['cited_case_number']}")
if args.apply:
await pool.execute(
"UPDATE precedent_internal_citations SET treatment = $2 WHERE id = $1",
r["id"], t,
)
print(f"\nסיכום טיפול: {counts} שגיאות={errors}"
+ ("" if args.apply else " (dry-run — לא נכתב)"))
if args.apply:
print("הרץ עכשיו: scripts/build_verified_layer.py (או db.refresh_verified_layer) "
"כדי שהספירה מודעת-הטיפול תיכנס לתוקף.")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { SubsectionCard } from "@/components/compose/subsection-card";
import { PrecedentsSection } from "@/components/compose/precedents-section";
import { CitationVerificationPanel } from "@/components/compose/citation-verification-panel";
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
import { Markdown } from "@/components/ui/markdown";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -236,6 +237,12 @@ export default function ComposePage({
<span aria-hidden>·</span>
<span className="text-navy">עורך החלטה</span>
</nav>
<Link
href={`/cases/${caseNumber}`}
className="inline-flex items-center gap-1.5 mb-3 rounded-md border border-rule bg-surface px-3 py-1.5 text-[0.82rem] font-semibold text-gold-deep hover:bg-gold-wash hover:text-navy transition-colors"
>
<span aria-hidden></span> חזרה לדף התיק
</Link>
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-navy text-2xl font-bold mb-0">ערר {caseNumber}</h1>
<StatusChip status={caseQuery.data?.status} />
@@ -270,6 +277,7 @@ export default function ComposePage({
<TabsList className="bg-rule-soft/60">
<TabsTrigger value="blocks">עורך הבלוקים</TabsTrigger>
<TabsTrigger value="positions">עמדות וטענות</TabsTrigger>
<TabsTrigger value="verify">אימות פסיקה</TabsTrigger>
</TabsList>
{/* Tab 1 — the 12-block decision editor (reused DecisionBlocksPanel) */}
@@ -277,6 +285,11 @@ export default function ComposePage({
<DecisionBlocksPanel caseNumber={caseNumber} />
</TabsContent>
{/* Tab 3 — citation verification: per-argument support + verify gate (#154) */}
<TabsContent value="verify" className="mt-5">
<CitationVerificationPanel caseNumber={caseNumber} />
</TabsContent>
{/* Tab 2 — chair positions on the analyst's threshold-claims + issues */}
<TabsContent value="positions" className="mt-5">
{analysis.isPending ? (

View File

@@ -39,14 +39,16 @@ export default function CaseDetailPage({
params: Promise<{ caseNumber: string }>;
}) {
const { caseNumber } = use(params);
const { data, isPending, error } = useCase(caseNumber);
const { data, isPending, error, refetch } = useCase(caseNumber);
const startWorkflow = useStartWorkflow(caseNumber);
const canStartWorkflow = data?.status === "new" || data?.status === "documents_ready";
const expectedOutcomeLabel = data?.expected_outcome
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
: null;
if (error) {
// Only take over the whole page when there is NO data to show. A transient
// 5xx on the 5s background refetch must not blow away an already-loaded page.
if (error && !data) {
return (
<AppShell>
<section className="space-y-6">
@@ -54,9 +56,14 @@ export default function CaseDetailPage({
<CardContent className="px-6 py-6 text-center space-y-3">
<p className="text-danger font-semibold">שגיאה בטעינת התיק</p>
<p className="text-sm text-ink-muted">{error.message}</p>
<Button asChild variant="outline">
<Link href="/">חזרה לרשימת התיקים</Link>
</Button>
<div className="flex items-center justify-center gap-2">
<Button variant="outline" onClick={() => refetch()}>
נסה שוב
</Button>
<Button asChild variant="ghost">
<Link href="/">חזרה לרשימת התיקים</Link>
</Button>
</div>
</CardContent>
</Card>
</section>
@@ -91,6 +98,9 @@ export default function CaseDetailPage({
<>
{data && <CaseEditDialog data={data} />}
<UploadSheet caseNumber={caseNumber} />
<Button asChild className="bg-gold text-white hover:bg-gold-deep border-transparent">
<Link href={`/cases/${caseNumber}/compose`}>פתח עורך החלטה</Link>
</Button>
{canStartWorkflow && (
<Button
className="bg-gold-deep hover:bg-gold-deep/90 text-parchment"
@@ -135,16 +145,7 @@ export default function CaseDetailPage({
<DocumentsPanel data={data} />
<AgentActivityPreview caseNumber={caseNumber} />
{/* gold CTA — open the decision editor */}
<Button
asChild
className="w-full bg-gold text-white hover:bg-gold-deep border-transparent py-6 text-base font-semibold"
>
<Link href={`/cases/${caseNumber}/compose`}>
פתח עורך החלטה
</Link>
</Button>
{/* decision-editor CTA moved to the band actions (visible on all tabs) */}
</TabsContent>
<TabsContent value="arguments" className="mt-0">

View File

@@ -22,12 +22,12 @@ import { MissingPrecedentsTable } from "@/components/missing-precedents/missing-
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 }[] = [
{ value: "open", label: "פתוח" },
{ value: "uploaded", label: "הועלה" },
{ value: "closed", label: "נסגר" },
{ value: "irrelevant", label: "לא-רלוונטי" },
{ value: "all", label: "הכל" },
];
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">
<b className="text-ink-soft">מחזור-חיים:</b>{" "}
<LifecycleChip tone="open">פתוח</LifecycleChip> {" "}
<LifecycleChip tone="up">הועלה</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">
linked_case_law_id
</code>{" "}
ונסגר. פריט שאינו רלוונטי מסומן{" "}
<LifecycleChip tone="na">לא-רלוונטי</LifecycleChip> מבלי שתידרש העלאה.
ונסגר.
</div>
</section>
</AppShell>

View File

@@ -12,7 +12,6 @@ import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Dialog,
DialogContent,
@@ -673,11 +672,11 @@ function RunLogDialog({ run, onClose }: { run: AgentRun | null; onClose: () => v
) : error ? (
<p className="text-sm text-destructive">שגיאה בטעינת הלוג: {String(error)}</p>
) : (
<ScrollArea className="h-[60vh] rounded-md border border-rule-soft bg-rule-soft/20 p-3">
<pre dir="ltr" className="text-[0.72rem] leading-relaxed whitespace-pre-wrap break-words text-navy text-start">
<div className="h-[60vh] w-full overflow-y-auto overflow-x-hidden rounded-md border border-rule-soft bg-rule-soft/20 p-3">
<pre dir="ltr" className="w-full min-w-0 max-w-full overflow-x-hidden text-[0.72rem] leading-relaxed whitespace-pre-wrap break-all text-navy text-start">
{text || "אין פלט עדיין."}
</pre>
</ScrollArea>
</div>
)}
</DialogContent>
</Dialog>

View File

@@ -69,8 +69,10 @@ export default function HomePage() {
{/* KPI row — mockup 04 .kpis (4-up, gold-washed "ממתינים לאישור") */}
<KPICards cases={data} loading={isPending} />
{/* two-column body — main flow + narrow gold gate rail (mockup 04 .cols) */}
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
{/* two-column body — main flow + narrow gold gate rail (mockup 04 .cols).
Rail trimmed 360→280 so the cases table gets the width it needs and
no longer needs a horizontal scrollbar (chair request). */}
<div className="grid gap-6 lg:grid-cols-[1fr_280px]">
<div className="space-y-6 min-w-0">
{/* "תיקים לפי סטטוס" (פסים אופקיים) הוסר — פיזור-הסטטוסים מוצג
בדונאט "פיזור סטטוסים" בטור-הצד (#1). */}

View File

@@ -236,7 +236,11 @@ export default function PrecedentDetailPage({
{/* side rail — citations + corroboration */}
<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>

View File

@@ -1,6 +1,6 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { useRef, useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
@@ -151,7 +151,7 @@ function CommentCard({
{identifier}
</Badge>
)}
<span className="text-[11px] text-ink-faint mr-auto flex items-center gap-1">
<span className="text-[11px] text-ink-faint me-auto flex items-center gap-1">
<Clock className="w-3 h-3" />
{timeAgo(comment.created_at)}
</span>
@@ -267,7 +267,7 @@ function AskUserQuestionsForm({
<fieldset key={q.id} className="space-y-2">
<legend className="text-sm font-semibold text-navy mb-1">
{q.prompt}
{(q.required ?? true) && <span className="text-rose-600 mr-1">*</span>}
{(q.required ?? true) && <span className="text-rose-600 ms-1">*</span>}
</legend>
<div className="space-y-1.5">
{q.options.map((opt) => {
@@ -316,7 +316,7 @@ function AskUserQuestionsForm({
{pending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4 ml-1" />
<Send className="w-4 h-4 me-1" />
)}
{interaction.payload.submitLabel || "שלח תשובה"}
</Button>
@@ -397,14 +397,14 @@ function RequestConfirmationForm({
onClick={handleReject}
disabled={pending || (requireReason && !reason.trim())}
>
<XCircle className="w-4 h-4 ml-1" />
<XCircle className="w-4 h-4 me-1" />
{rejectLabel}
</Button>
<Button size="sm" onClick={onAccept} disabled={pending}>
{pending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CheckCircle2 className="w-4 h-4 ml-1" />
<CheckCircle2 className="w-4 h-4 me-1" />
)}
{acceptLabel}
</Button>
@@ -489,7 +489,7 @@ function SuggestTasksForm({
onClick={() => (showReason ? onReject(reason.trim()) : setShowReason(true))}
disabled={pending}
>
<XCircle className="w-4 h-4 ml-1" />
<XCircle className="w-4 h-4 me-1" />
{showReason ? "אישור דחייה" : "דחייה"}
</Button>
<Button
@@ -500,7 +500,7 @@ function SuggestTasksForm({
{pending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CheckCircle2 className="w-4 h-4 ml-1" />
<CheckCircle2 className="w-4 h-4 me-1" />
)}
אישור משימות נבחרות ({selected.size})
</Button>
@@ -575,7 +575,7 @@ function InteractionCard({
{identifier}
</Badge>
)}
<span className="text-[11px] text-ink-faint mr-auto flex items-center gap-1">
<span className="text-[11px] text-ink-faint me-auto flex items-center gap-1">
<Clock className="w-3 h-3" />
{timeAgo(interaction.resolved_at ?? interaction.created_at)}
</span>
@@ -635,13 +635,13 @@ export function AgentActivityFeed({
const [body, setBody] = useState("");
const endRef = useRef<HTMLDivElement>(null);
// Build issue_id → identifier map
const issueMap = new Map<string, string>();
if (data?.issues) {
for (const iss of data.issues) {
issueMap.set(iss.id, iss.identifier);
}
}
// Build issue_id → identifier map (memoized — the feed refetches every 10s,
// and a fresh Map each render would defeat the child cards' memoization).
const issueMap = useMemo(() => {
const m = new Map<string, string>();
for (const iss of data?.issues ?? []) m.set(iss.id, iss.identifier);
return m;
}, [data?.issues]);
// Auto-scroll on new comments or interactions
const commentCount = data?.comments?.length ?? 0;
@@ -669,7 +669,7 @@ export function AgentActivityFeed({
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-ink-faint">
<Loader2 className="w-5 h-5 animate-spin ml-2" />
<Loader2 className="w-5 h-5 animate-spin me-2" />
<span>טוען פעילות סוכנים...</span>
</div>
);
@@ -777,6 +777,7 @@ export function AgentActivityFeed({
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="כתוב הוראה לסוכנים..."
aria-label="הוראה לסוכנים"
className="min-h-[60px] resize-none text-sm"
dir="rtl"
onKeyDown={(e) => {
@@ -798,7 +799,7 @@ export function AgentActivityFeed({
{sendComment.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4 ml-1" />
<Send className="w-4 h-4 me-1" />
)}
שלח
</Button>

View File

@@ -1,8 +1,20 @@
"use client";
import { useAgentActivity } from "@/lib/api/agents";
import { useState } from "react";
import { useAgentActivity, useResetCaseAgents } from "@/lib/api/agents";
import type { PaperclipAgentStatus } from "@/lib/api/agents";
import { Bot } from "lucide-react";
import { Bot, RotateCcw, Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
/* ── Status dot colors ───────────────────────────────────────── */
@@ -33,13 +45,82 @@ function AgentRow({ agent }: { agent: PaperclipAgentStatus }) {
className={`w-2 h-2 rounded-full flex-shrink-0 ${statusDot(agent.status)}`}
/>
<span className="text-xs text-ink truncate">{agent.name}</span>
<span className="text-[10px] text-ink-faint mr-auto">
<span className="text-[10px] text-ink-faint ms-auto">
{STATUS_LABEL[agent.status] ?? agent.status}
</span>
</div>
);
}
/* ── Reset button ─────────────────────────────────────────────── */
function ResetAgentsButton({ caseNumber }: { caseNumber: string }) {
const [open, setOpen] = useState(false);
const reset = useResetCaseAgents(caseNumber);
function handleConfirm() {
reset.mutate(undefined, {
onSuccess: (result) => {
setOpen(false);
const agentNames = result.reset_agents
.filter((a) => a.ok)
.map((a) => a.name)
.join(", ");
const msg = agentNames
? `סוכנים אופסו: ${agentNames}`
: "אופסו בהצלחה";
toast.success(msg);
},
onError: () => {
setOpen(false);
toast.error("שגיאה בביצוע האיפוס");
},
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button
className="text-[10px] text-ink-faint hover:text-red-600 flex items-center gap-0.5 transition-colors"
title="אפס סוכנים תקועים"
>
<RotateCcw className="w-3 h-3" />
<span>אפס</span>
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>איפוס סוכנים</DialogTitle>
<DialogDescription>
פעולה זו תאפס את כל הסוכנים במצב שגיאה ותחזיר issues פתוחים לניהול ידני.
פעולה הפיכה ניתן להפעיל את הסוכנים מחדש אחרי האיפוס.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={reset.isPending}>
ביטול
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
disabled={reset.isPending}
>
{reset.isPending ? (
<>
<Loader2 className="w-3.5 h-3.5 ms-1.5 animate-spin" />
מאפס...
</>
) : (
"אפס סוכנים"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/* ── Widget ───────────────────────────────────────────────────── */
export function AgentStatusWidget({
@@ -54,6 +135,7 @@ export function AgentStatusWidget({
const agents = data.agents ?? [];
const activeCount = agents.filter((a) => a.status === "active" || a.status === "running").length;
const hasErrors = agents.some((a) => a.status === "error");
return (
<div className="space-y-2">
@@ -62,11 +144,14 @@ export function AgentStatusWidget({
<Bot className="w-3.5 h-3.5" />
<span>סוכנים</span>
</div>
{agents.length > 0 && (
<span className="text-[10px] text-ink-faint">
{activeCount} פעילים מתוך {agents.length}
</span>
)}
<div className="flex items-center gap-2">
{hasErrors && <ResetAgentsButton caseNumber={caseNumber} />}
{agents.length > 0 && (
<span className="text-[10px] text-ink-faint">
{activeCount} פעילים מתוך {agents.length}
</span>
)}
</div>
</div>
<div className="space-y-0.5">

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
@@ -54,22 +54,26 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
},
});
/* Re-sync the form when the underlying case refetches after save */
useEffect(() => {
if (!open) return;
form.reset({
title: data.title ?? "",
subject: data.subject ?? "",
hearing_date: data.hearing_date ?? "",
notes: "",
expected_outcome: data.expected_outcome ?? "",
appellants: data.appellants ?? [],
respondents: data.respondents ?? [],
property_address: data.property_address ?? "",
permit_number: data.permit_number ?? "",
proceeding_type: data.proceeding_type ?? "ערר",
});
}, [open, data, form]);
/* Reset to the latest case values only on the open→true transition.
* Resetting on every `data` change would clobber in-progress edits, because
* useCase refetches every 5s (refetchInterval) while the dialog is open. */
const handleOpenChange = (next: boolean) => {
if (next) {
form.reset({
title: data.title ?? "",
subject: data.subject ?? "",
hearing_date: data.hearing_date ?? "",
notes: "",
expected_outcome: data.expected_outcome ?? "",
appellants: data.appellants ?? [],
respondents: data.respondents ?? [],
property_address: data.property_address ?? "",
permit_number: data.permit_number ?? "",
proceeding_type: data.proceeding_type ?? "ערר",
});
}
setOpen(next);
};
const onSubmit = form.handleSubmit(async (values) => {
try {
@@ -82,7 +86,7 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
עריכת פרטי תיק

View File

@@ -63,8 +63,10 @@ export function CaseHeader({
<span className="text-navy tabular-nums">{data?.case_number ?? "…"}</span>
</nav>
<div className="flex items-start justify-between gap-6 flex-wrap">
<div className="min-w-0">
{/* title block + metadata on one row — no wrap, so the metadata dl stays
parallel to the H1 and a long parties line can't push it below. */}
<div className="flex items-start justify-between gap-6">
<div className="min-w-0 flex-1">
{/* title row — H1 + status/type/blam chips inline (mockup .band h1) */}
<h1 className="text-navy text-[1.7rem] font-bold leading-tight flex items-center gap-3 flex-wrap mb-0">
<span className="tabular-nums">
@@ -107,11 +109,15 @@ export function CaseHeader({
{data.title}
</p>
)}
{/* parties line (mockup .parties) */}
{/* parties line (mockup .parties) — clamped to 2 lines so a long list
of appellants/respondents doesn't grow the band; full text on hover. */}
{parties ? (
<p className="text-ink-soft text-sm mt-1.5">{parties}</p>
<p className="text-ink-soft text-sm mt-1.5 max-w-3xl line-clamp-2" title={parties}>
{parties}
</p>
) : data?.subject ? (
<p className="text-ink-soft text-sm mt-1.5 max-w-3xl leading-relaxed">
<p className="text-ink-soft text-sm mt-1.5 max-w-3xl leading-relaxed line-clamp-2"
title={data.subject}>
{data.subject}
</p>
) : null}

View File

@@ -120,7 +120,7 @@ const columns: ColumnDef<Case>[] = [
accessorKey: "title",
header: "כותרת",
cell: ({ row }) => (
<div className="text-ink max-w-[420px] truncate flex items-center gap-2" title={row.original.title}>
<div className="text-ink max-w-[420px] min-w-0 truncate flex items-center gap-2" title={row.original.title}>
{(row.original.proceeding_type === 'בל"מ' || isBlamSubtype(row.original.appeal_subtype)) && (
<Badge
variant="outline"

View File

@@ -56,7 +56,7 @@ export function DecisionBlocksPanel({ caseNumber }: { caseNumber: string }) {
}
if (!data) return null;
const written = data.blocks.filter((b) => b.word_count > 0).length;
const written = data.blocks.filter((b) => (b.word_count ?? 0) > 0).length;
return (
<div className="space-y-4">
@@ -101,11 +101,11 @@ export function DecisionBlocksPanel({ caseNumber }: { caseNumber: string }) {
{blockLabel(block)}
</span>
<Badge
className={`text-[0.65rem] border ${STATUS_CLASSES[block.status]}`}
className={`text-[0.65rem] border ${STATUS_CLASSES[block.status] ?? ""}`}
>
{STATUS_LABELS[block.status]}
{STATUS_LABELS[block.status] ?? block.status}
</Badge>
{block.word_count > 0 && (
{(block.word_count ?? 0) > 0 && (
<span className="text-[0.7rem] text-ink-muted tabular-nums">
{block.word_count} מילים
</span>
@@ -137,19 +137,22 @@ function BlockEditor({
caseNumber: string;
block: DecisionBlock;
}) {
// The endpoint has no FastAPI response model, so `content` may arrive null
// for an empty block — coerce to "" to keep `.trim()` / state safe.
const content = block.content ?? "";
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(block.content);
const [value, setValue] = useState(content);
const [state, setState] = useState<SaveState>({ kind: "idle" });
/* The last content known to be persisted — used to skip no-op saves. */
const [baseline, setBaseline] = useState(block.content);
const [baseline, setBaseline] = useState(content);
const save = useSaveBlock(caseNumber);
/* Re-sync when the upstream query refetches (e.g. after another save) while
* not actively editing. Adjusting state during render — the documented React
* pattern for derived-from-props — avoids a setState-in-effect cascade. */
if (!editing && block.content !== baseline) {
setBaseline(block.content);
setValue(block.content);
if (!editing && content !== baseline) {
setBaseline(content);
setValue(content);
}
async function handleSave() {
@@ -171,8 +174,8 @@ function BlockEditor({
if (!editing) {
return (
<div className="space-y-3">
{block.content.trim() ? (
<Markdown content={block.content} />
{content.trim() ? (
<Markdown content={content} />
) : (
<p className="text-sm text-ink-muted italic">בלוק ריק.</p>
)}

View File

@@ -90,13 +90,23 @@ export function DocumentTypeEditor({
// clear it so it doesn't dangle confusingly in metadata.
if (!isAppraisal && appraiserSide) body.appraiser_side = "";
await patch.mutateAsync({ docId, patch: body });
setSaved(true);
// Swallow the rejection — errors surface via `patch.isError`; an unhandled
// rejection from this async click handler would otherwise leak.
try {
await patch.mutateAsync({ docId, patch: body });
setSaved(true);
} catch {
/* surfaced via patch.isError */
}
}
async function handleExtract() {
const result = await extract.mutateAsync();
setExtractResult(result);
try {
const result = await extract.mutateAsync();
setExtractResult(result);
} catch {
/* surfaced via extract.isError */
}
}
// Build the on-badge label: "שומה · שמאי הוועדה" when both present.
@@ -301,7 +311,7 @@ function PostSaveView({
נותרו {extractResult.missing.length} שומות ללא תיוג צד. תייג אותן
לפני הפעלת החילוץ:
</p>
<ul className="list-disc pr-4 space-y-0.5">
<ul className="list-disc ps-4 space-y-0.5">
{extractResult.missing.map((m) => (
<li key={m.document_id} className="truncate">
{m.title}

View File

@@ -1,6 +1,7 @@
"use client";
import { useRef, useState } from "react";
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -344,9 +345,9 @@ export function DraftsPanel({
</Button>
<span className="text-[0.7rem] text-ink-muted">
סטטוס הריצה בדף{" "}
<a href="/operations" className="underline">
<Link href="/operations" className="underline">
התפעול
</a>
</Link>
</span>
</div>
)}
@@ -628,7 +629,7 @@ function CitationsSection({ caseNumber }: { caseNumber: string }) {
<div className="rounded-lg border border-rule overflow-hidden divide-y divide-rule">
{data.linked.map((c) => (
<a
<Link
key={`l-${c.citation}`}
href={`/precedents/${c.cited_id}`}
className="flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-rule-soft/20"
@@ -641,7 +642,7 @@ function CitationsSection({ caseNumber }: { caseNumber: string }) {
<Badge className="ms-auto bg-success-bg text-success border-success/40 text-[0.65rem] shrink-0">
בספרייה
</Badge>
</a>
</Link>
))}
{data.missing.map((c) => (
<div

View File

@@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { useMemo, type ReactNode } from "react";
import {
Accordion,
AccordionContent,
@@ -9,7 +9,6 @@ import {
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Popover,
PopoverContent,
@@ -22,6 +21,7 @@ import {
PRIORITY_ORDER,
useAggregateArguments,
useLegalArguments,
type AggregateArgumentsResult,
type LegalArgument,
type LegalArgumentParty,
type LegalArgumentPriority,
@@ -189,82 +189,167 @@ export function LegalArgumentsPanel({ caseNumber }: LegalArgumentsPanelProps) {
}, [data]);
const handleAggregate = (force: boolean) => {
// Status feedback is rendered inline as a banner from `aggregate.data`;
// only hard transport/HTTP errors fall through to a toast.
aggregate.mutate(force, {
onSuccess: () => {
toast.success(
force
? "הופעלה חזרה חישוב טיעונים (force). יסתיים תוך דקה."
: "הופעל חישוב טיעונים. רענן בעוד דקה.",
);
},
onError: (e) => toast.error(`שגיאה: ${(e as Error).message}`),
});
};
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-navy text-base font-semibold">
טיעונים משפטיים
</h2>
<p className="text-ink-muted text-xs mt-0.5">
טיעונים מאוגדים מתוך הפרופוזיציות הגולמיות, מקובצים לפי צד וקדימות.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={aggregate.isPending}
onClick={() => handleAggregate(false)}
>
{aggregate.isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
) : (
<Sparkles className="w-3.5 h-3.5 me-1.5" />
)}
חשב טיעונים
</Button>
<Button
variant="ghost"
size="sm"
disabled={aggregate.isPending || !data?.total}
onClick={() => handleAggregate(true)}
title="חישוב מחדש (מוחק טיעונים קיימים)"
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-navy text-base font-semibold">טיעונים משפטיים</h2>
<p className="text-ink-muted text-xs mt-0.5">
טיעונים מאוגדים מתוך הטענות הגולמיות, מקובצים לפי צד וקדימות. החישוב
רץ אצל המנתח המשפטי ומדווח בחזרה.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={aggregate.isPending}
onClick={() => handleAggregate(false)}
>
{aggregate.isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
) : (
<Sparkles className="w-3.5 h-3.5 me-1.5" />
)}
חשב טיעונים
</Button>
<Button
variant="ghost"
size="sm"
disabled={aggregate.isPending || !data?.total}
onClick={() => handleAggregate(true)}
title="חישוב מחדש (מוחק טיעונים קיימים)"
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{isPending ? (
<div className="space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
) : isError ? (
<p className="text-danger text-sm">
שגיאה בטעינת טיעונים: {(error as Error).message}
</p>
) : !data?.total ? (
<p className="text-ink-muted text-sm">
אין טיעונים מאוגדים עדיין. לחץ &ldquo;חשב טיעונים&rdquo; כדי להריץ את ה-aggregator.
</p>
) : (
<div className="space-y-6">
{parties.map((party) => (
<PartySection
key={party}
party={party}
args={data.by_party[party] ?? []}
/>
))}
</div>
)}
</CardContent>
</Card>
<AggregateStatusBanner
result={aggregate.data}
force={aggregate.variables ?? false}
/>
{isPending ? (
<div className="space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
) : isError ? (
<p className="text-danger text-sm">
שגיאה בטעינת טיעונים: {(error as Error).message}
</p>
) : !data?.total ? (
<p className="text-ink-muted text-sm">
אין טיעונים מאוגדים עדיין. לחץ &ldquo;חשב טיעונים&rdquo; כדי לשלוח את
החישוב למנתח המשפטי.
</p>
) : (
<div className="space-y-6">
{parties.map((party) => (
<PartySection
key={party}
party={party}
args={data.by_party[party] ?? []}
/>
))}
</div>
)}
</div>
);
}
const BANNER_TONE = {
info: "border-info/30 bg-info-bg",
gold: "border-gold/40 bg-gold-wash",
muted: "border-rule bg-rule-soft",
warn: "border-warn/40 bg-warn-bg",
} as const;
const DOT_TONE = {
info: "bg-info",
gold: "bg-gold-deep",
muted: "bg-ink-light",
warn: "bg-warn",
} as const;
const TITLE_TONE = {
info: "text-info",
gold: "text-gold-deep",
muted: "text-ink-soft",
warn: "text-warn",
} as const;
/**
* Inline status feedback for the aggregation trigger — mirrors the approved
* Claude Design mockup (25-legal-arguments-panel). Shown only after a click;
* the four states map to the endpoint's discriminated-union result.
*/
function AggregateStatusBanner({
result,
force,
}: {
result: AggregateArgumentsResult | undefined;
force: boolean;
}) {
if (!result) return null;
let tone: keyof typeof BANNER_TONE;
let title: string;
let body: ReactNode;
switch (result.status) {
case "queued":
tone = "info";
title = "נשלח לאנליטיקאי.";
body = `${force ? "החישוב-מחדש" : "החישוב"} רץ ברקע אצל המנתח המשפטי; התוצאה תופיע תוך כמה דקות — רענן את הדף.`;
break;
case "exists":
tone = "gold";
title = "כבר חושב.";
body = result.message;
break;
case "no_claims":
tone = "muted";
title = "אין טענות גולמיות.";
body = result.message;
break;
case "skipped":
tone = "warn";
title = "לא ניתן להפעיל אוטומטית";
body = (
<>
{` (${result.reason}). הרץ ידנית מ-Claude Code: `}
<code className="font-mono text-[0.72rem] bg-navy/5 rounded px-1.5 py-0.5 select-all">
mcp__legal-ai__aggregate_claims_to_arguments
</code>
</>
);
break;
default:
return null;
}
return (
<div
className={`flex items-start gap-2.5 rounded-md border px-3.5 py-2.5 text-[0.8rem] leading-relaxed text-ink-soft ${BANNER_TONE[tone]}`}
>
<span
className={`mt-1.5 size-2 flex-none rounded-full ${DOT_TONE[tone]}`}
aria-hidden
/>
<p>
<strong className={`font-semibold ${TITLE_TONE[tone]}`}>{title}</strong>{" "}
{body}
</p>
</div>
);
}

View File

@@ -25,14 +25,20 @@ export function StatusChanger({
caseNumber: string;
currentStatus?: CaseStatus;
}) {
const [selected, setSelected] = useState<CaseStatus | "">(currentStatus ?? "");
// `null` = untouched → the dropdown tracks the live `currentStatus` (which
// arrives async and changes on the 5s poll / external updates). Only an
// explicit pick overrides it, until save resets back to tracking.
const [picked, setPicked] = useState<CaseStatus | null>(null);
const mutate = useUpdateCase(caseNumber);
const effective: CaseStatus | "" = picked ?? currentStatus ?? "";
const canSave = Boolean(effective) && effective !== currentStatus;
const handleSave = async () => {
if (!selected || selected === currentStatus) return;
if (!canSave || !effective) return;
try {
await mutate.mutateAsync({ status: selected });
toast.success(`הסטטוס עודכן ל${STATUS_LABELS[selected]}`);
await mutate.mutateAsync({ status: effective });
toast.success(`הסטטוס עודכן ל${STATUS_LABELS[effective]}`);
setPicked(null);
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה בעדכון הסטטוס");
}
@@ -43,8 +49,8 @@ export function StatusChanger({
<label className="text-[0.72rem] text-ink-muted block">שינוי סטטוס ידני</label>
<div className="flex items-center gap-2">
<Select
value={selected || "__current__"}
onValueChange={(v) => setSelected(v === "__current__" ? "" : v as CaseStatus)}
value={effective || "__current__"}
onValueChange={(v) => setPicked(v === "__current__" ? null : v as CaseStatus)}
dir="rtl"
>
<SelectTrigger className="text-[0.75rem] h-8">
@@ -68,7 +74,7 @@ export function StatusChanger({
size="sm"
variant="outline"
className="h-8 text-[0.72rem] px-3 shrink-0"
disabled={!selected || selected === currentStatus || mutate.isPending}
disabled={!canSave || mutate.isPending}
onClick={handleSave}
>
{mutate.isPending ? "שומר…" : "עדכן"}

View File

@@ -0,0 +1,261 @@
"use client";
/**
* "אימות פסיקה" tab (X11 Phase 2 / #154) — per legal argument, the in-corpus
* supporting precedents with the cumulative authority signal (cited_by), a verify
* gate + chair note, and per-issue radar (unlinked digests). The writer cites only
* verified quotes (INV-AH); the digest is never cited (INV-DIG1, radar only).
*/
import { useState } from "react";
import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
useCitationVerification,
useVerifyCitation,
type ArgumentBlock,
type SupportingPrecedent,
type RadarLead,
} from "@/lib/api/citation-verification";
const PRIORITY_LABEL: Record<string, string> = {
threshold: "סף",
substantive: "מהותית",
procedural: "פרוצדורלית",
relief: "סעד",
};
const RADAR_LABEL: Record<string, string> = {
new_lead: "ליד חדש",
gap_open: "בתור-חסרים",
fetched: "נמשך",
available_link: "בקורפוס — לקשר",
};
export function CitationVerificationPanel({ caseNumber }: { caseNumber: string }) {
const { data, isPending, isError } = useCitationVerification(caseNumber);
const verify = useVerifyCitation(caseNumber);
const [notes, setNotes] = useState<Record<string, string>>({});
if (isPending) {
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-3">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
);
}
if (isError || !data || data.status !== "ok") {
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-8 text-center text-ink-muted">
לא ניתן לטעון את אימות-הפסיקה כעת.
</CardContent>
</Card>
);
}
if (!data.arguments.length) {
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-12 text-center space-y-2">
<div className="text-gold text-3xl" aria-hidden></div>
<p className="text-navy font-semibold mb-0">אין עדיין סוגיות מזוקקות לתיק</p>
<p className="text-ink-muted text-sm">
הרץ את ניתוח-הטענות (aggregate) כדי שהמערכת תזהה סוגיות ותתאים להן פסיקה.
</p>
</CardContent>
</Card>
);
}
function runVerify(a: ArgumentBlock, s: SupportingPrecedent, verified: boolean) {
verify.mutate(
{
argument_id: a.argument_id,
case_law_id: s.case_law_id,
quote: s.quote,
citation: s.case_number || s.case_name,
chair_note: notes[s.case_law_id] ?? s.chair_note,
verified,
attached_id: s.attached_id ?? "",
},
{
onSuccess: () => toast.success(verified ? "הציטוט אומת" : "סומן כלא-רלוונטי"),
onError: (e: Error) => toast.error(`שגיאה: ${e.message}`),
},
);
}
function saveNote(a: ArgumentBlock, s: SupportingPrecedent) {
const note = notes[s.case_law_id];
if (note === undefined || note === s.chair_note) return;
verify.mutate(
{
argument_id: a.argument_id,
case_law_id: s.case_law_id,
quote: s.quote,
citation: s.case_number || s.case_name,
chair_note: note,
verified: s.verified,
attached_id: s.attached_id ?? "",
},
{
onSuccess: () => toast.success("הערת-יו״ר נשמרה"),
onError: (e: Error) => toast.error(`שגיאה: ${e.message}`),
},
);
}
const sum = data.summary;
return (
<div className="space-y-4">
{/* summary */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-ink-soft bg-parchment border border-rule rounded-lg px-4 py-2.5">
<span>סוגיות עם פסיקה: <b className="text-navy tabular-nums">{sum.arguments_with_support}/{sum.arguments_total}</b></span>
<span>אומתו: <b className="text-success tabular-nums">{sum.verified}</b></span>
<span>לידֵי רדאר: <b className="text-warn tabular-nums">{sum.radar_leads}</b></span>
<span className="text-ink-muted text-[0.78rem] ms-auto">ה-writer מצטט רק מאומתים (INV-AH) · היומון אינו מצוטט (INV-DIG1)</span>
</div>
{data.arguments.map((a) => (
<Card key={a.argument_id} className="bg-surface border-rule shadow-sm overflow-hidden">
<CardContent className="p-0">
{/* issue header */}
<div className="flex items-start gap-3 px-5 py-3.5 border-b border-rule-soft bg-parchment/60">
<div className="min-w-0 flex-1">
<div className="text-navy font-bold text-[0.98rem]">{a.title}</div>
<div className="text-ink-muted text-xs mt-0.5">
סוגיה{a.legal_topic ? ` · ${a.legal_topic}` : ""}
</div>
</div>
{a.priority && (
<span className="shrink-0 rounded-full bg-gold-wash text-gold-deep text-[0.68rem] font-semibold px-2.5 py-0.5">
{PRIORITY_LABEL[a.priority] ?? a.priority}
</span>
)}
</div>
{/* supporting precedents */}
<div className="px-5 py-3">
<div className="text-[0.72rem] font-bold text-ink-muted mb-1">פסיקה תומכת בקורפוס</div>
{a.supporting.length === 0 ? (
<p className="text-ink-muted text-sm py-2">לא נמצאה פסיקה תומכת בקורפוס לסוגיה זו.</p>
) : (
<ul className="list-none p-0 m-0 divide-y divide-rule-soft">
{a.supporting.map((s) => (
<li key={s.case_law_id} className="py-3 first:pt-1">
<div className="flex items-center gap-2 flex-wrap">
<a
href={`/precedents/${s.case_law_id}`}
className="font-bold text-gold-deep text-[0.86rem] hover:text-navy"
>
{s.case_number || s.case_name}
</a>
{s.cited_by.positive > 0 && (
<span className="rounded-full bg-success-bg text-success text-[0.68rem] font-semibold px-2 py-0.5">
אומץ ×{s.cited_by.positive}
</span>
)}
{s.cited_by.negative > 0 && (
<span className="rounded-full bg-danger-bg text-danger text-[0.68rem] font-semibold px-2 py-0.5">
אובחן ×{s.cited_by.negative}
</span>
)}
</div>
{s.quote && (
<blockquote className="border-s-[3px] border-gold bg-gold-wash text-ink-soft text-sm leading-7 rounded-e-md px-3.5 py-2 my-2 mx-0">
{s.quote}
</blockquote>
)}
<div className="flex items-center gap-2 flex-wrap">
{s.verified ? (
<>
<span className="text-success text-xs font-semibold"> אומת ע״י היו״ר</span>
<button
type="button"
disabled={verify.isPending}
onClick={() => runVerify(a, s, false)}
className="text-xs text-ink-muted hover:text-danger border border-rule rounded-md px-2.5 py-1 disabled:opacity-40"
>
בטל אימות
</button>
</>
) : (
<>
<button
type="button"
disabled={verify.isPending}
onClick={() => runVerify(a, s, true)}
className="text-xs font-semibold text-success bg-success-bg border border-success/30 rounded-md px-3 py-1 hover:brightness-95 disabled:opacity-40"
>
מאמת
</button>
<button
type="button"
disabled={verify.isPending}
onClick={() => runVerify(a, s, false)}
className="text-xs font-semibold text-danger border border-rule rounded-md px-3 py-1 hover:bg-danger-bg disabled:opacity-40"
>
לא רלוונטי
</button>
</>
)}
{s.cited_by.negative > 0 && !s.verified && (
<span className="text-warn text-[0.72rem] ms-1"> אובחן בדוק הקשר</span>
)}
</div>
{/* chair note */}
<div className="mt-2 flex items-start gap-2">
<label className="text-[0.72rem] font-bold text-gold-deep whitespace-nowrap pt-1.5">
הערת יו״ר:
</label>
<input
type="text"
defaultValue={s.chair_note}
placeholder="הוסף הערה לציטוט — מדוע תומך / הסתייגות / איך לשלב…"
onChange={(e) => setNotes((n) => ({ ...n, [s.case_law_id]: e.target.value }))}
onBlur={() => saveNote(a, s)}
className="flex-1 text-[0.8rem] text-ink-soft bg-parchment border border-dashed border-rule rounded-md px-2.5 py-1.5 focus:outline-none focus:border-gold"
/>
</div>
</li>
))}
</ul>
)}
</div>
{/* radar — unlinked digests for this issue (INV-DIG1: pointer, not citation) */}
<RadarStrip radar={a.radar} />
</CardContent>
</Card>
))}
</div>
);
}
function RadarStrip({ radar }: { radar: RadarLead[] }) {
if (!radar.length) return null;
return (
<div className="mx-5 mb-4 rounded-lg border border-dashed border-gold bg-parchment px-4 py-2.5">
<div className="text-[0.72rem] font-bold text-gold-deep mb-1">
📡 רדאר פסיקה שאין לנו בקורפוס (מצביע, לא מצוטט)
</div>
<ul className="list-none p-0 m-0 space-y-1">
{radar.map((r) => (
<li key={r.digest_id} className="flex items-center gap-2 flex-wrap text-[0.8rem]">
<span className="font-semibold text-navy">{r.underlying_citation || "(אין מראה-מקום)"}</span>
<span className="rounded-full bg-warn-bg text-warn text-[0.64rem] font-semibold px-2 py-0.5">
{RADAR_LABEL[r.action] ?? r.action}
</span>
<span className="text-ink-muted truncate">{r.headline}</span>
</li>
))}
</ul>
</div>
);
}

View File

@@ -43,7 +43,9 @@ function statusLabel(event: ProgressEvent | null): string {
if (event.status === "processing")
return event.step ? `בעיבוד · ${event.step}` : "בעיבוד";
if (event.status === "completed") return "הושלם";
if (event.status === "unknown") return "הושלם";
// TTL expired / subscribed too late — the outcome is genuinely unknown, not
// a confirmed success. The case-detail refetch is the real source of truth.
if (event.status === "unknown") return "הסתיים — רענן לאישור";
if (event.status === "failed") return event.error ?? "נכשל";
return event.status;
}
@@ -62,7 +64,9 @@ function UploadRowView({ row, caseNumber }: { row: UploadRow; caseNumber: string
const progress = useProgress(row.taskId, caseNumber);
const pct = row.error ? 100 : progressPercent(progress);
const failed = row.error || progress?.status === "failed";
const done = progress?.status === "completed" || progress?.status === "unknown";
const done = progress?.status === "completed";
// `unknown` = settled-but-indeterminate; render neutral, not green success.
const indeterminate = progress?.status === "unknown";
return (
<li className="rounded-lg border border-rule bg-parchment/40 px-4 py-3 space-y-2">
@@ -71,6 +75,8 @@ function UploadRowView({ row, caseNumber }: { row: UploadRow; caseNumber: string
<CheckCircle2 className="w-4 h-4 text-success shrink-0" />
) : failed ? (
<XCircle className="w-4 h-4 text-danger shrink-0" />
) : indeterminate ? (
<CheckCircle2 className="w-4 h-4 text-ink-muted shrink-0" />
) : (
<Loader2 className="w-4 h-4 text-gold animate-spin shrink-0" />
)}

View File

@@ -48,7 +48,14 @@ export type GraphControls = {
};
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 }[] = [
{ value: "type", label: "סוג נקודה" },

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Trash2, Upload, Pencil, ExternalLink } from "lucide-react";
import { useMemo, useState } from "react";
import { Trash2, Upload, Pencil, ExternalLink, ChevronDown } from "lucide-react";
import { toast } from "sonner";
import Link from "next/link";
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 }) {
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 = {
status?: MissingPrecedentStatus | "";
q?: string;
@@ -91,7 +141,11 @@ export function MissingPrecedentsTable({ status, q, legalTopic }: Props) {
status: status === "" ? undefined : status,
q,
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();
@@ -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) {
return (
<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">
<Table>
<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>
<TableHeaderRow />
<TableBody>
{isPending ? (
<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>
))
)}
<TableSkeleton cols={COLS} />
</TableBody>
</Table>
</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
id={openId}

View File

@@ -18,6 +18,7 @@ import {
useLinkRelatedCase,
useUnlinkRelatedCase,
RelatedCase,
IncomingCitation,
} from "@/lib/api/precedent-library";
const LEVEL_LABELS: Record<string, string> = {
@@ -133,19 +134,28 @@ function LinkDialog({ caseId, currentRelated, open, onOpenChange }: DialogProps)
type SectionProps = {
caseId: string;
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
* decisions as a navy-headed card with arrow-prefixed rows; keeps the full
* link/unlink logic. Used in the precedent-detail side rail. */
export function RelatedCasesSection({ caseId, related }: SectionProps) {
* link/unlink logic. Auto-detected incoming citations (decisions that cite this
* 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);
// 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 (
<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">
<h3 className="text-navy text-[0.92rem] font-semibold m-0">
ציטוטים מקושרים{related.length > 0 ? ` (${related.length})` : ""}
ציטוטים מקושרים{total > 0 ? ` (${total})` : ""}
</h3>
<Button
variant="outline"
@@ -157,10 +167,46 @@ export function RelatedCasesSection({ caseId, related }: SectionProps) {
</Button>
</div>
{related.length === 0 ? (
{total === 0 ? (
<p className="text-ink-muted text-[0.82rem] m-0">אין החלטות קשורות עדיין</p>
) : (
<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) => (
<li
key={r.id}

View File

@@ -179,3 +179,25 @@ export function useSubmitInteraction(caseNumber: string | undefined) {
},
});
}
export type AgentResetResult = {
ok: boolean;
reassigned_issues: { id: string; identifier: string }[];
reset_agents: { id: string; name: string; ok: boolean; error?: string }[];
};
export function useResetCaseAgents(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: () =>
apiRequest<AgentResetResult>(
`/api/cases/${caseNumber}/agents/reset`,
{ method: "POST" },
),
onSuccess: () => {
if (caseNumber) {
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
}
},
});
}

View File

@@ -176,14 +176,16 @@ export function useUpdateCase(caseNumber: string | undefined) {
body: input,
}),
onSuccess: (data) => {
/* Patch cached detail and nudge the list to refetch on next focus */
/* Patch cached detail, then invalidate only the LIST queries — not
* casesKeys.all, which would also invalidate the detail we just patched
* and throw away the optimistic merge. */
if (caseNumber) {
qc.setQueryData<CaseDetail | undefined>(
casesKeys.detail(caseNumber),
(prev) => (prev ? { ...prev, ...data } : prev),
);
}
qc.invalidateQueries({ queryKey: casesKeys.all });
qc.invalidateQueries({ queryKey: [...casesKeys.all, "list"] });
},
});
}

View File

@@ -0,0 +1,103 @@
/**
* Citation-verification domain (X11 Phase 2 / #154) — the compose "אימות פסיקה" tab.
*
* Per legal argument: in-corpus supporting precedents with the cumulative authority
* signal (cited_by — followed/distinguished), their verify state + chair note, and
* per-issue radar (unlinked digests). The chair verifies before the writer cites
* (INV-AH). Backed by GET/POST /api/cases/{n}/citation-verification[/verify].
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "./client";
export type CitedBy = {
total: number;
positive: number;
negative: number;
unclassified: number;
by_treatment: Record<string, number>;
};
export type SupportingPrecedent = {
case_law_id: string;
case_number: string;
case_name: string;
quote: string;
score: number;
cited_by: CitedBy;
attached_id: string | null;
verified: boolean;
chair_note: string;
};
export type RadarLead = {
digest_id: string;
yomon_number?: string | number | null;
headline: string;
underlying_citation: string;
underlying_court: string;
score: number;
missing_precedent_id: string | null;
missing_precedent_status: string | null;
action: "new_lead" | "gap_open" | "fetched" | "available_link" | string;
matched_issues?: string[];
};
export type ArgumentBlock = {
argument_id: string;
title: string;
legal_topic: string;
priority: string;
party: string;
supporting: SupportingPrecedent[];
radar: RadarLead[];
};
export type CitationVerificationView = {
status: string;
case_number: string;
arguments: ArgumentBlock[];
summary: {
arguments_total: number;
arguments_with_support: number;
verified: number;
radar_leads: number;
};
};
export type VerifyCitationBody = {
argument_id: string;
case_law_id?: string;
quote?: string;
citation?: string;
chair_note?: string | null;
verified: boolean;
attached_id?: string;
};
export function useCitationVerification(caseNumber: string | undefined) {
return useQuery({
queryKey: ["citation-verification", caseNumber ?? ""],
queryFn: ({ signal }) =>
apiRequest<CitationVerificationView>(
`/api/cases/${caseNumber}/citation-verification`,
{ signal },
),
enabled: Boolean(caseNumber),
staleTime: 10_000,
});
}
export function useVerifyCitation(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: VerifyCitationBody) =>
apiRequest(`/api/cases/${caseNumber}/citation-verification/verify`, {
method: "POST",
body,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["citation-verification", caseNumber ?? ""] });
},
});
}

View File

@@ -9,7 +9,8 @@ import { apiRequest } from "./client";
export type LinkedCitation = {
citation: string;
cited_id: string;
case_name: string;
/** May be null/empty — the panel guards with `c.case_name && …`. */
case_name: string | null;
court: string;
precedent_level: string;
};

View File

@@ -71,12 +71,30 @@ export function useLegalArguments(caseNumber: string | undefined) {
});
}
export type AggregateArgumentsResult = {
status: "started" | string;
case_number: string;
force: boolean;
message: string;
};
/**
* The aggregation runs on the legal-analyst agent (host-side, where the
* `claude` CLI lives) — NOT inline in the FastAPI container. The endpoint
* either delegates via a Paperclip wakeup (`queued`) or short-circuits on a
* cheap in-container DB pre-check (`no_claims` / `exists`). `skipped` means
* no analyst route was available; the chair can run the MCP tool manually.
*/
export type AggregateArgumentsResult =
| {
status: "queued";
sub_issue_id: string;
analyst_id: string;
main_issue_id: string;
}
| {
status: "no_claims" | "exists";
total: number;
message: string;
}
| {
status: "skipped";
reason: "no_api_key" | "no_analyst" | "no_issue" | string;
company_id?: string;
};
export function useAggregateArguments(caseNumber: string | undefined) {
const qc = useQueryClient();

View File

@@ -56,6 +56,10 @@ export type MissingPrecedent = {
discovery_source: string | null; // manual | cited_only | digest | court_fetch
cited_by_precedents: string[] | null; // corpus precedents citing a cited_only stub
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 = {

View File

@@ -143,10 +143,24 @@ export type RelatedCase = {
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 & {
full_text: string;
halachot: Halacha[];
related_cases: RelatedCase[];
incoming_citations: IncomingCitation[];
};
export type SearchHit =

View File

@@ -52,10 +52,12 @@ from web.paperclip_client import (
post_comment as pc_post_comment,
reject_interaction as pc_reject_interaction,
reset_agent_session as pc_reset_agent_session,
reset_case_agents as pc_reset_case_agents,
respond_to_interaction as pc_respond_to_interaction,
restore_project as pc_restore_project,
update_project_name as pc_update_project_name,
wake_analyst_for_appraiser_facts as pc_wake_analyst_for_appraiser_facts,
wake_analyst_for_argument_aggregation as pc_wake_analyst_for_argument_aggregation,
wake_ceo_agent as pc_wake_ceo,
wake_ceo_for_feedback_fold as pc_wake_ceo_for_feedback_fold,
wake_curator_for_final as pc_wake_curator_for_final,
@@ -99,6 +101,7 @@ __all__ = [
"pc_wake_curator_for_final",
"pc_wake_for_precedent_extraction",
"pc_wake_analyst_for_appraiser_facts",
"pc_wake_analyst_for_argument_aggregation",
# comments / interactions
"pc_post_comment",
"pc_get_issue_comments",
@@ -112,4 +115,5 @@ __all__ = [
"pc_get_run_events",
"pc_cancel_run",
"pc_reset_agent_session",
"pc_reset_case_agents",
]

View File

@@ -72,9 +72,11 @@ from web.agent_platform_port import (
pc_reject_interaction,
pc_request,
pc_reset_agent_session,
pc_reset_case_agents,
pc_respond_to_interaction,
pc_restore_project,
pc_wake_analyst_for_appraiser_facts,
pc_wake_analyst_for_argument_aggregation,
rename_case_project,
pc_wake_ceo,
pc_wake_ceo_for_feedback_fold,
@@ -2454,41 +2456,78 @@ async def api_get_claims(case_number: str):
# the FastAPI container it short-circuits with status="llm_unavailable".
@app.post("/api/cases/{case_number}/aggregate-arguments")
async def api_aggregate_arguments(
case_number: str,
background_tasks: BackgroundTasks,
force: bool = False,
):
"""Aggregate raw claims into distinct legal arguments via Claude.
async def api_aggregate_arguments(case_number: str, force: bool = False):
"""Queue claim→argument aggregation by waking the legal-analyst agent.
Runs as a BackgroundTask because the LLM pass can take 30-90 seconds.
The aggregation itself calls `claude_session.query_json()`, which shells
out to the local `claude` CLI — present on the agent host, **absent in
this FastAPI container**. Running it inline as a BackgroundTask (the old
behaviour) silently produced nothing, and on `force` it destructively
deleted the existing arguments *before* the doomed LLM call. So we
delegate to the analyst exactly like `extract-appraiser-facts`: create a
child Paperclip issue, assign it to the company's analyst, and trigger a
wakeup. The analyst runs the MCP tool locally and posts results.
Cheap in-container pre-checks (DB only, no LLM) short-circuit before any
agent is spun up:
- `no_claims` — there are no raw claims to aggregate yet.
- `exists` — arguments already computed and `force` is False.
Response shape:
{"status": "queued", "sub_issue_id", "analyst_id", "main_issue_id"}
or {"status": "no_claims"|"exists", "total", "message"}
or {"status": "skipped", "reason": "no_api_key"|"no_analyst"|"no_issue"}
"""
case = await db.get_case_by_number(case_number)
if not case:
raise HTTPException(404, f"תיק {case_number} לא נמצא")
async def _run() -> None:
try:
from legal_mcp.services import argument_aggregator
result = await argument_aggregator.aggregate_claims_to_arguments(
UUID(case["id"]), force=force,
)
logger.info(
"aggregate_arguments[%s] finished: %s",
case_number, result,
)
except Exception as e: # noqa: BLE001
logger.exception(
"aggregate_arguments[%s] failed: %s", case_number, e,
)
case_id = UUID(case["id"])
pool = await db.get_pool()
async with pool.acquire() as conn:
claim_count = await conn.fetchval(
"SELECT COUNT(*) FROM claims WHERE case_id = $1", case_id,
)
existing_args = await conn.fetchval(
"SELECT COUNT(*) FROM legal_arguments WHERE case_id = $1", case_id,
)
background_tasks.add_task(_run)
return {
"status": "started",
"case_number": case_number,
"force": force,
"message": "Aggregation started in background. Poll /legal-arguments for results.",
}
if not claim_count:
return {
"status": "no_claims",
"total": 0,
"message": (
"אין טענות גולמיות בתיק. הרץ קודם חילוץ טענות (extract_claims) "
"ואז חשב טיעונים."
),
}
if existing_args and not force:
return {
"status": "exists",
"total": existing_args,
"message": (
f"כבר קיימים {existing_args} טיעונים מאוגדים. השתמש בכפתור "
"החישוב-מחדש כדי לחשב מחדש (מוחק ובונה מחדש)."
),
}
# Route to the analyst of the correct company by case-number prefix.
prefix = case_number[:1]
company_id = (
PAPERCLIP_COMPANIES["licensing"] if prefix == "1"
else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9")
else ""
)
try:
result = await pc_wake_analyst_for_argument_aggregation(
case_number, company_id=company_id, force=force,
)
except Exception as e:
logger.exception("analyst wakeup failed for argument aggregation %s", case_number)
raise HTTPException(500, f"לא ניתן לשלוח לאנליטיקאי: {e}")
return result
@app.get("/api/cases/{case_number}/legal-arguments")
@@ -3155,6 +3194,62 @@ async def api_precedent_list(case_number: str):
return envelope_unwrap(parsed)
# ── Citation-verification panel (X11 Phase 2 / #154) — "אימות פסיקה" tab ──
@app.get("/api/cases/{case_number}/citation-verification")
async def api_citation_verification(case_number: str):
"""Per legal-argument: supporting corpus precedents (with cited_by authority),
their verify state + chair note, and per-issue radar (unlinked digests). Powers
the compose 'אימות פסיקה' tab — the chair verifies before the writer cites."""
from legal_mcp.services import case_citation_verification as ccv
view = await ccv.build_view(case_number)
if view.get("status") == "case_not_found":
raise HTTPException(404, f"תיק {case_number} לא נמצא")
return view
class CitationVerifyRequest(BaseModel):
argument_id: str
case_law_id: str = ""
quote: str = ""
citation: str = ""
chair_note: str | None = None
verified: bool = True
attached_id: str = "" # set when the precedent row already exists
@app.post("/api/cases/{case_number}/citation-verification/verify")
async def api_citation_verify(case_number: str, req: CitationVerifyRequest):
"""Verify / un-verify a precedent for a specific argument (the INV-AH gate the
writer respects). Upsert: PATCH an existing attachment, else attach a new one
linked to the argument + corpus ruling and mark it."""
case = await db.get_case_by_number(case_number)
if not case:
raise HTTPException(404, f"תיק {case_number} לא נמצא")
try:
if req.attached_id:
row = await db.set_case_precedent_verified(
UUID(req.attached_id), req.verified, req.chair_note)
if not row:
raise HTTPException(404, "שיוך-פסיקה לא נמצא")
return row
if not req.quote.strip() or not req.citation.strip():
raise HTTPException(400, "quote ו-citation חובה לצירוף חדש")
cid = case["id"]
row = await db.create_case_precedent(
case_id=UUID(cid) if isinstance(cid, str) else cid,
quote=req.quote, citation=req.citation,
chair_note=req.chair_note or "",
argument_id=UUID(req.argument_id) if req.argument_id else None,
case_law_id=UUID(req.case_law_id) if req.case_law_id else None,
verified=req.verified,
)
return row
except ValueError:
raise HTTPException(400, "מזהה לא תקין")
@app.delete("/api/precedents/{precedent_id}")
async def api_precedent_delete(precedent_id: str):
"""Delete a precedent attachment. The archived PDF (if any) stays
@@ -4079,6 +4174,19 @@ async def api_post_interaction_response(
raise HTTPException(502, f"שגיאת Paperclip: {e}")
@app.post("/api/cases/{case_number}/agents/reset")
async def api_reset_case_agents(case_number: str):
"""Reset stuck agents for a case.
Clears writer/QA agents from 'error' status and reassigns any open
issues back to the chair user, stopping Paperclip recovery loops.
"""
result = await pc_reset_case_agents(case_number)
if not result.get("ok"):
raise HTTPException(502, result.get("error", "שגיאה בביצוע האיפוס"))
return result
# ── Settings: MCP Server Configuration ────────────────────────────
#
# Source of truth for legal-ai env vars is Coolify (see memory:
@@ -6444,6 +6552,17 @@ class DigestLinkRequest(BaseModel):
case_law_id: str
@app.get("/api/cases/{case_number}/digest-radar")
async def api_case_digest_radar(case_number: str, limit: int = 5, min_score: float = 0.45):
"""Case-contextual digest radar (X12) — UNLINKED digests whose topic is close to
this case (rulings we don't hold yet). Powers the case-page "📡 רדאר יומונים" lead
so a relevant ruling known only via a digest doesn't fall through the cracks while
the case is decided. INV-DIG1: points at the underlying ruling, never cites the
digest."""
return await digest_service.case_digest_radar(
case_number, limit=max(1, min(int(limit), 20)), min_score=float(min_score))
@app.post("/api/digests/upload")
async def digest_upload(
file: UploadFile = File(...),
@@ -7141,7 +7260,7 @@ async def internal_decisions_upload(
practice_area: str = Form(""),
appeal_subtype: 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(""),
):
"""Upload a planning appeals-committee decision to the internal corpus.
@@ -7877,7 +7996,7 @@ async def missing_precedents_list(
case_id=case_uuid,
legal_topic=legal_topic.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)),
)
# Counters useful for the sidebar badge.

View File

@@ -791,6 +791,71 @@ async def reset_agent_session(agent_id: str) -> dict:
return resp.json()
CHAIM_USER_ID = "ZpDWXxFweC3MftuF1Ttyu2VUbSPHbKZd"
async def reset_case_agents(case_number: str) -> dict:
"""Reset agent state for a case: clear error status + reassign stuck issues to user.
Two actions:
1. Any non-completed issue still assigned to an agent is reassigned to the chair user,
stopping Paperclip's source_scoped_recovery_action loop.
2. Every agent in the case's company whose global status is 'error' gets a
reset_agent_session call (clears wedged runtime) and its DB status set to 'idle'.
"""
first_digit = case_number.split("-")[0][0] if case_number else "1"
company_id = COMPANIES["betterment"] if first_digit in ("8", "9") else COMPANIES["licensing"]
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
project = await conn.fetchrow(
"SELECT id FROM projects WHERE name LIKE $1 LIMIT 1",
f"%{case_number}%",
)
if not project:
return {"ok": False, "error": f"No Paperclip project found for {case_number}"}
project_id = project["id"]
reassigned = await conn.fetch(
"""UPDATE issues
SET assignee_agent_id = null, assignee_user_id = $1, updated_at = now()
WHERE project_id = $2
AND assignee_agent_id IS NOT NULL
AND status NOT IN ('done', 'cancelled')
RETURNING id, identifier""",
CHAIM_USER_ID, project_id,
)
error_agents = await conn.fetch(
"SELECT id, name FROM agents WHERE company_id = $1::uuid AND status = 'error'",
company_id,
)
if error_agents:
await conn.execute(
"UPDATE agents SET status = 'idle' WHERE id = ANY($1::uuid[])",
[r["id"] for r in error_agents],
)
finally:
await conn.close()
reset_results = []
for agent in error_agents:
try:
await reset_agent_session(str(agent["id"]))
reset_results.append({"id": str(agent["id"]), "name": agent["name"], "ok": True})
except Exception as e:
logger.warning("reset_agent_session failed for %s: %s", agent["id"], e)
reset_results.append({"id": str(agent["id"]), "name": agent["name"], "ok": False, "error": str(e)})
return {
"ok": True,
"reassigned_issues": [{"id": str(r["id"]), "identifier": r["identifier"]} for r in reassigned],
"reset_agents": reset_results,
}
async def respond_to_interaction(
issue_id: str, interaction_id: str, payload: dict,
) -> dict:
@@ -1371,3 +1436,121 @@ async def wake_analyst_for_appraiser_facts(
"analyst_id": analyst_id,
"main_issue_id": main_issue_id,
}
async def wake_analyst_for_argument_aggregation(
case_number: str,
company_id: str,
force: bool = False,
) -> dict:
"""Wake the legal-analyst to aggregate raw claims into legal arguments.
Triggered by the chair clicking "חשב טיעונים" in the case page. The
FastAPI container cannot run `aggregate_claims_to_arguments` directly —
the aggregator calls `claude_session.query_json()`, which only works
where the local `claude` CLI is present (the MCP server / agent runner
on the host), **not in this container**. So instead of an in-container
BackgroundTask (which fails silently and, on `force`, destructively
deletes the existing arguments before the doomed LLM call), we create a
child issue under the case's main Paperclip issue, assign it to the
analyst of the correct company, and trigger a wakeup. The analyst's
HEARTBEAT picks up the issue, runs the MCP tool locally, and reports
back via a comment.
Mirrors ``wake_analyst_for_appraiser_facts`` — same delegation shape.
Returns a dict shaped for the FastAPI endpoint to serialize as-is:
{"status": "queued", "sub_issue_id", "analyst_id", "main_issue_id"}
or {"status": "skipped", "reason": "..."} for non-fatal early outs.
"""
if not PAPERCLIP_BOARD_API_KEY:
logger.warning(
"PAPERCLIP_BOARD_API_KEY not set — cannot queue analyst wakeup "
"for argument aggregation on %s",
case_number,
)
return {"status": "skipped", "reason": "no_api_key"}
analyst_id = ANALYST_AGENTS.get(company_id)
if not analyst_id:
logger.info("No analyst configured for company %s — skipping", company_id)
return {"status": "skipped", "reason": "no_analyst", "company_id": company_id}
issues = await get_case_issues(case_number)
if not issues:
logger.warning(
"No Paperclip issues found for case %s — cannot queue analyst", case_number,
)
return {"status": "skipped", "reason": "no_issue"}
main_issue = next((i for i in issues if i.get("status") == "in_progress"), None) or issues[0]
main_issue_id = main_issue["id"]
force_clause = ", force=True" if force else ""
rerun_note = (
"זהו חישוב-מחדש (force) — הטיעונים הקיימים יימחקו ויחושבו מחדש.\n\n"
if force else ""
)
description = (
f"חיים ביקש חישוב טיעונים משפטיים בתיק {case_number}.\n\n"
f"{rerun_note}"
f"הרץ `mcp__legal-ai__aggregate_claims_to_arguments(case_number=\"{case_number}\"{force_clause})` "
f"וכתוב comment בעברית עם תוצאת האיגוד — כמה טיעונים מובחנים נוצרו לכל צד "
f"(עוררים / משיבים / ועדה / מבקשי-היתר). אם אין טענות גולמיות בתיק, דווח "
f"ב-comment שצריך להריץ קודם חילוץ טענות (`extract_claims`) וסגור את ה-issue כ-blocked."
)
child_resp = await pc_request(
"POST",
f"/api/issues/{main_issue_id}/children",
json={
"title": f"[ערר {case_number}] חישוב טיעונים משפטיים",
"description": description,
"status": "in_progress",
# Paperclip ISSUE_PRIORITIES = critical|high|medium|low — "normal"
# is NOT a valid enum value (Zod 400 → surfaces as 500).
"priority": "medium",
"assigneeAgentId": analyst_id,
},
raise_on_error=True,
)
sub_issue = child_resp.json()
sub_issue_id = sub_issue["id"]
# Tag plugin_state so the case page surfaces this sub-issue too.
try:
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
await _link_case_to_issue(conn, sub_issue_id, case_number)
finally:
await conn.close()
except Exception as e:
logger.warning("plugin_state link failed for sub_issue=%s: %s", sub_issue_id, e)
wake_resp = await pc_request(
"POST",
f"/api/agents/{analyst_id}/wakeup",
json={
"source": "on_demand",
"triggerDetail": "manual",
"reason": f"aggregate_arguments_{case_number}",
# "assignment" is the generic mutation the HEARTBEAT recognises;
# task intent lives in the child-issue description, not the payload.
"payload": {
"issueId": sub_issue_id,
"mutation": "assignment",
"caseNumber": case_number,
},
},
raise_on_error=True,
)
logger.info(
"Analyst wakeup for argument aggregation on case %s: sub_issue=%s "
"analyst=%s force=%s wake=%s",
case_number, sub_issue_id, analyst_id, force, wake_resp.status_code,
)
return {
"status": "queued",
"sub_issue_id": sub_issue_id,
"analyst_id": analyst_id,
"main_issue_id": main_issue_id,
}