234 Commits

Author SHA1 Message Date
e4651a9d06 feat(#99 / T10): get_style_guide — יחסי-זהב נמדדים מהקורפוס לצד היעד
style_distance.measure_corpus_ratios(): מפצל כל החלטה ב-style_corpus לסעיפים
(chunker) ומחשב ממוצע %-סעיף — אגרגט "_all" + פר-תוצאה (כשיש). cached.
get_style_guide מציג שורת "נמדד בפועל" עם ⚠️ על פער מטווח-היעד.

מצב נוכחי: style_corpus.outcome לא מאוכלס → מוצג אגרגט כל-ההחלטות (n=48:
רקע 26.4% / טענות 9.7% / דיון 43.8% / סיכום 20.1%); פיצול לפי-תוצאה future-ready.
המדידה גם מאירה מגבלות זיהוי-סעיפים (כוונת T10 — לסמן פער לבדיקה). חופף-חלקית
ל-T7 שמודד adherence per-draft; זה מודד את הקורפוס. כשל מדידה מוצג, לא נבלע.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:01:42 +00:00
a571ad535b fix(#88+#87): סנכרון DB↔file אוטומטי + claims_coverage מבחין כתב-ערר מתכתובת
#88 (DB↔file, lessons #35): drafts/decision.md דרסה את עצמה רק ב-save_block_content;
renumber_all_blocks + נתיבי store_block אחרים השאירו את הקובץ stale → QA נכשל
פעמיים על אותה בעיה (CMPA-62). תיקון: _update_draft_file הפך ל-hook אוטומטי
(מקבל decision_id, מאתר case פנימית) שנקרא מ-store_block (כל persist) ומ-
renumber_all_blocks. legal-qa ממילא קורא מ-DB → שני הצדדים זהים תמיד.

#87 (claims_coverage, 1033-25): טענות מתכתובת (claim_type='reply' — תגובה/
השלמת-טיעון) סומנו "לא נענו" כ-false-positive. תיקון: check_claims_coverage
דורש מענה רק לטענות כתב-הערר (claim_type='claim', appellant); reply/תכתובת
מוחרגות. בקבלה מלאה הסף מוקל (0.2→0.4) כי העורר זכה במלואו.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:54:31 +00:00
afc1548bca chore(style-acq T11): regen API types (learning + methodology endpoints)
npm run api:types — מסנכרן types.ts המחולל עם ה-endpoints החדשים
(/api/learning/pairs, style-distance, promote). הקוד משתמש בטיפוסים ידניים
(learning.ts) אז זה היגיינה לעתיד, לא תלות. סוגר את T11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:44:41 +00:00
e096c51037 fix(#85): claude_session — retry על כשלים חולפים של claude -p
שורש #85 התברר: `claude -p` נכשל מדי פעם ב-exit מהיר + stderr ריק על
פרומפטים גדולים/איטיים (CEO write_interim_draft, learning_loop distillation),
**אותו פרומפט מצליח בריצה חוזרת** — כשל חולף, לא nesting (אומת: nested claude
מ-bash וגם פרומפט 70K הצליחו; הכשל אינו דטרמיניסטי).

query() עוטף spawn+communicate ב-לולאת retry (MAX_RETRIES=3, backoff לינארי
5s*attempt). FileNotFoundError + timeout נשארים דטרמיניסטיים (ללא retry).
empty-response גם מטופל כ-transient.

אומת e2e: distillation על 1130-25 רץ בהצלחה → pair=analyzed (9 שינויים,
6 style_method, 33.8% diff). פותר גם את write_interim_draft של ה-CEO.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:08:54 +00:00
85c5a4aacb Merge pull request 'feat(halacha-triage): quality-gated + prioritized review queue + metrics (#84)' (#93) from worktree-task84-halacha-triage into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m25s
2026-06-06 20:01:27 +00:00
420cb819f5 feat(halacha-triage): quality-gated + prioritized review queue + metrics (#84)
Backend for the halacha approval-queue triage (#84). The keyboard UI, batch
actions and defer/reject (#84.4–6) already shipped; this adds the gating,
prioritization and metrics the queue was missing.

db.list_halachot — two opt-in triage controls:
  * exclude_low_quality (#84.1): drop items carrying ANY quality_flag
    (application / quote_unverified / truncated / non_decision / thin /
    nli_unsupported / near_duplicate) — they belong in a 'needs extraction fix'
    bucket, not the chair's approve queue.
  * order_by_priority (#84.3): active-learning order — negatively-treated
    first, then most-uncertain (lowest confidence), then oldest — instead of
    FIFO, so the highest-value decisions surface first.

halachot_pending (MCP) — now gated + prioritized BY DEFAULT; include_low_quality=
true reveals the needs-fix bucket. The agent review path benefits immediately.

GET /api/halachot — same two params, default OFF (non-breaking; the UI opts in).

metrics.halacha_backlog (#84.7) — splits pending into clean vs flagged, adds
deferred, reviewed_total, approve_ratio, and a pending_by_flag breakdown, so the
backlog distinguishes real review work from extraction noise.

Deferred (documented): #84.2 near-duplicate cluster cards and wiring the UI
fetch to the new params require frontend work + an api:types regen AFTER this
deploys (the new query params aren't in prod's OpenAPI until then) — a clean
follow-up. The backend fully supports both now.

Verified against the live DB (read-only):
- pending 177 → gated-clean 110, 0 flagged items leak into the clean queue.
- priority order surfaces the lowest-confidence items first (0.55, 0.55, ...).
- backlog: pending_clean=110 / pending_flagged=67 / approve_ratio=0.916,
  pending_by_flag={nli_unsupported:59, quote_unverified:3, thin:3, truncated:2}.
- pytest tests/test_halacha_quality.py — 52 passed (no regression).

Invariants: G1 (gate at source — SQL filter, not post-hoc); G2 (no parallel
path — same list_halachot); §6 (flagged items routed to a bucket, never dropped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:00:52 +00:00
32ef259843 Merge pull request 'feat(halacha): application gate + lexical dedup tail + quality harnesses (#81,#82)' (#92) from worktree-task81-82-halacha-engine into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m25s
2026-06-06 19:56:22 +00:00
1286a1e60d feat(halacha): application gate + lexical dedup tail + quality harnesses (#81,#82)
Halacha-extraction quality (#81) and dedup-on-insert (#82) — engine changes
(pure + tested) plus measurement/ops tooling.

halacha_quality.py
- #81.4 application gate: is_fact_dependent() (high-precision "applied to THIS
  case" deixis per the strict rubric §3/§27) + FLAG_APPLICATION. compute_quality_flags
  now takes rule_type and flags rule_type=='application' OR fact-dependent —
  blocking auto-approve (an illustration is not a generalizable holding).
- #82.3 lexical tail signal: jaccard_shingles / normalized_levenshtein /
  lexical_near_duplicate + FLAG_NEAR_DUPLICATE, for the 0.83–0.93 cosine band.

halacha_extractor.py — pass rule_type to the flag computation; re-type a
binding-labeled fact-application to 'application' (mirrors non_decision→obiter).

db.py (store_halachot_for_chunk) — dedup now fetches the nearest same-precedent
neighbor once: cosine ≥ DEDUP → skip (unchanged); cosine in [BAND, DEDUP) with
high lexical overlap → FLAG_NEAR_DUPLICATE (review, not skip — never drop a
possibly-distinct principle unreviewed).

config.py — HALACHA_DEDUP_BAND_COSINE (0.83).

Scripts:
- scripts/halacha_goldset.py (#81.7) — export stratified sample for human
  tagging; score validators (P/R/F1) against the tags. Backbone for #81.8.
- scripts/halacha_batch_reconcile.py (#82.7) — conservative cross-precedent
  dedup (cosine ≥0.95), dry-run report only.
- scripts/calibrate_halacha_dedup.py (#82.1) — calibrate the lexical thresholds
  against the 2026-06-03 cleanup gold-set.

Deferred (documented): #82.4 merge-provenance and #82.5 DB ON CONFLICT/UNIQUE
on normalized quote are NOT included — the current skip+flag behavior is safe,
whereas a UNIQUE on normalized_quote would fail on existing dups and a blind
merge risks losing provenance; they need their own chair-reviewed migration.
#82.6 over-merge guard is moot until merge lands. #81.6 full rhetorical-role
classifier deferred (section pre-filter + application flag cover the practical
case); #81.8 blocked on the human-tagged gold-set (harness now provided).

Verified:
- pytest tests/test_halacha_quality.py — 52 passed (14 new).
- calibrate: configured (0.55,0.70) → precision 1.0 (zero false-merge), recall
  0.30 — correct profile for an auto-approve-blocking signal.
- goldset export: 15-row sample CSV. batch reconcile: 819 halachot → 5
  cross-precedent candidate pairs.

Invariants: G1 (normalize at source — flag at insert, not at read); §6 (no
silent swallow — suspect items flagged to review, never dropped); G2 (no
parallel path — same store_halachot_for_chunk / compute_quality_flags).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:55:45 +00:00
366d89e6bb Merge pull request 'feat(nevo): backfill leaked preamble + ratio gold-set benchmark (#86)' (#91) from worktree-task86-nevo-backfill-benchmark into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m25s
2026-06-06 19:46:25 +00:00
fb51a0e869 feat(nevo): backfill leaked preamble + ratio gold-set benchmark (#86)
#86.2 backfill + #86.3 benchmark, plus a #86.1 over-strip fix found en route.

extractor.py
- extract_nevo_ratio(): capture Nevo's מיני-רציו block (editorial holdings
  summary) before it is stripped — a free professional gold-set (#86.3).
- _DECISION_START hardening (#86.2): the merged #86.1 regex over-stripped.
  (a) פסק-דין headers are markdown-wrapped (**פסק  דין**); the old anchor
      required the keyword as the first line char with one separator, so it
      missed the header and matched a citation 32K deep (עמ"נ 50567-07-21,
      losing 45% of the body). Now tolerates leading markdown + 0-3 seps,
      and the final-nun form (דין ן vs דינו נ).
  (b) bare השופט/הנשיא matched CITATIONS ("השופט מ' חשין, פסקה 23"). The
      authoring-judge line ends with a colon; we now require it.

ingest.py
- capture the ratio before stripping and store it on the row (best-effort,
  non-fatal); also strip the text-upload path (was file-only).

db.py
- add case_law.nevo_ratio column (additive); allow it in update_case_law.

scripts/backfill_nevo_preamble.py (#86.2) — dry-run-by-default data migration:
finds historically-leaked rulings, captures ratio→nevo_ratio, rewrites
full_text (+content_hash), reindexes, and FLAGS (never deletes) halachot whose
quote lives in the removed preamble (review_status=pending_review +
nevo_preamble_leak flag). Safety guard: rows with keep%<--min-keep (60) are
excluded from --apply as suspected over-strip. --apply writes backup+manifest
to data/audit/ first. Chair-gated — NOT applied here.

scripts/nevo_ratio_benchmark.py (#86.3) — LLM-as-judge (local claude_session,
zero cost) measures recall/precision/granularity of our halachot vs the Nevo
ratio. Works pre- and post-backfill (reads nevo_ratio, falls back to full_text).

Verified:
- pytest tests/test_nevo_preamble.py — 12 passed (incl. citation/markdown
  over-strip regressions).
- backfill dry-run: 19 leaked rulings, 27 contaminated halachot, all ≥75%
  keep (the 32K over-strip is gone).
- benchmark on בג"ץ 1764/05: recall=0.875 precision=1.0 granularity=1.75x.

Invariants: G1 (normalize at source — strip/capture at ingest, not at read);
no silent swallow (contaminated halachot flagged + reported, not dropped);
data-migration is dry-run-default with backup+manifest, chair-gated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:45:43 +00:00
12bdec10fa Merge pull request 'fix(claude_session): surface real CLI error + sanitize nested env (#85)' (#90) from worktree-task85-claude-session-nested into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
2026-06-06 19:30:22 +00:00
8ec24cf822 fix(claude_session): surface real CLI error + sanitize nested env (#85)
write_interim_draft failed for all blocks from the CEO MCP instance with
"Claude CLI failed (exit 1): unknown error". Two fixes:

1. Error surfacing (the certain win): on non-zero exit, capture and log
   both stderr AND stdout (the CLI sometimes writes its diagnostic to
   stdout or nowhere), so the next occurrence is diagnosable instead of
   collapsing to "unknown error". This is why #85 was unsolved — the real
   error was swallowed (engineering rule §6: no silent swallow).

2. Defensive hardening: strip Claude Code session markers (CLAUDECODE,
   CLAUDE_CODE_*, CLAUDE_AGENT_*, AI_AGENT, CLAUDE_EFFORT) from the env of
   nested `claude -p` calls and run them from $HOME, decoupling them from
   the parent agent's session/project state. Aligns query() with the
   existing query_streaming() path (which already sets cwd=HOME). Auth/
   config vars are preserved.

Note: the original adapter-context failure could not be reproduced in a
plain interactive session (nested claude -p succeeds there in both old and
new code), so the env markers are a suspect, not a proven cause. The real
value is the diagnostics. Verified: nested query() returns PONG from
inside a CLAUDECODE=1 session; unit tests cover env sanitization.

Invariants: G1 (normalize at source — fix the spawn, not readers),
G2 (no parallel path — same query()), §6 (no silent error swallow).
INV: feedback_claude_session_local_only preserved (all calls stay local).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:29:36 +00:00
3b9f77daa8 Merge pull request 'feat(style-acq T8): analyze_corpus — הסרת LIMIT 20 (כיסוי מלא)' (#89) from worktree-style-acquisition-mvp into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m24s
2026-06-06 19:25:40 +00:00
5fa76a09b4 feat(style-acq T8): analyze_corpus — הסרת LIMIT 20 (כיסוי 48/48)
LIMIT 20 קבוע השמיט בשקט שליש מקורפוס דפנה מחילוץ author-features שהפרופיל
של הכותב (T0) נסמך עליו. עכשיו limit=0 (ברירת-מחדל) = כל הקורפוס; פרמטר
lim>0 אופציונלי לתקרה.

G11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:25:13 +00:00
32a6e2b57b Merge pull request 'fix(style-acq T9): מספור-אוטומטי אמיתי בייצוא DOCX' (#88) from worktree-style-acquisition-mvp into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s
2026-06-06 19:24:02 +00:00
3c68383e86 fix(style-acq T9): מספור-אוטומטי אמיתי בייצוא DOCX (היה ללא מספור)
באג: ה-exporter הסיר את הקידומת "N." והחיל סגנון "List Paragraph" — שאין לו
numPr בתבנית (אין numbering.xml) → ההחלטות יצאו **ללא מספור** כלל.

- docx_exporter._ensure_decision_numbering: מזריק abstractNum עשרוני (RTL,
  lvlJc=right) + num לחלק-המספור פעם אחת; _apply_list_numbering מחבר כל
  פסקת-גוף לרשימה הרציפה. מספור Word אמיתי — מתעדכן בעריכה, copy/paste נקי.
  אומת מבנית: numId יחיד, decimal, שתי פסקאות→אותו numId, docx נשמר.
- התאמת ANTI_PATTERNS (T7): הוסר manual_paragraph_numbers — "N." בתחילת-שורה
  הוא ה-signal הנדרש לייצוא, לא אנטי-דפוס. נשאר inline (1)..(2)/markdown/bullets.
- voice-fingerprint §3.1: תוקן — הכותב כן מקדים "N. " בתחילת-שורה (signal),
  הייצוא ממיר ל-auto-numbering. סתירה קודמת ("אל תקליד מספרים") יושבה.

⚠️ אימות-מבנה עבר; אימות ויזואלי ב-Word מומלץ על ייצוא ראשון. G11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:23:29 +00:00
37c00bac13 Merge pull request 'feat(style-acq T14): שער-יו"ר לאישור הצעות-curator → הטמעה לפרופיל' (#87) from worktree-style-acquisition-mvp into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m42s
2026-06-06 19:18:13 +00:00
f20a3a09fd feat(style-acq T14): שער-יו"ר לאישור הצעות-curator → הטמעה לפרופיל
סוגר את הלולאה מקצה-לקצה (INV-G10/LRN1): ה-curator מציע (status=analyzed),
היו"ר מאשרת, והלקחים נכתבים לערוצים שהכותב צורך (T15) — אין auto-commit.

- db.get_draft_final_pair(id) — שורת-פנקס מלאה כולל analysis.
- app.py: GET /api/learning/pairs/{id} (חושף רק changes מסוג style_method —
  INV-LRN5) + POST .../promote (לקחים→discussion_rules['universal'],
  ביטויים→transition_phrases['universal'] דרך merge ל-appeal_type_rules;
  status→lessons_folded). _append_methodology_override משותף.
- web-ui: usePairDetail/usePromoteLearning + ProposalReview (בחירת לקחים/
  ביטויים לאימוץ) בטאב "למידה" עבור pairs במצב analyzed.

INV-G10 (שער-יו"ר) · INV-LRN1 (אין auto-commit) · INV-LRN5 (טוהר).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:17:56 +00:00
6313fcd316 Merge pull request 'feat(style-acq T6+T13): פנקס-התאמה + מדד מרחק-סגנון ב-UI' (#86) from worktree-style-acquisition-mvp into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 38s
2026-06-06 19:13:32 +00:00
ee76455a9a feat(style-acq T6+T13): פנקס-התאמה + מדד מרחק-סגנון ב-UI
ה"איך מנהלים/רואים את הלמידה": טאב "למידה" ב-/training.

- app.py: GET /api/learning/pairs (פנקס-ההתאמה — כל ההחלטות + סטטוס draft↔final,
  INV-LRN4) + GET /api/learning/style-distance/{case} (מדד T7).
- web-ui: learning.ts hooks + LearningPanel (טבלת פנקס; לחיצה על תיק →
  מדד מרחק-הסגנון: שינוי draft→final, סטיית יחסי-זהב, אנטי-דפוסים) + טאב ב-/training.

מכסה גם את T6 (רשימת כל ההחלטות הנסגרות מול הסופי). ללא endpoint-schema חדש
לטיפוסים מחוללים (טיפוסים ידניים). G9, INV-LRN4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:13:10 +00:00
7b1c0c1a32 Merge pull request 'feat(style-acq T12): /methodology — ביטויי-מעבר + אנטי-דפוסים editable' (#85) from worktree-style-acquisition-mvp into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m25s
2026-06-06 19:09:15 +00:00
e4fbda6c1f feat(style-acq T12): /methodology — קטגוריות ביטויי-מעבר + אנטי-דפוסים
מרחיב את עורך-הפרופיל ב-/methodology עם 2 קטגוריות נוספות שהכותב (T15)
והמדד (T7) צורכים — כך שהיו"ר עורכת אותן והעריכה זורמת לכתיבה:

- app.py: _METHODOLOGY_DEFAULTS += transition_phrases (מקובץ לפי תוצאה) +
  anti_patterns (מ-lessons.ANTI_PATTERNS). דרך ה-CRUD הגנרי הקיים (appeal_type_rules).
- block_writer (T15 loop): קורא overrides גם ל-transition_phrases + anti_patterns.
- web-ui: GenericMethodologyPanel (עורך key→JSON) + 2 טאבים ב-/methodology.

voice_invariants (doc) — נדחה (לא key-value). G11, INV-LRN4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:08:44 +00:00
3b3e1e3bbf Merge pull request 'docs: FU-14 GAP-54 — סגירה כ-resolved-by-FU-1 (קליטת-פסיקה כבר מאוחדת)' (#84) from docs/gap54-closure into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
2026-06-06 19:03:14 +00:00
37dcb30604 docs: FU-14 GAP-54 — סגירה כ-resolved-by-FU-1 (איחוד קליטת-פסיקה)
אימות (G2 — לא לפתור מחדש): קליטת-הפסיקה כבר מאוחדת ע"י FU-1. שני מסלולי-
הפסיקה (precedent_library + internal_decisions) עוברים דרך
ingest.ingest_document הקנוני עם ולידציית-enums + citation-guard סימטריים
(מתועד ב-01-ingest §4). המסלול ה-3 (training→style_corpus) הוא קורפוס נפרד
במכוון. מאומת ב-test_unified_ingest (9/9). אין קוד — רק תיעוד סגירה.

Invariants: מאשר INV-ING1 + G2 מקוימים. doc-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:02:55 +00:00
dc0936adf9 docs: remove n8n from Nautilus services table
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 25s
n8n was unused and fully removed (Coolify service + containers + volumes
deleted 2026-06-06), so drop its row from the services table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:58:47 +00:00
0059c326f1 Merge pull request 'feat(mcp): FU-14 GAP-50 — deprecate draft_section לטובת get_block_context' (#83) from fix/fu14-gap50-draft-section into main
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
2026-06-06 18:57:24 +00:00
a2236363d4 feat(mcp): FU-14 GAP-50 — deprecate draft_section לטובת get_block_context
INV-TOOL2. מיפוי הראה ש-6 כלי-הבלוק אינם כפילות מיותרת: write_block/
write_all_blocks/save_block_content/write_interim_draft משרתים זרימות שונות
(CLI/initial-draft מול תהליך-ה-writer "התיקון בקובץ, לא ב-DB"). הכפילות
האמיתית היחידה — draft_section (הקשר לפי-סעיף, granularity ישן) חופף ל-
get_block_context (לפי-בלוק, תואם 12-הבלוקים הקנוני).

הכרעת-יו"ר: draft_section סומן deprecated (docstring ב-server.py + drafting.py
מפנה ל-get_block_context; draft-decision.md עודכן). ללא הסרה, ללא מיזוג כלי-
הכתיבה — שמירת תהליך-הכתיבה המכוון.

בדיקות: 182/182 עוברים. GAP-49+50 סגורים.

Invariants: מקדם INV-TOOL2 + G2. מתועד ב-X9 (נסגר) + gap-audit פרוסה 9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:57:02 +00:00
b515f3453e Merge pull request 'feat(mcp): FU-14 GAP-49 — תיקון שם-הכלי המטעה precedent_search_library' (#82) from fix/fu14-gap49-search-naming into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m26s
2026-06-06 18:51:40 +00:00
14568fdd15 feat(mcp): FU-14 GAP-49 — תיקון שם-הכלי המטעה (precedent_search_library)
INV-TOOL2: `precedent_search_library` (שמחפש ציטוטים מצורפים-לתיק) היה הפוך
וכמעט-זהה ל-`search_precedent_library` (ספריית-הפסיקה הסמכותית, מקור CREAC),
מה שסיכן ציטוט מהמקור הלא-נכון בהחלטה. שונה ל-`search_case_precedents` (שם
ברור: case-attached). השם הישן נשמר כ-@mcp.tool() alias deprecated המנתב לחדש
→ אפס שבירה לסוכנים חיים.

docstrings של שני כלי-הפסיקה הובהרו (case-attached מול authoritative).
עודכנו: web/app.py (typeahead), legal-researcher/legal-writer docs, precedent_library docstring.

5 כלי-החיפוש הנותרים (search_decisions/case_documents/find_similar/internal/
precedent_library) מחפשים קורפוסים מובחנים בשמות סבירים — לא בוצע rename המוני
(churn גבוה, ערך נמוך מול הסיכון).

בדיקות: 182/182 עוברים. אחרי deploy — סנכרון cross-company של doc-הסוכן.

Invariants: מקדם INV-TOOL2 + G2. מתועד ב-X9 + gap-audit פרוסה 8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:51:17 +00:00
172511339f Merge pull request 'fix(style-acq T1): insert_style_exemplar — vector כ-list (register_vector)' (#81) from worktree-style-acquisition-mvp into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m36s
2026-06-06 18:15:31 +00:00
ad4350029a fix(style-acq T1): insert_style_exemplar — vector כ-list לא str (register_vector)
asyncpg עם pgvector register_vector מקבל את ה-embedding כ-list[float] ישירות;
str() גרם ל-DataError. תוקן בהתאם לדפוס store_*_image_embeddings.
Backfill הורץ בהצלחה: 2670 דוגמאות מ-83 החלטות.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:14:56 +00:00
424dc7cd18 Merge pull request 'feat(style-acq T1-T3): קורפוס-דוגמאות של דפנה לכותב (style_exemplars)' (#80) from worktree-style-acquisition-mvp into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m41s
2026-06-06 18:10:31 +00:00
2e20e27e17 feat(style-acq T1-T3): קורפוס-דוגמאות של דפנה לכותב (style_exemplars)
ממלא את ערוץ-הדוגמאות (B) של מערכת רכישת-הסגנון: הכותב מאחזר פסקאות-בלוק
אמיתיות של דפנה בזמן כתיבה, ממוקדות section+outcome+practice_area.

T1 — תשתית + backfill:
- SCHEMA_V27: טבלת style_exemplars (purpose-built — בלי תיקים מזויפים בשרשרת
  decision_paragraphs). decision_number/source/section/outcome/practice_area+embedding.
- db: insert/delete/search_style_exemplars + count_style_exemplars.
- scripts/backfill_style_exemplars.py: מפצל קורפוס דפנה (style_corpus +
  internal_committee) לסעיפים→פסקאות, embed, שמירה. אידמפוטנטי, dry-run/apply.

T2 — אחזור ממוקד:
- search_style_exemplars(section, outcome, practice_area) — section=hard filter,
  outcome/practice_area=soft. block_writer._build_precedents_context ממפה
  block→section ומאחזר (ראשי), לצד הנתיב הישן (משלים).

T3 — contrastive/adapt:
- הדוגמאות מתויגות "מבנה/קול בלבד — התאם, אל תעתיק תוכן"; פסקה מלאה (1100 תווים).

INV-LRN5 (טוהר — סגנון בלבד). G11. הרצת backfill --apply בנפרד.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:10:01 +00:00
ea84a602e6 Merge pull request 'feat(mcp): FU-14 GAP-48 פרוסה 3 — envelope ל-drafting (סגירת GAP-48)' (#79) from fix/fu14-gap48-drafting into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m42s
2026-06-06 17:52:21 +00:00
29af008271 feat(mcp): FU-14 GAP-48 פרוסה 3 — envelope למשפחת drafting (סגירת GAP-48)
הפרוסה האחרונה של GAP-48 (INV-TOOL1). 18 כלי drafting הומרו ל-{status,data,message}
דרך tools/envelope.py — כולל מסלול הפקת-ההחלטה הקריטי.

עיקרון לכלים עם כשל משמעותי (export_docx/revise_draft/apply_user_edit): err()
ברמת-המעטפת — כך שהסוכן והמשתמש רואים את הכשל; failed_gates רוכב ב-data.
שאר הכלים: ok(data=payload) להצלחה, err להיעדר-תיק/קלט-שגוי/חריגה.

6 צרכני-app.py חוּוטו (get_decision_template, apply_user_edit ×2, revise_draft,
list_bookmarks, export_docx) עם envelope_unwrap + בדיקת status=="error"→4xx,
לשמירת חוזה-ה-API (X6) ללא-שינוי. test_export_qa_gate עודכן לחוזה החדש.

בדיקות: 182/182 עוברים (כולל שערי-QA של הייצוא).

GAP-48 סגור: כל ~12 משפחות-הכלים אחידות. נותר ב-FU-14: GAP-49/50 (שובר), GAP-54.

Invariants: משלים INV-TOOL1 + G2. מתועד ב-X9 (נסגר) + gap-audit פרוסה 7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:51:56 +00:00
0a514cc276 Merge pull request 'docs: תיקון שורת חריג-Paperclip — הסוכנים אינם מבודדים (אומת מול האדפטר הרשמי)' (#78) from fix/paperclip-isolation-doc-correction into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 15s
2026-06-06 17:44:48 +00:00
cde7f94628 docs: תיקון שורת חריג-Paperclip — הסוכנים אינם מבודדים (אומת מול האדפטר הרשמי)
PR #73 כתב שבידוד סוכני Paperclip "נאכף ברמת runtime" — אומת (2026-06-06)
שזה לא נכון: 14/16 הסוכנים על claude_local הרשמי שמריץ claude -p ב-cwd משותף,
ואין לו worktreeMode/-w (קיים רק ב-fork ה-deepseek). מתקן לתיאור מדויק +
הפניה ל-TaskMaster #104 (נסגר cancelled: "לתעד, לא לבדד").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:44:27 +00:00
9a3e7faf08 Merge pull request 'feat(mcp): FU-14 GAP-48 פרוסה 2 — envelope אחיד ל-11 משפחות-כלים' (#77) from fix/fu14-gap48-envelope-rest into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m5s
2026-06-06 17:42:00 +00:00
79b9c37301 feat(mcp): FU-14 GAP-48 פרוסה 2 — envelope אחיד ל-11 משפחות-כלים
המשך מיגרציית INV-TOOL1 מעבר למשפחת-החיפוש (#71). הומרו ל-{status,data,message}:
precedent_library, citations, internal_decisions, missing_precedents,
training_enrichment, precedents, legal_arguments, cases, documents, workflow
(~55 כלים). בוטלו 5 עותקי _ok/_err משוכפלים (alias ל-tools/envelope.py — SSoT, G2).

עיקרון: envelope-status = הצלחת-הקריאה-לכלי; תוצאה-עסקית (idempotent_existing,
noop, completed...) נשמרת בתוך data. err רק לכשל אמיתי (not-found/invalid/exception).

תאימות-API: צרכני web/app.py של cases/workflow/precedents חוּוטו דרך
envelope_unwrap + בדיקת status=="error"→4xx — תשובת ה-HTTP זהה, web-ui לא מושפע.
(documents/legal_arguments/citations/... אינם נצרכים מ-app.py — agent-only.)

בדיקות: 182/182 עוברים (test_corpus_constraints עודכן לחוזה החדש).
נותר: משפחת drafting (מסלול הפקת-ההחלטה) בפרוסה נפרדת עם שער טסט-ייצוא.

Invariants: מקדם INV-TOOL1 + G2 (SSoT, ביטול כפילות). מתועד ב-X9 + gap-audit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:41:39 +00:00
dd46ffb3e3 Merge pull request 'feat(style-acq T7): מדד מרחק-סגנון — סוגר את ה-MVP' (#76) from worktree-style-acquisition-mvp into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 6m34s
2026-06-06 17:33:50 +00:00
a3451775fa feat(style-acq T7): מדד מרחק-סגנון — האם הטיוטות מתכנסות לדפנה
סוגר את ה-MVP (T0+T4+T5+T7): מטא-אות על בריאות-הלמידה (INV-LRN4),
דטרמיניסטי וללא LLM.

- lessons.ANTI_PATTERNS — אנטי-דפוסים נמדדים (מ-voice-fingerprint §3 המתוקן):
  מספרים-ידניים, רשימת-מיני (1)..(2), כותרות markdown, תבליטים.
- services/style_distance.py — 3 רכיבים: golden_ratio_adherence (סטיית
  אחוזי-סעיפים מ-GOLDEN_RATIOS), anti_pattern_hits, draft_to_final_diff
  (change_percent מפנקס-ההתאמה). מקור-אמת אחד עם lessons.py.
- MCP tool style_distance(case_number).

INV-LRN4. G9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:33:00 +00:00
caeaf51db4 ci: prune old build-NNN images and stale build cache after deploy
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
Old build-NNN tags accumulated in the shared host /var/lib/docker
(~1.3GB each, 24 builds = ~30GB) and filled the disk to 100%.
Keep the newest 5 build tags, drop dangling images, and prune build
cache older than 72h on every run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:31:43 +00:00
9a6d690e0e Merge pull request 'fix(ui): ברירת-מחדל של ספריית הפסיקה — החלטות ועדות ערר ראשונות' (#75) from worktree-fix+precedents-default-committee into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m31s
2026-06-06 17:27:11 +00:00
a3ef9e5e34 fix(ui): ברירת-מחדל של ספריית הפסיקה — החלטות ועדות ערר ראשונות
מתג-המקטעים נפתח כעת על "החלטות ועדות ערר" (הקורפוס המרכזי של היו"ר)
במקום "פסיקת בתי משפט".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:26:49 +00:00
7a2865339c Merge pull request 'feat(style-acq T4+T5): פנקס-התאמה draft↔final + דיסטילציה אוטומטית דרך ה-curator' (#74) from worktree-style-acquisition-mvp into main
Some checks failed
Build & Deploy / build-and-deploy (push) Failing after 2m31s
2026-06-06 17:21:23 +00:00
0d995483ce feat(style-acq T4+T5): פנקס-התאמה draft↔final + דיסטילציה אוטומטית דרך ה-curator
סוגר את לולאת-הלמידה (INV-LRN4): כל החלטה נסגרת מול הסופי, וכל סופי
מנותח מול הטיוטה. מזין את הטבלאות ש-T15 כבר קורא מהן.

T5 — פנקס-התאמה:
- SCHEMA_V26: טבלת draft_final_pairs (snapshot draft + final + diff + analysis + status).
- db: create/update/list_draft_final_pairs.
- mark-final (app.py): תופס snapshot של הטיוטה (decision_blocks) ברגע החתימה,
  לפני שאפשר לדרוס אותו, ופותח שורת-פנקס (status=final_received).

T4 — דיסטילציה אוטומטית:
- learning_loop.process_final_version: משתמש ב-snapshot (לא בבלוקים שאולי השתנו),
  מסווג style_method↔substance, שומר הצעה ב-pair (status=analyzed).
  **הוסר ה-auto-upsert של style_patterns** — ביטל את ה-bug שדרס את שער-היו"ר
  וזיהם סגנון במהות (INV-LRN1 + INV-LRN5).
- LESSONS_PROMPT: הפרדת style_method↔substance מפורשת + לקח מופשט בלבד.
- curator wake + hermes-curator.md: מריץ ingest_final_version ראשון; מציע רק
  style_method שלא תועד; substance→מסלול precedent.

INV-LRN1 (שער-יו"ר, אין auto-commit) · INV-LRN4 (ניגוד-אמת) · INV-LRN5 (טוהר).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:20:57 +00:00
24f9ceb164 Merge pull request 'docs+config: בידוד-סשנים נתמך-סביבה לעבודה מקבילה (worktree defaults)' (#73) from worktree-docs-worktree-defaults into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
2026-06-06 17:16:47 +00:00
c482414819 docs+config: בידוד-סשנים נתמך-סביבה לעבודה מקבילה (worktree defaults)
הופך את כלל ה-worktree המבודד מ-דיסציפלינה-ידנית ל-ברירת-מחדל נתמכת-סביבה,
לפי המקורות הרשמיים של Anthropic (worktrees + settings) ו-Git.

- .claude/settings.json: worktree.baseRef=fresh (בסיס מ-origin/main),
  worktree.symlinkDirectories=[web-ui/node_modules] (שיתוף 789MB במקום npm ci לכל worktree),
  ו-WorktreeRemove hook עם --force לעקיפת באג cleanup #40259. spec-guard נשמר.
- .worktreeinclude: העתקת .claude/settings.local.json (allowlist הרשאות) + env לכל worktree.
- .gitignore: הוספת .claude/worktrees/ (טיפ רשמי) — מנקה את git status של העץ הראשי.
- CLAUDE.md: שדרוג מקטע "בידוד-סשנים" — claude --worktree כברירת-מחדל תחת .claude/worktrees/,
  caveat בידוד-DB (לא migrations מ-2 worktrees), אזכור באג #60588 (אימות baseRef).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:39:11 +00:00
014eb4937e Merge pull request 'feat(style-acq T15): הכותב צורך את כל הלמידה (/methodology + /training) + תיקון-מספור' (#72) from worktree-style-acquisition-mvp into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m56s
2026-06-06 16:37:01 +00:00
b9bdca0572 feat(style-acq T15): הכותב צורך את כל הלמידה (/methodology overrides + /training lessons) + תיקון-מספור
עונה ל"להתחשב במה שכבר למדנו": הכותב התעלם מעריכות היו"ר ב-/methodology
(נשמרו ב-appeal_type_rules אך block_writer קרא רק קבועי lessons.py) ומ-
decision_lessons של /training. עכשיו הכל מגיע לכתיבה.

- db.get_methodology_overrides(category) — overrides של היו"ר (יחסי-זהב,
  כללי-דיון, צ׳קליסטים) מ-appeal_type_rules (כמו merge של ה-API).
- db.get_recent_decision_lessons(limit, practice_area) — לקחי /training.
- _build_style_context(practice_area): מוסיף סעיף " למידה מצטברת — גובר
  על ברירת-מחדל" עם שניהם, אחרי voice-fingerprint (T0). שני ה-callers מעבירים
  practice_area. עובד יחד עם הלולאה (T4/T5) שתזין לאותן טבלאות.

תיקון-מספור (חלק מ-T9, דחוף כי T0 הזריק את הטעות): voice-fingerprint §3.1
תוקן — ההחלטה ממוספרת תמיד (מספור-אוטומטי ב-Word); "ללא מספור" היה
ארטיפקט-חילוץ. האנטי-דפוס האמיתי: רשימת-מיני בתוך פסקה + מספרים ידניים.

INV-LRN4 (הזרמת למידה) · INV-LRN5 (טוהר). G11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:36:32 +00:00
f17e0e382a Merge pull request 'feat(mcp): FU-14 GAP-48 פרוסה 1 — envelope אחיד (SSoT) + משפחת-חיפוש' (#71) from fix/fu14-gap48-envelope-ssot into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m45s
2026-06-06 16:32:32 +00:00
aa0a736a7b feat(mcp): FU-14 GAP-48 פרוסה 1 — envelope אחיד (SSoT) + משפחת-חיפוש
INV-TOOL1: כלי-ה-MCP החזירו 3 מוסכמות סותרות (raw payload / {error} /
{status,message} אד-הוק) + 5 עותקי _ok/_err משוכפלים. נוצר tools/envelope.py
כמקור-אמת יחיד: ok/empty/err → {status,data,message}, כש-status מבחין
מפורשות הצלחה/ריק/שגיאה.

פרוסה 1 ממירה את משפחת-החיפוש (search_decisions, search_case_documents,
find_similar_cases, search_internal_decisions). web/app.py מפרק את המעטפת
דרך envelope_unwrap כדי לשמר את חוזה-ה-UI↔API (X6) ללא-שינוי — תשובת ה-HTTP
זהה (list על hits, {"message"} על ריק/שגיאה). טסט test_search_domain_scope
עודכן לחוזה החדש (5/5 עוברים).

החלטה: הדרגתי לפי-משפחה ולא big-bang. מפת-צרכנים: server.py pass-through,
web-ui מבודד (/api/*), רק 17 כלים נצרכים ישירות מ-app.py → סיכון מינימלי
לסוכנים החיים. ~73 כלים נותרו לפרוסות הבאות.

Invariants: מקדם INV-TOOL1 (envelope עקבי) + G2 (SSoT, ביטול כפילות _ok/_err).
לא נוגע ב-G1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:32:07 +00:00
c52b5986a3 Merge pull request 'feat(ui): אינדיקטור התקדמות לחילוץ מטא-דאטה + מתג-מקטעים בספריית הפסיקה' (#70) from worktree-feat+metadata-extraction-progress into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m42s
2026-06-06 16:22:42 +00:00
6bf19bd0d7 feat(ui): אינדיקטור התקדמות לחילוץ מטא-דאטה + מתג-מקטעים בספריית הפסיקה
שתי בעיות UX בדף /precedents:

1. חילוץ מטא-דאטה לא נתן שום אינדיקציה שהוא רץ. בניגוד לחילוץ טקסט/הלכות
   (extraction_status / halacha_extraction_status) למטא-דאטה היתה רק חותמת-זמן
   metadata_extraction_requested_at — אין מצב "processing", לכן StatusPill לא
   הציג כלום. נוספה עמודת metadata_extraction_status ('pending'|'processing'|
   'completed'|'failed') במתכונת העמודות הקיימות, וה-worker
   (process_pending_extractions + reextract_metadata) מעדכן אותה: processing
   בתחילת פריט, completed בסיום (מנקה גם את החותמת), pending בכשל (לריטריי).
   ה-UI מציג תג "מחלץ מטא-דאטה" + באנר מונה-אצווה עם אחוז התקדמות (high-water-mark
   של עומק-התור) שמתעדכן אוטומטית דרך ה-polling הקיים (5ש').

2. שתי טבלאות מוערמות (בתי משפט / ועדות ערר) חייבו גלילה ארוכה. הוחלפו במתג-
   מקטעים — טבלה אחת בכל פעם, עם שמירה על העמודות הייעודיות לכל סוג.

Invariants: G2 (מרחיב מנגנון-סטטוס קיים, לא מסלול מקביל), INV-TOOL4/GAP-45
(המשך חשיפת תור-החילוץ הסמוי). אין נגיעה בתוכן משפטי (G11).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:21:41 +00:00
b97e8d595d Merge pull request 'feat(style-acq T0): הזרקת פרופיל-הקול לכותב + מדיניות-העתקה + הפרדת דוגמאות↔פסיקה' (#69) from worktree-style-acquisition-mvp into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m43s
2026-06-06 16:20:55 +00:00
8a3bcd3ffc feat(style-acq T0): הזרקת פרופיל-הקול לכותב + מדיניות-העתקה + הפרדת דוגמאות↔פסיקה
הלוֹבר הראשי של מערכת רכישת-הסגנון. block_writer עבר היום מ"העתקה +
ערבוב-מהות" ל"הכללת-סגנון + הפרדה":

- _build_style_context: טוען את daphna-voice-fingerprint.md (פרופיל-הקול
  המופשט — המנגנון המרכזי) + מדיניות-העתקה מפורשת לפי סוג-תוכן
  (נוסחה→מותר, ניתוח→הכלל, מהות מתיק אחר→אסור). INV-LRN5.
- _build_precedents_context: פוצל לשני זרמים נפרדים —
  daphna_style_exemplars (איך דפנה כותבת) מול case_law_citations (מהות לציטוט).
- block-yod prompt: שני סעיפים מסומנים במקום "פסיקה רלוונטית (צטט מכאן)"
  שערבב סגנון ומהות; הדוגמאות-סגנוניות מתויגות "מבנה/קול בלבד".

INV: G11 (סגנון דפנה), INV-LRN5 (טוהר-הקול). חלק מתוכנית style-acquisition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:20:24 +00:00
11f20072ea Merge pull request 'docs: כלל קשיח — כל סשן עובד ב-worktree מבודד (מניעת מירוץ-ענף)' (#68) from docs/worktree-isolation-rule into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
2026-06-06 16:12:04 +00:00
d37274a31b docs: כלל קשיח — כל סשן עובד ב-worktree מבודד (מניעת מירוץ-ענף בעץ משותף)
כמה סשנים (chaim + סוכני Paperclip) רצים במקביל על אותו עץ-עבודה ~/legal-ai.
עץ אחד = ענף אחד משותף → סשן מחליף branch/משאיר WIP בזמן שאחר עובד → דריסה
ומירוץ-ענף. הכלל: כל עבודת-כתיבה דרך `git worktree add` ייעודי מ-origin/main;
אסור לערוך/לתייק בעץ הראשי כשייתכן שסשן אחר פעיל; ניקוי אחרי מיזוג.

מעלה את [[feedback_shared_worktree_branch_race]] מ"אמת branch לפני commit"
לכלל-בידוד מלא.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:11:41 +00:00
9c77123fa3 Merge pull request 'feat(spec): מערכת רכישת-הסגנון כיעד-על + ספ 07-learning §0 + משימות (PR1 יסודות)' (#67) from feat/style-acquisition-subsystem into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
2026-06-06 16:02:53 +00:00
770d23b198 feat(spec): הגדרת מערכת רכישת-הסגנון כיעד-על + ספ + משימות (PR1 יסודות)
מגדיר במפורש את יעד-העל שמעולם לא הוגדר: שהסוכנים יכתבו וינתחו עררים
בדיוק כמו דפנה תמיר, דרך תת-מערכת Style-Acquisition נפרדת ממערכת-הכתיבה.

- CLAUDE.md: פרק "יעד-העל: רכישת-הסגנון" — הפרדה writing↔learning,
  Authorial Style Profiling (לא fine-tuning), מדיניות-העתקה לפי סוג-תוכן
- docs/spec/07-learning.md §0: תת-המערכת, 3 ערוצי-הזנה, צינור 7-שלבים,
  ניהול ב-UI, + INV-LRN4 (ניגוד-אמת draft↔final) + INV-LRN5 (טוהר-הקול)
- TaskMaster: 15 משימות T0-T14 (89-103) — MVP=T0+T4+T7

ללא שינוי-קוד runtime. 1130-25 כבר נקלט ל-internal_committee (תהליך מקביל).
INV: G9 (ידע מובנה), G10 (שער-יו"ר), G11 (סגנון דפנה).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:02:18 +00:00
1565a636a8 Merge pull request 'feat(mcp): FU-14 GAP-47 (חלק provenance) — draft_section מחזיר document_id+page+score' (#66) from fix/fu14-gap47-provenance into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m8s
2026-06-06 15:59:09 +00:00
40c1111e9b feat(mcp): FU-14 GAP-47 (חלק provenance) — draft_section מחזיר document_id+page+score
ה-provenance (document_id, page_number, score) כבר נשלף ב-search_similar אך
נזרק בבניית פלט draft_section. כעת מוחזר לכל קטע ב-case_documents/precedents,
כך שהכותב יכול לעקוב אחורה אל מסמך-המקור והעמוד ולצטטם, ולא לסמוך על תוכן
חסר-מקור. תוספתי בלבד — אין צרכן שמפרסר את מפתחות-הפלט, תואם-לאחור.

נותר ב-GAP-47: העברת הנחיות-יו"ר מ-analysis-and-research.md ל-DB
(get_chair_directions) — שינוי-מסלול גדול יותר, לפרוסה נפרדת.

Invariants: מקיים INV-TOOL4 (מקור-אמת נגיש) + G9 (provenance). לא נוגע ב-G2/G1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:58:39 +00:00
4977ab8d9a Merge pull request 'feat(mcp): FU-14 GAP-51 — איחוד אוצר-המילים של תוצאת-תיק (set_outcome SSoT)' (#65) from fix/fu14-gap51-outcome-ssot-impl into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m41s
2026-06-06 15:35:36 +00:00
701efab726 feat(mcp): FU-14 GAP-51 — איחוד אוצר-המילים של תוצאת-תיק (set_outcome SSoT)
הכרעת-יו"ר: קנוני = 3 תוצאות אמיתיות (rejection/partial_acceptance/full_acceptance);
betterment_levy יוצא מהיותו "תוצאה" ועובר ל-override לפי practice_area.
+ עקרון "אנגלית-ב-DB, עברית-ב-UI": מפת-תוויות SSoT אחת.

lessons.py:
- VALID_OUTCOMES = 3 (הוסר betterment_levy).
- OUTCOME_LABELS_HE (SSoT לתצוגה) + LEGACY_OUTCOME_MAP + canonical_outcome().
- PRACTICE_AREA_OVERRIDES["betterment_levy"] מרכז את כל ה-guidance שהיה מפתוח כ-outcome
  (golden_ratios/opening/summary/discussion/template).
- get_lessons_for_outcome(outcome, practice_area) + format_ratios_comment(..., practice_area)
  מחילים override + מנרמלים legacy.

block_writer.py: STRUCTURE_GUIDANCE קנוני + תווית מ-OUTCOME_LABELS_HE + override betterment.
workflow.set_outcome: קנוני 3 + מיפוי-legacy סלחני; תווית מ-SSoT.
drafting.py: טבלת יחסי-זהב + get_decision_template מודעי-practice_area (override).
web-ui case.ts: הסרת betterment_levy מ-expectedOutcomes (הוא practice_area).
server.py: docstrings קנוניים.

מיגרציה: migrate_gap51_outcomes.py — 9 שורות נורמלו (rejected→rejection וכו'),
גיבוי ב-data/audit/. הקוד canonicalize בקריאה ⇒ backward-compatible גם בלי מיגרציה.

אומת: py_compile (5 קבצים) + בדיקות-יחידה offline (override/legacy/labels) + אימות-DB.
עודכנו X9 §3 + gap-audit (GAP-51 ).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:34:49 +00:00
d3f1d04915 Merge pull request 'feat(mcp): FU-14 GAP-45 — extraction_status (חשיפת תור-החילוץ הסמוי)' (#64) from fix/fu14-gap45-extraction-status into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m37s
2026-06-06 15:00:50 +00:00
ea8b48c6ac feat(mcp): FU-14 GAP-45 — extraction_status (חשיפת תור-החילוץ הסמוי)
INV-TOOL4 (visibility / persistence). תור בקשות-החילוץ (metadata/halacha) נשמר
ב-case_law.{metadata,halacha}_extraction_requested_at ומרוקן ע"י
precedent_process_pending — אבל לא היה כלי לראות את עומק-התור.

נוסף:
- db.extraction_queue_status() — count + גיל הבקשה הוותיקה לכל kind (read-only).
- plib.extraction_status() — tool wrapper (envelope _ok/_err).
- רישום extraction_status ב-server.py ליד precedent_process_pending.
- precedent_process_pending קיבל _clamp_limit (עקביות עם GAP-53).

תוספתי, read-only, אפס שבירה. עודכנו X9 (INV-TOOL4 ) ו-gap-audit (GAP-45 ).
py_compile עבר על 3 קבצי הקוד.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:00:25 +00:00
0d0f5aa8e9 Merge pull request 'feat(mcp): FU-14 GAP-52 — idempotency על case_create/precedent_attach/document_upload' (#63) from fix/fu14-gap52-idempotency into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m46s
2026-06-06 14:53:14 +00:00
034b609bd3 feat(mcp): FU-14 GAP-52 — idempotency על case_create/precedent_attach/document_upload
INV-TOOL3 (idempotency על מפתח דטרמיניסטי). כל שלושת הכלים מחזירים את הרשומה
הקיימת במקום ליצור כפילות:

- case_create — מפתח case_number (כבר UNIQUE ב-schema): מחזיר את התיק הקיים
  במקום unique-violation.
- precedent_attach — מפתח (case_id, section_id, citation, quote): צירוף חוזר
  של אותו ציטוט לאותו סעיף מחזיר את הקיים.
- document_upload — מפתח (case_id, SHA-256 של בייטי הקובץ): העלאה חוזרת של אותו
  קובץ מחזירה את המסמך הקיים ו**מדלגת על copy+OCR+embed** (החלק היקר). נוספה
  עמודת documents.content_hash (תוספתי, DEFAULT '') + get_document_by_hash.

נבחרה בדיקת-מפתח ברמת-אפליקציה (SELECT-לפני-INSERT) ולא UNIQUE-constraint —
כדי לא לשבור startup אם קיימים נתונים-כפולים legacy. אין מיגרציה הרסנית.

עודכנו docs/spec/X9 (INV-TOOL3 ) ו-gap-audit (GAP-52 , פרוסה 2).
py_compile עבר על 4 קבצי הקוד. אימות runtime (restart MCP server) נדחה עד
שהחילוץ הפעיל יסתיים.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:52:33 +00:00
b53d65c1f6 Merge pull request 'feat(mcp): FU-14 פרוסה 1 — get_appraiser_facts (GAP-44) + limit-caps (GAP-53)' (#62) from fix/fu14-slice1-appraiser-getter-limit-caps into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m50s
2026-06-06 14:38:22 +00:00
ebfe7f6a1d feat(mcp): FU-14 פרוסה 1 — get_appraiser_facts (GAP-44) + limit-caps (GAP-53)
תוספתי בלבד, אפס שבירת-תאימות. שני invariants מחוזה-כלי-ה-MCP (X9):

GAP-44 (INV-TOOL4, סימטריית extract/get): נוסף get_appraiser_facts — ה-get
המקביל ל-extract_appraiser_facts. קורא list_appraiser_facts + detect_appraiser_conflicts
מה-DB ללא חילוץ-LLM יקר ולא-דטרמיניסטי. מחזיר count=0 (לא שגיאה) אם טרם חולץ.

GAP-53 (INV-TOOL5, limit-caps / OWASP API4:2023): נוסף _clamp_limit (תקרה 200,
non-positive→max) על ~13 כלי list/search ב-server.py (case_list, search_*,
precedent_library_list, halachot_pending, missing_precedent_list, list_*_citations…).
list_chair_feedback קיבל param limit חדש (server→workflow→db עם LIMIT) — היה ללא תקרה כלל.

לא הוסף get_appraiser_facts ל-frontmatter של סוכנים (INV-AG3 "לא עודף" — ההוראות
עוד לא מפנות אליו; חיווט = follow-up). נותר ב-FU-14: GAP-45/48/49/50/51/52.

עודכנו docs/spec/X9 (INV-TOOL4/5) ו-gap-audit (סטטוס פרוסה 1).

אומת: py_compile על 4 קבצי הקוד. אימות runtime (restart MCP server) נדחה עד
שהחילוץ הפעיל של היו"ר יסתיים.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:37:30 +00:00
67a3d9a9b0 Merge pull request 'fix(security+agents): GAP-57 fail-loud PAPERCLIP_DB_URL + FU-13 analyst tool alignment' (#61) from fix/gap57-creds-fu13-analyst-tools into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
2026-06-06 14:21:59 +00:00
482f302d54 fix(security+agents): GAP-57 fail-loud PAPERCLIP_DB_URL + FU-13 analyst tool alignment
GAP-57 (אבטחה, CWE-798 / INV-ENV4): ה-default הקשיח
postgresql://paperclip:paperclip@... הוסר מ-3 קבצי web/. נוסף resolver משותף
require_paperclip_db_url() ב-paperclip_api.py שנכשל בקול אם PAPERCLIP_DB_URL לא
מוגדר — במקום ליפול בשקט ל-creds ידועים. Coolify מגדיר את המשתנה (אומת), אז
הייצור לא נפגע. (2 מופעים בסקריפטים מקומיים נותרו ל-FU-15 המלא.)

FU-13 (INV-AG3, GAP-46): יישור הרשאות-סוכן. התברר שהפער שמופה ב-31.5 היה רחב
מדי — יוחס לפי תיאור-תפקיד, לא ההוראות בפועל. הכרעת-יו"ר "היבריד":
- legal-analyst: נוסף aggregate_claims_to_arguments (frontmatter + שלב 7) — הכלי
  שמקבץ את הטענות שהוא חילץ לטיעונים משפטיים.
- extract_references/extract_internal_citations הם מטלת-researcher (שכבר מחזיק
  אותם), לא analyst — הוסרו מרשימת "החסרים".
- legal-researcher: כבר היה תקין; ה-spec היה מיושן.
עודכנו X4-agents.md (§2א, INV-AG3) ו-gap-audit.md (FU-13 , FU-15 חלקי).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:14:39 +00:00
27b40dfec5 Merge pull request 'fix(lint): תיקון 10 שגיאות ESLint + ניקוי directives מיותרים' (#60) from fix/lint-errors into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 42s
2026-06-06 13:32:46 +00:00
1f1a025509 fix(lint): תיקון 10 שגיאות ESLint + ניקוי directives מיותרים
10 שגיאות (כולן קיימות-מראש, לא מהפיצ'רים האחרונים):
- react/no-unescaped-entities (3): legal-arguments-panel, precedent-edit-sheet
  — escaping של מרכאות ב-JSX (&ldquo;/&quot;)
- react-hooks/set-state-in-effect (6): documents-panel, chair-editor,
  content-checklists, discussion-rules, golden-ratios, documents.ts
  — disable-comment לדפוסי sync/reset לגיטימיים (false-positive ידוע)
- React Compiler reassign (1): subject-donut — refactor לחישוב prefix-sums
  ללא mutable accumulator

ניקוי: הסרת 5 eslint-disable directives מיותרים (halacha-review-panel,
precedent-upload-sheet). תוצאה: 0 errors (היה 10), 24→ warnings (היה 29).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:31:31 +00:00
fdeed8a045 Merge pull request 'feat(spec): חיבור ספ-המערכת למסלול-הכתיבה האינטראקטיבי (אכיפה 3-שכבתית)' (#59) from feat/spec-enforcement-interactive into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
2026-06-06 13:29:30 +00:00
7f4e036211 feat(spec): חיבור ספ-המערכת למסלול-הכתיבה האינטראקטיבי (אכיפה 3-שכבתית)
הספ (docs/spec/, G1–G11) חובר לסוכני Paperclip דרך INV-AG1 אבל לא למסלול
שבו רוב הקוד נכתב בפועל — הסשן האינטראקטיבי של Claude Code. סוגר את הפער
לפני מחזור-2 (FU-9..15), שהוא כולו כתיבת-קוד.

שלוש שכבות אכיפה:
1. תיעוד — CLAUDE.md §"פרוטוקול כתיבת-קוד" + docs/spec בטבלת-הייחוס
2. hook — scripts/spec-guard.sh (PreToolUse על Edit/Write/MultiEdit, רשום
   ב-.claude/settings.json) מזכיר פעם-בסשן בכל נגיעה בקובץ-קוד; non-blocking
3. PR — .gitea/PULL_REQUEST_TEMPLATE.md עם סעיף-חובה "Invariants"

המקבילה האינטראקטיבית ל-INV-AG1 שכבר אוכף על הסוכנים (HEARTBEAT §"קריאת-ספ").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:28:15 +00:00
35c15720a5 Merge pull request 'feat(feedback): חיבור פידבק יו"ר לסוכנים — סימון "יושם" מקפל לקח לקובץ הידע' (#58) from feat/chair-feedback-fold into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m41s
2026-06-06 13:09:29 +00:00
4174217179 feat(feedback): סימון "יושם" מפעיל CEO לקיפול הלקח לקובץ הנכון
סוגר את לולאת פידבק-יו"ר→ידע-סוכנים. עד כה resolve רק עדכן את ה-DB; עכשיו
לחיצה ב-/feedback מעירה את ה-CEO שמקפל את הלקח לקובץ לפי הקטגוריה.

- paperclip_client.py: wake_ceo_for_feedback_fold() — יוצר issue ב-Paperclip
  עם הלקח + rubric ניתוב (style→SKILL.md, wrong_structure→block-schema,
  אחר→lessons.md), מעיר CEO. משכפל את דפוס wake_for_precedent_extraction
- db.py: get_chair_feedback(id) — שליפת הערה בודדת עם case_number/appeal_type
- app.py: resolve endpoint מקבל fold (ברירת מחדל true); BackgroundTask
  fire-and-forget; guard — רק עם lesson_extracted. מחזיר fold_queued
- legal-ceo.md: dispatch ל-feedback_fold_ + סעיף "קיפול הערת יו"ר" עם rubric
- frontend: useResolveFeedback מקבל fold; /feedback שולח fold=true עם toast;
  drafts-panel שולח fold=false (bookkeeping per-case, בלי קיפול כפול)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:08:41 +00:00
dd0e754dad docs(lessons): קיפול ידני של 21 הערות יו"ר backlog לקבצי הידע
- legal-decision-lessons.md: סקשן "Chair Feedback Backlog (June 6, 2026)"
  לקחים #36-#46 (רקע תכנוני כארגומנטציה, ראיות ויזואליות, עררים מקבילים,
  שלד יו"ר, סדר ט-לפני-ז, להלן-מתוך, ציר זמן בלוק ו, תכנית נקודתית מול
  כוללנית, תנאי אי-רווח ס'19(ב)(4), הבחנת טענות כתב-ערר מתכתובת)
- block-schema.md: סדר בלוק ט לפני ז בתיקי רישוי 1xxx
- SKILL.md: תבנית "להלן מתוך [מסמך]:" כחובה
- TaskMaster: משימות 87 (claims_coverage), 88 (פער DB↔file)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:08:21 +00:00
e3e3da09e5 feat(feedback): דף מרכזי /feedback להערות יו"ר + תיקון קישורי מרכז אישורים
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 37s
- דף /feedback חדש: מאגד את כל הערות chair_feedback מכל התיקים, סינון
  טרם-יושמו/הכל + לפי קטגוריה, כפתור "סמן כיושם" לכל הערה
- מרכז אישורים: כרטיס "הערות יו"ר" קישר ל-/ (חסר תועלת) → עכשיו /feedback
- מרכז אישורים: כרטיס "תיקים שנכשלו ב-QA" — כל תיק במדגם קליקבילי לדף
  התיק, והכרטיס מקשר ישירות לתיק כשיש רק אחד
- ApprovalSample.href אופציונלי; פריטי מדגם נהפכים ל-Link כשיש href
- ניווט: הוספת "הערות יו"ר" לקבוצת work ב-app-shell

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:38:04 +00:00
59ff4e31cf feat(halacha): כפתורי אישור/דחייה/שחזור inline ברכיב "הלכות שחולצו"
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 38s
ExtractedHalachotSection היה read-only — הוסף כפתורי פעולה לכל הלכה לפי
review_status: נדחתה → אשר/שחזר לתור · מאושרת → בטל אישור/דחה ·
ממתינה → אשר/דחה. משתמש ב-useUpdateHalacha שמרענן את detail query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:27:48 +00:00
68a77c11b6 feat(upload): חסימת כפילות בהעלאת פסיקה + banner עם אפשרויות
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m38s
- בקאנד: GET לפני ה-async task — אם citation כבר קיים כ-external_upload מחזיר 409
- DB: get_external_case_law_by_citation — lookup לפי citation + source_kind
- פרונט: banner אדום עם פרטי הרשומה הקיימת ושני כפתורות:
  • "הפעל חילוץ מחדש" — request-halachot ל-ID הקיים וסגירת הטופס
  • "מחק את הרשומה" — DELETE עם confirm, ניקוי conflict לאחר מכן

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 12:11:33 +00:00
c83d0162ca feat(halacha): טאבים נדחו/אושרו + שחזור הלכה + הסרת placeholders עם שמות
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s
- מוסיף טאב "נדחו" לדף האישורים: הלכות שנדחו מופיעות עם כפתורי "אשר" (ישירות) ו-"שחזר לתור"
- מוסיף טאב "אושרו": הלכות שאושרו עם "בטל אישור" ו-"דחה"
- ספירה צבועה על כל טאב (זהב/אדום/כחול)
- מוסיף useHalachotByStatus hook ב-API
- מסיר placeholders עם שמות ("דפנה תמיר") משדות יו"ר

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 12:07:49 +00:00
f5926506fe chore(types): regenerate OpenAPI types after decision-blocks endpoints
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 39s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:42:05 +00:00
df97e21d22 Merge pull request 'feat(ui): interactive decision-block viewer + inline editor on case page' (#57) from feat/decision-blocks-viewer into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 4m15s
feat(ui): interactive decision-block viewer + inline editor on case page (#57)
2026-06-06 09:37:13 +00:00
c35e0e50ed feat(ui): interactive decision-block viewer + inline editor on case page
Adds a new "ההחלטה" tab to the case detail page showing all 12 decision
blocks with rendered markdown content and inline editing that saves back
to the DB via two new FastAPI endpoints.

Backend (web/app.py):
- GET  /api/cases/{n}/decision-blocks   — returns all 12 blocks (empty
  ones included) merged from BLOCK_CONFIG + decision_blocks table.
  Exposes source_of_truth ("docx"|"blocks") and active_draft_path.
- PUT  /api/cases/{n}/decision-blocks/{block_id} — inline save via
  block_writer.save_block_content; warns (does not block) when an
  active DOCX draft exists.

Frontend:
- src/lib/api/decision-blocks.ts    — typed hooks (useDecisionBlocks,
  useSaveBlock) following the cases.ts hand-written-module pattern.
- src/components/cases/decision-blocks-panel.tsx — accordion of 12
  blocks; view mode renders Markdown component; edit mode is a textarea
  with on-blur save (derived from ChairEditor pattern, setState-during-
  render for re-sync to avoid effect cascade).
- BLOCK_LABELS in feedback.ts extended from 7 → 12 blocks.
- cases/[caseNumber]/page.tsx — new "ההחלטה" tab wired to the panel.

No DB migration required — decision_blocks + active_draft_path exist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:36:51 +00:00
6dd125c491 Merge pull request 'fix(nevo): strip preamble/mini-ratio from court rulings too (#86.1)' (#56) from fix/nevo-preamble-court-rulings into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m35s
2026-06-03 16:56:01 +00:00
f8c3fd6c89 fix(nevo): strip preamble/mini-ratio from court rulings too (#86.1)
strip_nevo_preamble's _DECISION_START only matched ועדת-ערר openings (בפנינו /
הערר שבנדון / ...), so Nevo COURT judgments — exactly the ones carrying a
מיני-רציו — slipped through unstripped. The editorial mini-ratio then leaked into
the chunked body, risking that the halacha extractor reads Nevo's answer key
(contamination) and polluting the corpus. Proven on בג"ץ 1764/05: its full_text
still contained the מיני-רציו (unstripped).

Fix:
- Extend _DECISION_START with court-ruling openings: פסק-דין/פסק דין header and
  the authoring-judge line (השופט/ת, כב' השופט, הנשיא, המשנה לנשיא). re.search
  picks the earliest line-start match → the real opinion start, not the prose
  ratio above it.
- Widen the Nevo-marker detection window 400→1500 chars so a long court/parties
  header doesn't push חקיקה שאוזכרה:/מיני-רציו: out of range.

Verified on the real 1764/05 full_text: strips 2702 chars, body now starts at
'השופט ס' ג'ובראן:', מיני-רציו gone. Regression: ועדת-ערר openings still strip;
non-Nevo text untouched; markers-past-400 now detected. Suite 182 passed (6 new).

This is the anti-contamination prerequisite for the Nevo-ratio gold-set (#86.3/#81.7).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:55:31 +00:00
d47a633fcf Merge pull request 'feat(halacha): over-extraction consolidation — fold facets via claude_session (#81.5)' (#55) from feat/halacha-consolidation into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m38s
2026-06-03 16:27:13 +00:00
fb60dca796 feat(halacha): over-extraction consolidation — fold facets via claude_session (#81.5)
After a precedent finishes extracting, a claude_session pass folds facets of the
SAME legal question (below #82's dedup cosine — the שפר 14-vs-4 / 403-17→89
granularity gap) into one canonical; the rest are marked 'rejected' (reversible:
out of the active corpus AND the review queue, but recoverable). FOLD-ONLY —
never merges distinct legal questions, never invents.

- Engine: claude_session-as-judge (local CLI, zero cost), 'high' effort — folding
  needs careful judgment. One pass per precedent, runs in _extract_impl once all
  chunks are done (the prompt dedups within a chunk; this catches across chunks).
- Pure, unit-tested helpers in halacha_quality: CONSOLIDATE_SYSTEM,
  build_consolidation_prompt, parse_fold_groups (fails SAFE → [] on any malformed
  shape; drops <2-member groups; coerces/dedups indices).
- halacha_extractor._consolidate_precedent picks the canonical per group
  (approved>pending, higher confidence, quote_verified, longer) and rejects the
  rest via the existing update_halachot_batch (#84). Never rejects a canonical.
  Fails OPEN on any error (no CLI / parse fail → 0 folds, data untouched).
- config: HALACHA_CONSOLIDATE_ENABLED/MODEL/EFFORT.

Verified: suite 176 passed (10 new); integration vs dev DB — a 2-facet group
folds to 1 canonical + 1 rejected (tagged), distinct rules untouched, claude
error → 0 folds (fail-open).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:26:44 +00:00
5efb8cf915 Merge pull request 'feat(halacha): NLI entailment validator via claude_session (#81.3)' (#54) from feat/halacha-nli-validator into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m39s
2026-06-03 14:46:40 +00:00
f196bed564 feat(halacha): NLI entailment validator via claude_session (#81.3) + task #86
#81.3 — a post-extraction validator that flags halachot whose rule_statement is
NOT entailed by its supporting_quote (the model over-reaching beyond its source).

- Engine: claude_session-as-judge (local CLI, zero API cost) per chaim's standing
  preference — one batched judge call per chunk, NOT a hosted NLI model.
- Pure, unit-tested helpers in halacha_quality: NLI_SYSTEM, build_nli_prompt,
  parse_nli_verdicts (fails OPEN — any shape/label ambiguity → 'entailed').
- halacha_extractor._nli_check wraps the call; fails OPEN on any error (e.g. no
  CLI in the container) so a flaky judge never blocks a genuine halacha.
- Non-entailed (neutral/contradiction) → quality_flag 'nli_unsupported' which
  blocks auto-approve (routes to pending_review) via the existing store gate.
- config: HALACHA_NLI_ENABLED/MODEL/EFFORT (effort 'low' — entailment is simple).

Verified: suite 166 passed (10 new); LIVE smoke test against the real claude CLI
returned ['entailed','neutral'] for a supported vs unsupported rule.

Also commits TaskMaster #86 (Nevo preamble/ratio: anti-contamination strip fix +
gold-set benchmark) capturing today's strip_nevo_preamble findings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:46:12 +00:00
e25507f9ad Merge pull request 'feat(upload): accept legacy .doc, convert via LibreOffice in container' (#53) from feat/doc-upload-support into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m3s
2026-06-03 13:48:26 +00:00
476c2fc5d1 feat(upload): accept legacy .doc, convert via LibreOffice in container
Legacy Hebrew .doc precedents (e.g. nevo.co.il CP1255 OLE2) can now be
uploaded directly through the precedent-library, missing-precedent, and
training upload paths — the frontend already advertised .doc but the
backend gate rejected it before reaching the extractor.

- web/app.py: add .doc to ALLOWED_EXTENSIONS (covers all paths that share
  the set: precedent library, missing-precedent, training).
- Dockerfile: install libreoffice-writer-nogui (no X11/Java) so the
  extractor's existing _extract_doc LibreOffice conversion works in the
  Coolify container (was missing → would fail at runtime).
- extractor.py: isolate the LibreOffice user profile per call to avoid a
  profile-lock failure on concurrent .doc conversions.

Verified in python:3.12-slim (prod base): .doc→.docx→text yields text
byte-identical to a native Word .docx save (103 paragraphs, 24,341 chars).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:47:47 +00:00
db6bad5d1e Merge pull request 'feat(halacha): review-queue triage — defer + batch + quality-flag badges (#84)' (#52) from feat/halacha-review-triage into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m41s
2026-06-03 13:42:53 +00:00
eeb70a5758 feat(halacha): review-queue triage — defer + batch group actions + quality-flag badges (#84)
Make the chair's pending-halacha review faster and less exhausting.

Backend:
- New 'deferred' review_status (snooze): stays out of the active library AND
  out of the default pending queue, without the finality of 'rejected'.
  update_halacha stamps reviewer+reviewed_at on defer; HALACHA_REVIEW_STATUSES
  is the single source of valid statuses (PATCH validation now uses it).
- db.update_halachot_batch(ids, status, reviewer) — one atomic UPDATE for a
  whole group; invalid status / empty ids are a no-op.
- POST /api/halachot/batch (HalachaBatchReviewRequest) wraps it.
- update_halacha now RETURNs quality_flags too (parity with list_halachot).

Frontend (halacha-review-panel):
- Quality-flag badges (#81: non_decision / truncated_quote / thin_restatement /
  quote_unverified) so the chair sees WHY an item was held back.
- Defer action — button + keyboard 'D' — to snooze without rejecting (fixes the
  'leave in pending forever' anti-pattern; reject stays the junk verb).
- Per-precedent batch bar: 'אשר הכל' / 'דחה הכל' via useBatchReviewHalachot
  (one request, one refetch) with confirm guards.
- Halacha/HalachaPatch types gain quality_flags + 'deferred'.

Verified: mcp-server suite 156 passed; web build green; end-to-end integration
against dev DB (batch approve/reject, defer sets status+timestamp, pending
excludes approved+deferred, deferred queryable, invalid status no-op).

Note: api:types regen deferred until deploy (the batch hook is hand-typed, not
dependent on generated types).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:42:21 +00:00
7ebddcce6d Merge pull request 'feat(halacha): UNIQUE(case_law_id, halacha_index) backstop (#83)' (#51) from feat/halacha-unique-index into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m37s
2026-06-03 13:07:30 +00:00
0f64b4c062 feat(halacha): UNIQUE(case_law_id, halacha_index) backstop + task tracking (#83)
#83 pipeline robustness — the index-numbering correctness guarantee:
- Add CREATE UNIQUE INDEX idx_halachot_unique_index ON halachot(case_law_id,
  halacha_index). The extractor assigns the index as MAX+1 under an in-process
  store-lock + a cross-process pg advisory lock, so collisions shouldn't occur
  in normal operation — but per the research (FireHydrant/OneUptime) the
  constraint is the actual correctness guarantee while the lock is the
  optimization. A racing/double run now fails LOUDLY (UniqueViolation, chunk
  left un-checkpointed → clean resume) instead of silently appending the
  duplicates that were the 2026-05/06 over-extraction root cause.

Data prep (run against the live DB before the constraint, backed up to
data/audit/halacha-reindex-backup-*.sql): the 6 precedents that still carried
colliding halacha_index values (9 groups, distinct principles that shared a
number — NOT content dups) were renumbered to unique sequential indices.

Verified: advisory lock holds cross-process and the DB path is direct asyncpg
(no transaction-pooler), so the session lock is safe (83.1); force=True does
delete+checkpoint-clear in one transaction (83.5); constraint rejects a
duplicate-index insert (integration-checked). Full suite 156 passed.

Also commits the TaskMaster tracking for the whole halacha-quality initiative
(#81-#84 + research-backed subtasks, statuses).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:06:58 +00:00
8e3d14abee Merge pull request 'feat(halacha): strict-rubric quality gate + dedup-on-insert (#81,#82)' (#50) from feat/halacha-quality-gate into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m36s
2026-06-03 12:31:27 +00:00
ca959d4a9c feat(halacha): strict-rubric quality gate + dedup-on-insert (#81,#82)
Bake the 2026-06-03 strict-cleanup rubric into the extraction pipeline so the
corpus stays clean at the source instead of accumulating duplicates, obiter
dicta, truncated quotes and thin restatements that clog the review queue.

#81 — quality gate:
- New pure module halacha_quality.py with unit-tested validators:
  non-decision/obiter (Wambaugh markers), truncated-quote (mid-word cut),
  thin-restatement (rule≈quote), quote-unverified.
- Validators run in halacha_extractor._process; a non-decision is re-typed
  obiter; flags persist in new halachot.quality_flags column.
- Auto-approve now requires confidence>=threshold AND no quality flags;
  flagged items route to pending_review regardless of confidence.
- Both extraction prompts hardened: reject undecided dicta, exclude
  case-specific applications, require abstraction, forbid over-splitting.

#82 — dedup-on-insert (store_halachot_for_chunk):
- Within the same precedent, skip a halacha whose normalized supporting_quote
  already exists, or whose rule-embedding has cosine>=HALACHA_DEDUP_COSINE
  (0.93) against an already-stored one. Makes re-runs idempotent.

Migration: halachot.quality_flags TEXT[] (additive, idempotent ALTER).
Tests: 19 new unit tests; full suite 156 passed. Validated end-to-end against
dev DB (dedup skips dups, flag blocks auto-approve, re-run inserts 0).
Calibration: flags fire on only ~10% of current survivors (low false-positive).

Spec: docs/halacha-strict-rubric.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:30:38 +00:00
b0ec24a9d5 Merge pull request 'chore(#80): backfill 8070-25 → appraisal multimodal 12/12; close #80' (#49) from chore/80-multimodal-appraisal-coverage into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
2026-06-03 09:46:44 +00:00
f5d14fd6b8 chore(#80): backfill 8070-25 -> appraisal multimodal coverage 12/12; close #80
Full check found the premise wrong on every count (like #71/#70):
- Not 140 docs/17,700 pages/2hr/$$ needing Dafna+chaim. Of 140 image-less
  docs, only 65 are PDF (rest MD/DOCX — pipeline renders PDF only) = 704 pages.
- The value docs (appraisal, where multimodal's table/image worth is) were
  already 8/12 embedded. The only gap was ONE case, 8070-25 (4 appraisal docs).
- Backfilled 8070-25 locally (voyage-multimodal-3, ~30s, cents): all 14 docs
  embedded. Appraisal coverage now 12/12 (100%).
- Remaining 51 PDFs/649 pages are all text-dense (reference/response/appeal);
  #15 proved multimodal does NOT help text-dense docs, so they're intentionally
  left text-only. Not an inconsistency — the correct config.

No gold-set / Dafna labeling / chaim cost approval needed — cost was cents and
value was already proven in #15. #80 done (technical, not human-gated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:46:23 +00:00
bbe3db7b94 Merge pull request 'chore(#70): delete 15 orphaned cited_only stubs + close #70' (#48) from chore/70-orphan-stub-cleanup into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
2026-06-03 09:38:51 +00:00
7d0d4a9b27 chore(#70): delete 15 orphaned cited_only stubs + close #70
The 4 'ambiguous' citation items flagged for chair turned out to be dead
orphan stubs: 0 inbound/outbound edges across all 5 citation mechanisms,
0 full_text, 0 halachot, 0 chunks/embeddings. A corpus-wide check found 15
such orphans total (incl. clean-looking ones). Per OpenCitations (keep an
id-less entity only if it is CITED — these are cited by nothing), these are
pure noise → deleted, not chair-judgment.

- 15 orphan cited_only stubs deleted (cited_only 46 -> 31); backup in
  data/audit/fu2b-orphan-stub-cleanup-*.json.
- 0 malformed / 0 orphans remain; all 31 remaining stubs are cited.
- Combines with the 3 earlier mechanical normalizations. #70 fully done.
- Known forward-edge (no current data, no task): '+' combined-citation
  handling in citation_extractor if it recurs in future extraction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:38:30 +00:00
61dde4cd83 Merge pull request 'chore(tasks): research-backed decisions — close #71/#42/#14/#76 + #70 normalization' (#47) from chore/close-open-tasks-research-decisions into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
2026-06-03 09:10:02 +00:00
2a9168a1b4 chore(tasks): research-backed decisions to close open tasks (#71/#42/#14/#76/#70)
Per chaim's directive — for decisions not requiring Dafna/chaim, decide after
>=3 authoritative open sources.

#71 DONE — resolved by #15's weight fix (measured: all multi-relevant docs now
  in top-10, the rank-15/16 weak queries fixed). Research (6 sources) said
  enable rerank; tested empirically → it HURT (nDCG@5 0.879 vs 0.960, MRR 0.867
  vs 0.954) because recall is saturated and the cross-encoder demotes exact
  known-item matches. Measurement overrides theory: no rerank, no limit change.
#42 CANCELLED — obviated by BM25 hybrid (already on; handles abbreviation
  tokens lexically); 0 abbrev queries in eval, recall ~0.99, no measured gap.
#14 DEFERRED (reviewed) — no current blocker; YAGNI; trigger documented.
#76 CANCELLED — upstream Paperclip bug (ee=companyId), not safely fixable our
  side; workaround + #78 documented.
#70 — research-backed normalization (ECLI/Akoma Ntoso/ELI/OpenCitations +
  Christen). Applied 3 deterministic mechanical fixes to cited_only (whitespace
  + missing prefix-space); 0 malformed remain. 4 ambiguous items (2 garbled,
  'ערר אדלר', 1 combined citation) flagged for chair — NOT auto-guessed, per
  the entity-resolution false-merge guardrail.

#80 stays pending — human-gated (Dafna value-labeling + chaim cost).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:09:30 +00:00
5a00a0ef47 Merge pull request 'chore(#15): adopt MULTIMODAL_TEXT_WEIGHT=0.65 + close #15, open #80' (#46) from chore/15-multimodal-weight-065 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
2026-06-03 08:45:29 +00:00
4debe9995b chore(#15): adopt MULTIMODAL_TEXT_WEIGHT=0.65 + close #15, open #80
A/B eval (eval_retrieval.py, 86-query gold-set) showed the 0.5 default was
mis-tuned: the image side was too heavy and dragged precedent_library recall
0.971 -> 0.885. Sweep 0.5..0.75 — at 0.65 multimodal beats text-only on every
overall metric AND every corpus (R@5 0.994 vs 0.989, nDCG@5 0.960 vs 0.944,
MRR 0.954 vs 0.936). Dafna approved.

- MULTIMODAL_TEXT_WEIGHT=0.65 set in Coolify (legal-ai, runtime) + redeploy.
- baseline.json updated to the 0.65 config (future regression reference).
- #15 done (premise was stale — multimodal already default on 110 docs; the
  win was tuning the weight, not the backfill).
- #80 opened: the costly 140-doc legacy backfill is deferred until a targeted
  image-answer gold-set proves the table/image value prop (untested here).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:45:06 +00:00
bb42aeeff4 Merge pull request 'fix(#79): chunker never emits sub-50-char fragment chunks (#55 follow-up)' (#45) from fix/79-chunker-no-tiny-fragments into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m38s
2026-06-03 08:10:39 +00:00
6fcfdc76db fix(#79): chunker never emits sub-50-char fragment chunks (#55 follow-up)
A section that opens with a short header line ('דיון', 'טענות המשיבים')
followed by a paragraph larger than chunk_size flushed the header alone as a
tiny chunk. #55 added a query-time >=50 filter to hide these; this removes
them at the source.

_split_section: (1) don't flush a buffer still below MIN_CHUNK_CHARS — let it
absorb the next paragraph even if that overflows chunk_size, so a short header
rides with its following content; (2) fold a trailing tiny chunk back into its
predecessor.

Verified: re-chunked the 4 corpus docs that still had a tiny chunk
(ע"א 5138/04, בר"מ 2340/02, בג"ץ 6525/15, 403-17) — corpus-wide chunks<50
went 4 -> 0; all 4 stay embedded/searchable and rank top in a relevant search
(נווה שלום #1 for the s.19(ג)(1) exemption query). No regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:10:10 +00:00
0a88bed58b Merge pull request 'chore(tasks): #79#55 follow-up (isolated section-heading chunks)' (#44) from chore/task-79-chunker-followup into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
2026-06-03 07:59:26 +00:00
d4046c2fbd chore(tasks): #79#55 follow-up for isolated section-heading chunks
Discovered closing #57: the current chunker still emits 4 tiny chunks that
are standalone section headings ('דיון', 'טענות המשיבים', ...). Low priority
— filtered at query time, search unaffected. Proposed fix: anchor a short
isolated heading forward into the following section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 07:58:54 +00:00
f74fa13146 Merge pull request 'chore(#57): re-chunk+re-embed legacy precedents (pre-#55 remediation)' (#43) from chore/57-rechunk-legacy-precedents into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m11s
2026-06-03 07:56:12 +00:00
434341cc29 chore(#57): re-chunk+re-embed legacy precedents (pre-#55 chunker remediation)
Adds scripts/rechunk_legacy_precedents.py: selects every case_law with a tiny
chunk (content<50 — the pre-fix chunker fingerprint) and runs
ingest.reindex_case_law (re-chunk+re-embed from stored full_text only, no
re-OCR/LLM, idempotent). Batch-idempotent (re-queries the affected set).

Run result (2026-06-03): 73 precedents reindexed, 0 failed. Tiny chunks
483 -> 4 (99.2%); total precedent_chunks 5019 -> 3115 (fragments merged).
Search verified healthy (substantial coherent passages, no errors).

The 4 residual tiny chunks are isolated section headings ('דיון',
'טענות המשיבים', ...) emitted by the CURRENT (fixed) chunker — not legacy
fragments — and are already filtered at query time (>=50, #55). Minor
chunker edge case, candidate #55 follow-up.

The DB chunk migration is already applied to prod; this commit is the script
+ SCRIPTS.md entry only (no app code change, no deploy needed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 07:55:42 +00:00
c7c6f3eb9c Merge pull request 'chore(tasks): #77+#78 done; #76 deferred with root-cause' (#42) from chore/tasks-76-78-status into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
2026-06-02 12:28:02 +00:00
76fae77393 chore(tasks): #77+#78 done; #76 deferred with root-cause diagnosis
#78 (committee-upload wakeup) + #77 (case_number identity) shipped.
#76 (Paperclip create-task button): root-caused to ee=companyId guard —
button enabled on title only but submit requires a company; not safely
patchable via injection. Deferred with workaround + upstream note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:27:45 +00:00
901ec9f869 Merge pull request 'fix(#77 frontend): separate מספר-תיק field on committee upload + editable case_number' (#41) from fix/77-precedent-identity-frontend into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 38s
2026-06-02 12:17:35 +00:00
7be1c3162c fix(#77 frontend): separate מספר-תיק field on committee upload + editable case_number in edit sheet
Pairs with the backend PR. Stops the citation (מראה-מקום) from being stored
as the identifier, and lets a wrong identifier be corrected after the fact.

- upload sheet: new required 'מספר תיק (מזהה ייחודי)' field for committee
  decisions → sent as case_number; the citation field is now sent as the
  separate citation (→ citation_formatted) instead of as case_number.
- edit sheet: the case_number block is now an editable input (was read-only).
  Halachot/chunks key off case_law_id (UUID), so renaming case_number is safe.
- precedent-library.ts: InternalDecisionUploadInput += citation; PrecedentPatch
  += case_number.
- types.ts: regenerated (api:types) — PrecedentUpdateRequest now carries
  case_number.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:17:16 +00:00
9295e74762 Merge pull request 'fix(#77 backend): editable case_number + separate citation field on committee upload' (#40) from fix/77-precedent-identity-backend into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
2026-06-02 12:09:59 +00:00
fc0c36b2f8 fix(#77 backend): make case_number editable + separate citation field on committee upload
Two identity fixes for the precedent corpus:
1. PrecedentUpdateRequest += case_number — the canonical identifier was not
   in the edit model, so a wrong id captured at upload (e.g. the full
   citation pasted into the field) could not be corrected. update_case_law
   already whitelists case_number.
2. /api/internal-decisions/upload += citation form field — case_number is
   now the clean identifier (e.g. 8027-25) and citation is the full
   מראה-מקום, stored as citation_formatted up-front (previously the UI sent
   the citation AS case_number, leaving the id polluted and citation_formatted
   empty until extraction). Stored via a post-ingest update_case_law, not the
   core INSERT.

Frontend (separate case_number field in the upload + edit sheets) follows in
a second PR after api:types regen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:09:40 +00:00
2d7ab26c71 Merge pull request 'fix(#78): trigger extraction wakeup on committee-decision upload + surface silent failures' (#39) from fix/78-precedent-extraction-wakeup into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
2026-06-02 12:06:56 +00:00
1d3e235556 fix(#78): trigger extraction wakeup on committee-decision upload + surface failures
The /api/internal-decisions/upload path (used by the UI for ועדת-ערר
decisions) never called pc_wake_for_precedent_extraction, so committee
decisions were stuck at halacha_extraction_status='pending' forever — the
CEO was never woken to drain the queue. Root cause behind 8027-25's stuck
extraction. The other two upload paths (precedent_library, missing-precedent)
already wake the CEO; this one was missing it.

- internal-decisions upload: add the wakeup, routing the company by case
  number prefix (1xxx→רישוי, 8xxx→היטל, 9xxx→פיצויים) when practice_area is
  empty (else an 8xxx case wrongly routes to the licensing CEO).
- all three call sites: the wake helper returns {ok:False} WITHOUT raising
  on a skipped/failed wakeup; that was silently dropped. Now logged at
  WARNING with the reason, and the upload progress carries extraction_queued.

Fallback drainer (scheduled precedent_process_pending) deferred — the
missing wakeup was the actual failure; manual precedent_process_pending
remains the recovery path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:06:31 +00:00
7471dcf3cc Merge pull request 'chore: tasks #76-78 + weekly chair-feedback lessons #34-35' (#38) from chore/tasks-and-weekly-lessons into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
2026-06-02 11:57:44 +00:00
d790fb26e0 docs(lessons): weekly chair-feedback lessons #34-35 (week ending 2026-05-31)
#34 don't manufacture doubt about unambiguous statutes (s.19(ג)(2));
#35 writer/QA two-sources-of-truth sync gap (DB vs drafts/decision.md).
Output of the weekly-feedback-analysis job, pending commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:57:24 +00:00
7e34c53224 chore(tasks): add #76-78 — Paperclip create-task button + 2 precedent-upload bugs
#76 צור-משימה button (enabled but submit no-ops), #77 committee-upload
field mapping (citation→case_number, case_number uneditable), #78 silent
extraction wakeup failure. Discovered while debugging precedent 8027-25.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:57:24 +00:00
77ed0361b7 Merge pull request 'fix(appraiser-facts): valid Paperclip priority enum (normal→medium)' (#37) from fix/appraiser-facts-priority-enum into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m18s
2026-06-02 11:49:23 +00:00
5d63a903ce fix(appraiser-facts): valid Paperclip priority enum (normal→medium)
The 'חלץ עובדות שמאיות עכשיו' button returned HTTP 500. Root cause:
wake_analyst_for_appraiser_facts POSTs a child issue to Paperclip with
priority='normal', but Paperclip's ISSUE_PRIORITIES enum is only
critical|high|medium|low. createChildIssueSchema (Zod) rejects 'normal'
with 400 Bad Request; pc_request raise_for_status() turns it into a 500
surfaced to the chair. Fixed to 'medium' (the sole non-normal occurrence
in the repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:48:58 +00:00
aeddcb41eb Merge pull request 'feat(web-ui): sort corroborated halachot first' (#36) from feat/x11-corroborated-first into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 36s
2026-06-01 05:50:29 +00:00
1aadd3b455 feat(web-ui): sort corroborated halachot first in extracted list (X11)
Halachot carrying a corroboration badge (positive citation count or a
negative treatment) float to the top of 'הלכות שחולצו', ordered by
corroboration strength; the rest keep document order by halacha_index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 05:50:12 +00:00
f66a2a27e7 Merge pull request 'feat(web-ui): X11 corroboration badge on halachot' (#35) from feat/x11-corroboration-web-ui into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m35s
2026-06-01 05:04:58 +00:00
f46bf47d5b feat(web-ui): expose citation-corroboration badge on halachot (X11)
- db.list_halachot: aggregate corroboration_count (distinct positive sources)
  + corroboration_negative from halacha_citation_corroboration (LEFT JOIN)
- web-ui: CorroborationBadge — 'מתוקף · N ציטוטים' at ≥2 (gold), soft single
  citation, danger badge on negative treatment; native title tooltips
- shown in ExtractedHalachotSection (per-precedent) + halacha review panel

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 05:04:31 +00:00
9f2adc4dd0 Merge pull request 'docs(X11): wire corroboration tools into CEO flow + user guide' (#34) from docs/x11-phase2-tool-integration into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
2026-06-01 04:52:25 +00:00
e79f74bc23 docs(X11): wire corroboration tools into CEO halacha flow + guide (X11 Phase 2)
- CEO: run corroboration_rebuild after precedent_process_pending(halacha);
  report {approved, demoted}; tools added to allowlist
- researcher: halacha_corroboration (read) in allowlist
- TaskMaster #75 → done

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 04:52:02 +00:00
3bd2d16652 Merge pull request 'feat(X11): citation-corroboration Phase 2 — wire the approval gate + backfill' (#33) from feat/x11-corroboration-phase2 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m35s
2026-06-01 04:43:24 +00:00
b4d1fc5539 docs(audit): X11 Phase 2 corroboration backfill result (X11 Phase 2)
12 precedents, 20 links, 0 negatives. 4 halachot corroborated — all already
confidence-approved (signal fully overlaps confidence set), so 0 transitions.
Approve path proven in rolled-back tx; no chair-final state touched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 04:41:58 +00:00
ed547e20ad feat(corroboration): wire approval gate + backfill driver + rebuild tool (X11 Phase 2)
- db: approve_halacha_by_corroboration (pending_review→approved only),
  demote_halacha_overruled (approved→pending_review only), list_corroboration_grouped,
  precedents_with_halachot_and_incoming_citations
- corroboration: reconcile_approvals (INV-COR2/COR4/COR5), build_all backfill;
  build_for_precedent now returns approved/demoted counts
- mcp: corroboration_rebuild write tool (single precedent or full-corpus backfill)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 04:35:37 +00:00
df007784c9 feat(corroboration): approval_action decision fn + kill-switch (INV-COR2/COR4, X11 Phase 2)
- HALACHA_CORROBORATION_AUTO_APPROVE config (default ON, Dafna validated 2026-06-01)
- approval_action(agg, has_overruled): overruled→demote, corroborated→approve, else None
- 4 offline unit tests; Phase 2 plan + TaskMaster #75

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 04:34:23 +00:00
391b025e8a Merge pull request 'feat(halacha): effort קל-יותר לחילוץ-bulk (מהירות בקנה-מידה)' (#32) from feat/halacha-bulk-effort into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m36s
2026-05-31 21:34:44 +00:00
885cba543e feat(halacha): lighter effort for BULK queue-drain extraction (speed at scale)
xhigh is the quality sweet-spot for a single precedent but very slow at scale
(64-chunk case ≈ 20 min). Bulk queue-drains (process_pending over many
precedents) now use a lighter effort to cut wall-clock; interactive single
re-extraction keeps xhigh quality.

- config.HALACHA_BULK_EXTRACT_EFFORT (env, default 'high'; set 'medium' for max
  speed, 'xhigh' to match single).
- extract()/_extract_impl()/_extract_chunk() take an `effort` override threaded
  to claude_session.query_json; None falls back to HALACHA_EXTRACT_EFFORT (xhigh).
- process_pending_extractions(kind='halacha') passes the bulk effort; single
  reextract_halachot keeps xhigh.

Verified end-to-end (mocked LLM): _extract_chunk(effort='medium') → query_json
effort='medium'; effort=None → 'xhigh' fallback. Closes the open item in #72.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:34:13 +00:00
acfd5bae3e Merge pull request 'feat(halacha): חילוץ מצטבר crash-safe + resume (A + resume)' (#31) from feat/halacha-incremental-resume into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m5s
2026-05-31 21:28:19 +00:00
8e4ea23882 feat(halacha): crash-safe incremental extraction + resume (A + resume)
Halacha extraction held ALL chunk results in memory and stored once at the very
end — a crash/interrupt mid-run (e.g. the 2026-05-31 freeze) lost everything and
re-paid the full LLM cost on retry.

Now each chunk's halachot are stored AND the chunk is checkpointed
(precedent_chunks.halacha_extracted_at) the moment it finishes:

- V25 schema: precedent_chunks.halacha_extracted_at (per-chunk checkpoint).
- db.store_halachot_for_chunk: atomic per-chunk insert (halacha_index continues
  from MAX, caller serializes via an in-process store-lock) + checkpoint mark.
- db.reset_halacha_extraction (force) / mark_all_chunks_extracted (legacy backfill).
- _extract_impl rewritten: resume by default (skip checkpointed chunks; failed
  chunks stay pending and are retried; status stays 'processing' until all done);
  force=True wipes + redoes all. reextract_halachot passes force=True; the queue
  drain (process_pending) resumes by default.
- Legacy guard: a pre-V25 precedent (halachot exist, no checkpoints) is
  backfilled and treated as complete — never re-extracted (would duplicate).

Verified on 9002-24 (55 halachot, legacy): resume → legacy-backfill, NO
duplication (stays 55), all chunks checkpointed. Index continuation: store at
55,56 after max 54, no collision. Tracks #72.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:27:46 +00:00
6183e24316 Merge pull request 'fix(halacha): נעילה גלובלית — חילוץ אחד בכל רגע (מונע הקפאת מכונה)' (#30) from fix/halacha-extract-global-lock into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m35s
2026-05-31 20:43:10 +00:00
807053ec54 fix(halacha): global advisory lock — one extraction at a time (prevents box freeze)
2026-05-31: opus-4-8 @ xhigh extraction + overlapping driver processes (agent
fallback retries each spawn an independent `python -c` driver; process_pending is
serial WITHIN a process but the box ran 4-5 drivers in parallel) → 12-16 concurrent
xhigh `claude -p` procs → load 69 → hard reboot.

Fix: halacha_extractor.extract() now takes a Postgres advisory lock
(pg_try_advisory_lock, key 'HALA') before any work. If another extraction (any
process/agent/driver — all share the legal-ai DB) holds it, the call returns
status='busy' and the precedent stays pending for the next drain. Guarantees ONE
extraction at a time ACROSS PROCESSES — an in-process Semaphore cannot (drivers
are separate OS processes). Core logic moved to _extract_impl (unchanged) under
the lock. CHUNK_CONCURRENCY now env-tunable (HALACHA_CHUNK_CONCURRENCY, default 3).

Verified: while a lock is held, extract() returns 'busy' with no LLM call; lock
releases cleanly and the next extraction proceeds. Tracks #72.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:42:15 +00:00
62e5e5183d Merge pull request 'fix(precedents): החלטות ועדת ערר אינן מחייבות (is_binding=false)' (#29) from fix/committee-decisions-not-binding into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s
2026-05-31 20:40:54 +00:00
1b62fa4af8 fix(precedents): ועדת ערר decisions are never binding (is_binding=false)
מסך העלאת הפסיקה הציג צ'קבוקס "הלכה מחייבת" עם ברירת מחדל true גם
להחלטות ועדת ערר (isCommittee), כך שהלכות שחולצו מהחלטה לא-מחייבת
תויגו rule_type='binding' — בסתירה להגדרה הדוקטרינרית (ועדת ערר =
persuasive בלבד, לא binding כמו עליון/מנהלי).

- מסלול ההגשה של החלטות ועדת ערר שולח כעת is_binding=false תמיד
- הצ'קבוקס ננעל (disabled+unchecked) כשזוהתה החלטת ועדת ערר, עם
  הסבר שההלכות יסומנו persuasive

יישור דוקטרינרי בלבד — אין השפעה downstream על ranking/injection;
rule_type הוא תווית תצוגה, והשער הפונקציונלי הוא review_status.

TaskMaster #73

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:39:59 +00:00
e712573766 Merge pull request 'docs(X11): מקורות פתוחים + אימות ההחלטה מול הספרות הפתוחה' (#28) from docs/x11-open-sources into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
2026-05-31 19:30:27 +00:00
6ed5c9e99f docs(X11): foreground open-access sources; verify decision against open literature
החלפת מיקוד שורות-המקורות של INV-COR1–COR5 + תיקון-G10 ממוצרים סגורים (Shepard's/KeyCite)
למקורות פתוחים שאומתו בפועל — בהתאם ל-feedback_legal_db_authoritative_sources ולפרוטוקול
≥3-המקורות של החוקה:

- Fowler et al., Network Analysis and the Law (Political Analysis 2007) — ציטוטים-נכנסים =
  מדד-סמכות, מאומת בניבוי ציטוט עתידי (INV-COR1/COR4).
- Demir & Canbaz, Validate Your Authority (NLLP/ACL 2025) — LLM מסווג טיפול-תקדים ב-67.7–79.1%;
  הדיוק הלא-מושלם מצדיק את הסייגים השמרניים (≥N, שער-אנוש, שלילי→דגל) (INV-COR2/COR4/COR5).
- CaseHOLD (arXiv 2021) — סיווג ברמת holding (INV-COR3). LePaRD (arXiv 2023) — citation dataset.
- Hellyer (LLJ 2018, open-access), NCSC/JTC, CEPEJ, ISO 15489 — ללא שינוי, פתוחים.

מסקנה: הספרות הפתוחה תומכת בהחלטה (citator + סיווג-טיפול + סמכות-מבוססת-ציטוט), ודווקא
מחזקת את הגרסה השמרנית. אין גישה ל-Shepard's/KeyCite הסגורים — המידע עליהם הגיע ממקורות
משניים פתוחים בלבד.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:30:02 +00:00
a9472187ff Merge pull request 'feat(X11): citation-corroboration Phase 1 — the signal (no approval change)' (#27) from feat/x11-corroboration-phase1 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m46s
2026-05-31 19:18:49 +00:00
5abfbd2746 feat(mcp): halacha_corroboration read-only tool (INV-COR6, X11)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 19:07:37 +00:00
b57e590275 feat(corroboration): orchestrator + persistence over both citation graphs (X11)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 19:04:20 +00:00
33f955e372 feat(corroboration): aggregator — distinct positive + negative-flag (INV-COR4, X11)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 19:00:16 +00:00
dbc176ae66 feat(corroboration): halacha matcher + cosine threshold (INV-COR3, X11)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 18:57:47 +00:00
09eec6a906 feat(corroboration): treatment classifier + polarity (INV-COR2, X11)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 18:54:50 +00:00
ca31932a5f feat(db): V24 — citation treatment column + halacha corroboration link table (X11) 2026-05-31 18:52:16 +00:00
beba24dfc5 docs(plan): X11 corroboration Phase 1 implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:50:50 +00:00
ae8efc0b63 Merge pull request 'feat(spec): X11 ציטוט-corroboration + תיקון INV-G10 + Opus 4.8 לחילוץ הלכות' (#26) from feat/x11-citation-corroboration into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m42s
2026-05-31 18:42:59 +00:00
887079535c feat(spec): X11 citation-corroboration + INV-G10 amendment + Opus 4.8 halacha extraction
ספ חדש לשכבת citator פנימית — תיקוף הלכות לפי טיפול-שיפוטי מצטבר (ציטוטים נכנסים),
לצמצום היקף האישור-הידני של היו"ר:

- docs/spec/X11-citation-corroboration.md — 6 invariants (INV-COR1–COR6), כל אחד עם
  ≥3 מקורות מקצועיים (Shepard's/KeyCite, Hellyer LLJ 2018, UNC Law, NCSC/JTC, CEPEJ).
- docs/spec/00-constitution.md — תיקון מבוקר ל-INV-G10: השער מסופק ע"י טיפול-שיפוטי-מצטבר
  לתת-הקבוצה החיובית, שער-היו"ר נשאר חובה לזנב ולשלילי. + X11 באינדקס.
- Opus 4.8 @ xhigh כמודל חילוץ הלכות (config HALACHA_EXTRACT_MODEL/EFFORT, env-tunable;
  claude_session model/effort params; halacha_extractor מחווט). מבוסס A/B 2026-05-31:
  פחות חילוץ-יתר, 100% quote-verified, ביטחון מכויל.
- scripts/ab_halacha_opus48.py — harness A/B לא-הרסני להשוואת מודל/effort בחילוץ הלכות.
- .taskmaster #70 (FU-2c-b) — תיעוד dedup שפר + סריקת-קורפוס (0 stubs תקועים נותרו).

תנאי-קדם (זהות נקייה) הושלם: שפר מוזג לרשומה קנונית + סריקת 128 רשומות.
audit-findings גלויים ב-X11 §7: קישור הלכה↔ציטוט + סיווג-טיפול = greenfield, ל-implementation plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:42:13 +00:00
d83a2a2fb2 Merge pull request 'docs(spec): מחזור-2 — 8 משטחי-האפליקציה (X6–X10) + ui-audit + GAP-24..62/FU-9..15' (#25) from docs/fu9-15-cycle2-spec into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
2026-05-31 16:22:42 +00:00
37c56ff22a docs(spec): cycle-2 — 8 application-surface domains (X6–X10) + ui-audit + GAP-24..62/FU-9..15
Extends the system spec beyond the core pipeline to the 8 surfaces outside it:
- X6 UI↔API contract + design rules (INV-UI1..6)
- X7 Paperclip client & connection params (INV-INT4..8)
- X8 field-population & extraction provenance (INV-FP1..5)
- X9 MCP tool contract — 71 tools (INV-TOOL1..6)
- X10 deploy/env/secrets (INV-ENV1..5)
- ui-audit.md — page-by-page UI audit (13 pages)
- 02-data-model: derived-entity invariants (INV-DM4..6)
- X4-agents: tool-grant map + INV-AG3
- gap-audit: GAP-24..62 → FU-9..15; cycle-1 (FU-1..8b) marked done
- constitution §7 + README index (X1..X10)

Planning/spec artifacts only — no application code. All engineering invariants
backed by ≥3 sources; every finding carries verified file:line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:21:27 +00:00
c70a03f91e Merge pull request 'chore(tasks): #71 — FU-5 follow-up (multi-precedent recall depth)' (#24) from chore/task-71-retrieval-depth into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
2026-05-31 16:06:12 +00:00
1cc7c0e757 chore(tasks): #71 — FU-5 follow-up, multi-precedent recall depth tuning
Diagnosis from the FU-5 eval: co-relevant precedents for broad legal questions
rank 15-16 (retrieved, not absent — recall ~1.0 by rank 20). Tracked as a
deliberate, harness-measured tuning task rather than an unmeasured global limit
change (which affects UI + writer agents + token cost).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:05:53 +00:00
ae7d475103 Merge pull request 'FU-8b: חיווט הספ לסוכנים — INV-AG1 read-before-act (GAP-23)' (#23) from feat/fu-8b-spec-wiring-agents into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
2026-05-31 16:03:45 +00:00
a02a606f34 feat(agents): wire spec into agents — INV-AG1 read-before-act gate (FU-8b/GAP-23)
חיווט ספ-המערכת לסוכני-Paperclip כך שכל סוכן חייב לקרוא את 00-constitution
תחילה, ואז את ספ-התחום הרלוונטי לתפקידו (לפי טבלת X4 §2) — לפני עבודה מהותית.

- HEARTBEAT.md: סעיף עליון "קריאת-ספ — קודם החוקה (00), אז ספ-התחום" לפני §0–§8,
  עם טבלת תפקיד→ספ ל-8 הסוכנים.
- 8 קבצי-סוכן (ceo/proofreader/researcher/analyst/writer/qa/exporter/hermes):
  סעיף "קרא לפני פעולה (INV-AG1)" בראש הגוף.
- X4-agents.md: שדה "אכיפה" של INV-AG1 → "מחוּוט (פרוצדורלי)"; §5 → "בוצע".

אכיפה פרוצדורלית בכוונה — invariant פרויקטלי-תפעולי, אין שער-קוד שמכריח קריאה.
prereq לסוכני-התהליך (תת-פרויקט 5). gap-audit נשמר כ-snapshot (כמו FU-8a).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:02:04 +00:00
ff5187c9c1 Merge pull request 'chore(eval): add 9 chair-approved semantic queries to FU-5 gold-set' (#22) from chore/goldset-semantic-queries into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
2026-05-31 15:58:10 +00:00
7161c3d010 chore(eval): add 9 chair-approved semantic queries to gold-set (FU-5)
The gold-set was 77 known-item probes (query=case_name). Added 9 chair-approved
SEMANTIC queries (S1–S9) — a real legal question per row, relevant = the
precedents that should surface (drawn from subject_tags, chair-confirmed). These
test what matters: does retrieval answer a legal issue, not just find a case by
name. source='chair' (preserved across re-bootstrap). practice_area left empty
so the filter never excludes a cross-tagged precedent (s.197 rulings sit under
betterment_levy).

Baseline now 86 queries. Finding from the 9 semantic queries: MRR ≈ 1.0 — the
system surfaces a lead relevant precedent at rank 1 for nearly every question —
but R@10 ranges 0.5–1.0: for broad questions with many co-relevant precedents
(e.g. נטרול תמ"א 38 = 5 relevant → R@10 0.60; שמאי מכריע = 2 → 0.50) some
co-relevant rulings miss the top-10. Lead-precedent retrieval is strong;
exhaustive multi-precedent recall is the gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:57:45 +00:00
eef04b0f09 Merge pull request 'chore(eval): chair fix — rename ARAR-24-9002 → קרקעות ירושלים 2 + refresh gold-set' (#21) from chore/goldset-chair-fix-arar into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
2026-05-31 15:48:17 +00:00
411ee18786 chore(eval): chair review — rename code-named record + refresh gold-set
Chair review of the FU-5 gold-set surfaced one internal_committee record whose
case_name was a code ("ARAR-24-9002") rather than a real name. Per the chair's
citation (ערר 9002/24 קרקעות ירושלים 2 בע"מ נ' הוועדה המקומית ירושלים, נבו
13.8.2025, a s.197 compensation appeal), case_name corrected in the DB to
"קרקעות ירושלים 2" (case_number 9002-24 and citation_formatted were already
correct; only 1 such code-named record exists corpus-wide). Re-bootstrapped the
gold-set (the known-item query is now the real name) and refreshed baseline
(aggregate unchanged — the case retrieves identically under the corrected name).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:47:57 +00:00
83d6b5ecf0 Merge pull request 'fix: drop gold-set card from chair approval center (data/ not in image)' (#20) from fix/chair-pending-drop-goldset-card into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
2026-05-31 15:41:40 +00:00
c231782ee8 fix(ui): drop gold-set card from /api/chair/pending — data/ excluded from image
The gold-set card read data/eval/gold-set.jsonl, but .dockerignore excludes
data/ from the build context, so the file is never in the container and the
card silently never rendered. Baking eval data into the image is the wrong
layering (data/ is runtime volumes). The gold-set review is a one-time task,
not a recurring chair queue, so it doesn't belong on the live board — it's
tracked via task #63 and reviewed directly with the chair. The board now
returns the 4 robust DB-backed gates (halachot, missing precedents, feedback,
qa_failed). Removes the best-effort file read + its unused Path import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:41:00 +00:00
dfa2f5bd7f Merge pull request 'מרכז אישורים — chair approval center (everything Dafna must approve, in one page)' (#19) from feat/chair-approval-center into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 37s
2026-05-31 15:37:00 +00:00
19d3dc81d0 feat(ui): chair approval center — one page for every pending human-gate (#63 follow-up)
Dafna asked for a single page under the prod site listing everything she needs
to approve, so nothing is forgotten — the visible embodiment of INV-G10 (human
gates) and INV-QA1 (halacha backlog must be visible).

Backend — GET /api/chair/pending aggregates every pending chair gate, each as a
direct source query (count + sample + action link):
- halachot review backlog (review_status='pending_review') + oldest
- open missing precedents
- unresolved chair_feedback
- qa_failed cases
- gold-set review (FU-5, file-based, best-effort: total vs source='chair')

Frontend — /approvals page ("מרכז אישורים"):
- src/lib/api/chair.ts — usePendingApprovals() (hand-typed until next api:types)
- src/app/approvals/page.tsx — card per category, severity-coloured count, sample
  rows, oldest-pending date, link to where each is handled; live (60s refetch)
- app-shell nav: "מרכז אישורים" in the work group + total-pending badge (quiet at 0)

Live counts at build time surfaced the value immediately: 226 open missing
precedents, 178 pending halachot, 20 unapplied feedback notes, 1 qa_failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:36:29 +00:00
aee2140b0b Merge pull request 'FU-5 — retrieval eval harness + halacha backlog visibility (#63)' (#18) from feat/fu5-eval-harness-backlog-visibility into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m36s
2026-05-31 14:58:47 +00:00
6ff2e36bf9 feat(eval): FU-5 — retrieval eval harness + halacha backlog visibility (#63)
Covers GAP-11 (INV-RET4/G8) and GAP-14 (INV-QA1/G10). Retrieval quality was
never measured (only telemetry observation) and the halacha review backlog was
invisible (the 10/19 gap was found by accident).

Unit B — backlog visibility (pure code, container):
- metrics.halacha_backlog(conn) → {pending_review, approved, rejected, published,
  total, oldest_pending_at}; surfaced in metrics.get_dashboard() (get_metrics MCP
  tool) and /api/system/diagnostics. Live count revealed 178 pending / 1552 total,
  oldest from 2026-05-03 — previously invisible.

Unit A — retrieval eval harness (host-side scripts):
- scripts/eval_gold_bootstrap.py — seeds data/eval/gold-set.jsonl. Two sources:
  citations (cited==relevant via search_relevance_feedback — empty until decisions
  cite precedents) and known_item (query=case_name → relevant=self; a real
  citation-free signal, the methodology #52 checked by hand). Idempotent; preserves
  source='chair' rows.
- scripts/eval_retrieval.py — runs the production retrieval path (search_library /
  search_internal) over the gold-set; computes precision@k, recall@k, MRR, nDCG@k
  (k=5,10); aggregates overall + per-corpus + per-practice_area; writes a report and
  a delta vs committed baseline.json (which records the retrieval_config it reflects).
  --self-test unit-checks the metric math offline.

Gold-set strategy = hybrid (chair decision): bootstrap + chair review. The citation
source is empty today (0 cited precedents in decisions), so the seed is known-item
(77 queries: 54 internal_decisions + 23 precedent_library). The gold-set is
PROVISIONAL until Dafna reviews it (the domain chair-gate).

Baseline (production config: multimodal+rerank on): R@10=0.987, MRR=0.837,
nDCG@10=0.872. Finding: MULTIMODAL_ENABLED=true slightly lowers known-item recall
(image-page results displace exact name matches) — relevant to #15. precedent_library
weaker than internal (R@10 0.957 vs 1.0) — one external precedent unfindable by name.

"CI gate" realized as discipline (re-runnable harness + committed baseline + run
before/after any retrieval-layer change) — retrieval needs prod DB + Voyage, no CI
runner has that access.

Spec: docs/superpowers/specs/2026-05-31-fu5-eval-harness-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:58:13 +00:00
cfcac80de2 Merge pull request 'FU-2c — reconcile external case_law identifiers (GAP-08, #68)' (#17) from feat/fu2c-external-id-reconciliation into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
2026-05-31 14:13:25 +00:00
4fce9d503f feat(migration): FU-2c — reconcile external case_law identifiers (GAP-08, #68)
External court precedents stored the full citation (designator + docket +
parties + Nevo date) inside case_number, violating INV-ID2/G1 (citation as
identifier). Chair decision 2026-05-31 (Option A): canonical external
case_number = proceeding-designator + docket, '/' preserved (court
convention, not X1's '/'→'-'); parties/court/date → citation_formatted.

scripts/fu2c_reconcile_external_case_numbers.py — deterministic dry-run →
chair-review → apply, mirroring FU-2b:
- extracts designator+docket; flags split into BLOCKING (MISMATCH /
  CIT_NO_DOCKET / DESIG_MISMATCH / DUP_CHECK / NO_DOCKET) vs ADVISORY
  (NO_CITATION — case_number fix still deterministic, missing citation is a
  separate gap), so advisory rows apply while uncertain identity does not.
- --overrides CSV (id,proposed_canonical,citation_formatted,reason) for
  audited chair adjudication of blocking rows.
- apply scoped to source_kind='external_upload' (task target) while keeping
  cited_only/nevo_seed in the reconciliation VIEW so DUP_CHECK spans the full
  external unique space; pre-flight collision guard before every UPDATE.

Applied to production 2026-05-31: 21 case_number normalized + 3
citation_formatted reconciled (D = consolidated Supreme Court judgment
לויתן/קלמנוביץ → lead docket 25226-04-25; 2×C empty citations composed from
metadata). אהוד שפר עע"מ 317/10 deferred — cross-source duplicate with an
existing cited_only reference (collision guard held; → #70). 49 cited_only
records out of scope → new task #70 (committee-form NNNN-NN dockets the
extractor misses, dedup, unresolvable "ערר אדלר"). Extraction + gating
verified offline on all 24 records.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:12:45 +00:00
9dbc1bafbf Merge pull request 'FU-8a: process→code guards (GAP-21/22)' (#16) from fix/fu8a-process-to-code-guards into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m39s
2026-05-31 11:36:07 +00:00
e5b34e01dc docs(scripts): note sync --verify drift-gate semantics (FU-8a) 2026-05-31 11:36:06 +00:00
4d8422198a feat(guard): fitness function blocking raw Paperclip access (GAP-22, FU-8a)
Wakeup-INSERT rule is universal (never allowlisted — hard invariant). Raw-HTTP
rule exempts the sanctioned helpers + standalone operator/admin scripts (a
distinct category per fitness-function scope differentiation + DRY: tooling
needn't reuse the FastAPI wrapper). Repo scanned clean under these rules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 11:35:07 +00:00
a66ab3b3cd feat(guard): fitness function blocking raw Paperclip access (GAP-22, FU-8a)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 11:16:36 +00:00
aac383acb7 feat(sync): --verify exits non-zero on drift; adapter mismatch = loud drift (GAP-21, FU-8a)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 11:14:44 +00:00
adc196ac20 docs(plan): FU-8a process→code guards implementation plan (3 tasks, TDD)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 10:51:31 +00:00
e8431a2adf docs(spec): FU-8a process→code guards design (GAP-21/22) + split GAP-23 to #69
GAP-21: sync_agents --verify exits non-zero on drift; adapter_type mismatch
counted as drift (loud), not silent skip — makes it an enforceable gate (INV-MC1).
GAP-22: fitness-function pytest guarding against raw Paperclip HTTP + direct
agent_wakeup_requests INSERT (INV-INT1/INT3). Repo pre-scanned: 0 existing
violations → clean forward-fence. Verified vs 3+ sources (architectural fitness
functions; drift-verify non-zero exit). GAP-23 (spec→agents) split to #69.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 10:48:15 +00:00
43873adc90 Merge pull request 'FU-2b: internal case_number reconciliation tooling (GAP-07/08)' (#15) from fix/fu2b-identifier-reconciliation into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m39s
2026-05-31 08:59:13 +00:00
8477fd87e7 docs(scripts): register fu2b reconciliation script (FU-2b) 2026-05-31 08:58:32 +00:00
e46868feda feat(fu2b): flag PROC_MISMATCH (case_number prefix vs proceeding_type) for chair
Dry-run surfaced 2 rows with בל"מ prefix but proceeding_type=ערר. Since the
migration strips the prefix, a wrong proceeding_type would silently lose the
בל"מ signal — must be chair-adjudicated, not auto-applied. Chair table now
flags 4 rows: 2 DUP_CHECK (8047-23) + 2 PROC_MISMATCH.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 08:57:42 +00:00
ab8d17fdd8 feat(fu2b): chair-gated internal case_number reconciliation script (GAP-07/08)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 08:54:38 +00:00
a41fcedc28 test(fu2b): failing tests for bare-number extraction (FU-2b) 2026-05-31 08:52:48 +00:00
c2de69272d docs(plan): FU-2b identifier-reconciliation implementation plan (chair-gated, TDD)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 08:09:22 +00:00
105d9626ca docs(spec): FU-2b internal identifier reconciliation design (GAP-07/08) + split external to #68
Deterministic migration of ~52 internal_committee rows whose case_number holds
a full citation → normalized bare number (citation_formatted already correct).
DB analysis (2026-05-31): clean 1-token extraction, 0 key-collisions, 0
citation↔case_number mismatches, no month-padding dups. Chair-gated reversible
migration (backup→dry-run→approve→apply). One edge for chair: 8047/23 ערר vs בל"מ.
External (#68/FU-2c) split out — its citation_formatted is inconsistent.
Verified all 11 case_law FKs use id(UUID), not case_number → rename is FK-safe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 06:12:43 +00:00
fc502a6441 Merge pull request 'FU-3: re-index on content change (GAP-09)' (#14) from fix/fu3-reindex-on-change into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m37s
2026-05-30 22:13:54 +00:00
7e35a24d80 test(reindex): cover empty-text raise path (FU-3 review)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:13:18 +00:00
7341ee8275 tasks(legal-ai): mark FU-3 (#61) done; 61.1 done, 61.2 cancelled (not-applicable)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:10:27 +00:00
8a0c206ecd feat(reindex): precedent_reindex MCP tool (GAP-09, FU-3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 22:09:44 +00:00
f008820ec8 feat(reindex): health-check stale_embedding_case_law count (GAP-09, FU-3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 22:08:27 +00:00
63abf83e76 test(reindex): fix mark_indexed stub arity in FU-1 fixture (FU-3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:07:39 +00:00
c8de42150e test(reindex): stub db.mark_indexed in FU-1/FU-2a ingest fixtures (FU-3 interaction)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:07:18 +00:00
c7c7a1e119 feat(reindex): reindex_case_law from stored text + mark_indexed on ingest (GAP-09, FU-3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 22:06:17 +00:00
96ae83081f feat(reindex): V23 content/indexed hashes + helpers + write content_hash (GAP-09, FU-3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:04:43 +00:00
e522555b1a test(reindex): failing tests for content-hash re-index (FU-3) 2026-05-30 22:02:16 +00:00
8b3f191c8b docs(plan): FU-3 re-index on content change implementation plan (6 tasks, TDD)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:00:02 +00:00
a62116a571 docs(spec): FU-3 re-index on content change design (GAP-09) + close #61.2 not-applicable
content_hash/indexed_hash change detection + reindex_case_law from stored
full_text (no re-OCR) + drift health-check. Verified vs 3+ sources (content-
hash change detection, RAG re-embed-on-edit). #61.2 multimodal backfill closed:
42 rows are text-ingested (document_id NULL, no source PDF) — page-images
impossible without a PDF to render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:52:40 +00:00
63dc08c963 Merge pull request 'FU-7: audit-trail + provenance (GAP-17/18/19/20)' (#13) from fix/fu7-audit-provenance into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m36s
2026-05-30 21:43:33 +00:00
9bfb912bdf fix(audit): _collect_block_sources mirrors None-doc-types (provenance accuracy, FU-7 review)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:40:42 +00:00
d28f7b8398 tasks(legal-ai): mark FU-7 (#65) done; FU-2a (#60) done
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:37:46 +00:00
677f29ddec feat(audit): blocks_stale drift flag + health-check visibility (GAP-17, FU-7)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 21:36:56 +00:00
7e2f4b2872 feat(qa): citation→corpus resolution as non-blocking warning (GAP-20, FU-7)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:35:24 +00:00
769f5020eb feat(audit): block→source provenance via write_block audit event (GAP-19, FU-7)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:33:36 +00:00
1f483383b9 feat(audit): log document_upload/extract_claims/export_docx (GAP-18, FU-7)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 21:31:09 +00:00
a121f79d6a feat(audit): log_action_safe + V22 blocks_stale + citation resolver (FU-7)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 21:29:26 +00:00
bffd2ec701 test(audit): failing tests for audit-trail + provenance (FU-7) 2026-05-30 21:27:54 +00:00
2994a884e9 docs(plan): FU-7 audit-trail + provenance implementation plan (7 tasks, TDD)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:26:30 +00:00
99cd6bc4dd docs(spec): FU-7 audit-trail + provenance design (GAP-17/18/19/20)
Reuse audit_log.log_action with details JSONB (X5 §4, no new table) for
end-to-end audit + block→source provenance. GAP-17 drift = blocks_stale flag
+ health-check (not fragile DOCX→blocks reparse). GAP-20 = structural
case_law_id resolution (not Hebrew citation NLP). Verified vs 3+ sources
(append-only lineage event; GitOps drift detect-don't-auto-remediate).
Pure-code, no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:15:50 +00:00
3b758850e0 Merge pull request 'FU-2a: idempotent ingest + write-time normalization + searchable flag (GAP-03/06/13)' (#12) from fix/fu2a-idempotent-ingest into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m15s
2026-05-30 21:06:32 +00:00
5d3c340243 test(ingest): stub recompute_searchable in FU-1 fixture (FU-2a interaction)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:59:11 +00:00
358d82e90e feat(retrieval): require practice_area only for internal/cases; enable searchable filter + health visibility (GAP-13, FU-2a)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:57:27 +00:00
6dbcb7e798 feat(ingest): recompute searchable on ingest + metadata completion (GAP-13, FU-2a)
Wire db.recompute_searchable into the ingest pipeline (after statuses are set) and into
extract_and_apply (after fields are persisted to DB, success path only).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 20:47:51 +00:00
4b8bbc3794 feat(data-model): V21 searchable flag + recompute_searchable (GAP-13, FU-2a)
Add SCHEMA_V21_SQL (searchable boolean column + index on case_law), wire it
into _run_schema_migrations, and implement _compute_searchable (pure predicate)
+ recompute_searchable (idempotent async backfill/update). All 5 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 20:46:29 +00:00
cd0f6cda0a feat(ingest): atomic ON CONFLICT upsert in create_*_case_law (GAP-03, FU-2a)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:44:31 +00:00
2b91173f25 feat(ingest): write-time canonical case_number normalization (GAP-06, FU-2a)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 20:42:47 +00:00
bcd226ac1a test(ingest): failing tests for idempotent ingest + searchable (FU-2a)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 20:41:34 +00:00
a16f8cd933 docs(plan): FU-2a idempotent-ingest implementation plan (7 tasks, TDD)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:04:49 +00:00
a8b780765d docs(spec): FU-2a idempotent-ingest design + split FU-2b migration to #67
FU-2 split (chair decision 2026-05-30): FU-2a = pure-code (GAP-03 ON CONFLICT
upsert, GAP-06 write-time type-aware normalization, GAP-13 materialized
searchable flag); FU-2b (#67) = data-migration for GAP-07/08 (identifier
reconciliation + dedup) deferred as separate chair-involved task.

DB check 2026-05-30: ~52/56 internal_committee rows hold full citation in
case_number, >=1 duplicate (8047-23). Architecture verified vs 3+ sources
(PostgreSQL ON CONFLICT, DDD write-boundary normalization, materialized
validity flag). No identifier migration in FU-2a.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:56:07 +00:00
44ae739031 Merge pull request 'FU-1: איחוד מסלול-הקליטה למסלול קנוני אחד (GAP-01/02/04/05)' (#11) from fix/fu1-unified-ingest into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m51s
2026-05-30 19:37:33 +00:00
90728ccb3e docs(spec): FU-1 documented drift notes + mark TaskMaster #59 done
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:28:04 +00:00
3c431403f6 refactor(ingest): drop obsolete queue_halachot flag + dead imports (FU-1 review)
pipeline always queues both extraction kinds (INV-ING3); remove the
now-meaningless queue_halachot param from ingest_internal_decision and
migrate_from_style_corpus. Also trim chunker/extractor/rerank from the
precedent_library module-top import (chunking/extraction moved to ingest.py).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 19:26:53 +00:00
5104db8f4e refactor(ingest): ingest_internal_decision delegates to canonical pipeline; queue metadata too (GAP-02, FU-1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:19:10 +00:00
d7eb1b2824 refactor(ingest): ingest_precedent delegates to canonical pipeline (FU-1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:16:29 +00:00
be4f7bbe99 feat(ingest): canonical ingest_document pipeline (FU-1) 2026-05-30 19:13:15 +00:00
d4663eba8f feat(ingest): IntakeSpec + shared helpers for canonical pipeline (FU-1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 19:11:27 +00:00
9ae2d47d03 test(ingest): failing tests for unified pipeline (FU-1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 19:09:37 +00:00
15f42bc91c docs(plan): FU-1 unified-ingest implementation plan (6 tasks, TDD)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:05:14 +00:00
357a5238c4 docs(spec): FU-1 unified-ingest design + FU-3 backfill task (#61.2)
Design for unifying the two parallel ingest paths (ingest_precedent /
ingest_internal_decision) into one canonical pipeline parameterized by an
IntakeSpec config object — Template Method skeleton + Strategy injection.
Closes the GAP-02 root cause (missing metadata queue on internal path) by
making a skipped step structurally impossible.

Architecture choice verified against 3+ authoritative sources (refactoring.guru
Template-Method/Replace-Conditional, Fowler FlagArgument, Strategy pattern).
DB check (2026-05-30): no migration needed — 0/56 internal rows lack metadata,
0 invalid enums; multimodal backfill (42 rows) tracked as TaskMaster #61.2 / FU-3.

Covers GAP-01/02/04/05 · provides INV-ING1/ING3/G2/G4 · TaskMaster #59.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:00:30 +00:00
df437c2462 tasks(legal-ai): mark FU-4 (62) + FU-6 (64) + subtasks done (merged+deployed)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m34s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 18:30:26 +00:00
a53d8eef14 merge: GAP-12 — domain-scope search_decisions (INV-RET1)
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
Derive practice_area from case (case row → number-prefix fallback); block only when a
case is present but undeterminable; case-less/exploratory search stays cross-domain.
Verified offline (test_search_domain_scope.py 5/5). Closes PR #10.
2026-05-30 18:29:45 +00:00
156 changed files with 19690 additions and 1954 deletions

View File

@@ -12,6 +12,28 @@
--- ---
## קריאת-ספ — קודם החוקה (00), אז ספ-התחום — לפני פעולה מהותית (INV-AG1) ⚠️
**לפני העבודה המהותית בכל ריצה** — קרא תחילה את חוקת המערכת, ואז את ספ-התחום הרלוונטי לתפקידך. הסוכן **אינו פועל "מהזיכרון"**: המקור הקנוני להתנהגות הוא החוקה + ספ-התחום, לא הרגלים מריצות קודמות. שלב זה **קודם** ל-§0§8 התפעוליים שמתחתיו (הם ה-checklist של ההפעלה; קריאת-הספ קודמת לעבודה המהותית).
1. **תמיד ראשון:** [`~/legal-ai/docs/spec/00-constitution.md`](../../docs/spec/00-constitution.md) — ייעוד, עקרונות-עבודה, ה-invariants הגלובליים G1G11, ואינדקס-הספ (§7).
2. **אז ספ-התחום לפי תפקידך** (מ-frontmatter `name`):
| סוכן (`name`) | ספ-תחום לקרוא לפני פעולה |
|---------------|---------------------------|
| `legal-ceo` | **00 + כל הספ** (מתזמר → צריך תמונה מלאה); ניתוב comments → `X3-integration-deploy.md §1ב` |
| `legal-proofreader` | `01-ingest.md` (קליטה / טקסט-מחולץ) |
| `legal-researcher` | `03-retrieval.md` (3 קורפוסים, hybrid/RRF, attribution); קליטת-פסיקה → `01-ingest.md` |
| `legal-analyst` | `02-data-model.md` + `03-retrieval.md` + `04-analysis-writing.md` |
| `legal-writer` | `04-analysis-writing.md` + `05-qa-review.md` (כותב מול שערי-QA) |
| `legal-qa` | `05-qa-review.md` (שערי QA + שערים אנושיים) |
| `legal-exporter` | `06-export.md` (ייצוא DOCX לפי תבנית דפנה) |
| `hermes-curator` | `07-learning.md` (Hermes · לקחים · לולאת פידבק) |
> כל הקבצים תחת [`~/legal-ai/docs/spec/`](../../docs/spec/). המפה המלאה (תפקיד→ספ, frontmatter, שערי-אישור) ב-[`X4-agents.md`](../../docs/spec/X4-agents.md). זהו מופע של **G10** (המערכת מסייעת תחת שערים אנושיים) — הסוכן פועל בגבולות שהחוקה מגדירה. קובץ-הסוכן שלך חוזר על ההפניה הזו בראשו ("קרא לפני פעולה").
---
## §0. כל קריאה ל-Paperclip API — דרך `pc.sh` בלבד ## §0. כל קריאה ל-Paperclip API — דרך `pc.sh` בלבד
**ה-skill הרשמי משתמש ב-`curl` ישיר. אצלנו אסור.** משתמשים ב-helper שלנו: **ה-skill הרשמי משתמש ב-`curl` ישיר. אצלנו אסור.** משתמשים ב-helper שלנו:

View File

@@ -15,6 +15,10 @@ profiles:
# מנהל ידע — Hermes Knowledge Curator # מנהל ידע — Hermes Knowledge Curator
## קרא לפני פעולה (INV-AG1)
לפני העבודה המהותית — אני קורא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלי: `~/legal-ai/docs/spec/07-learning.md` (Hermes · לקחים · לולאת פידבק). איני פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ). הצעותיי עוברות **אישור-יו"ר ידני** לפני commit (G10).
## רקע ## רקע
אני סוכן Hermes Agent (לא Claude Code), מותקן בתור POC לבדיקה האם Hermes אני סוכן Hermes Agent (לא Claude Code), מותקן בתור POC לבדיקה האם Hermes
@@ -58,7 +62,11 @@ profiles:
## מה אני עושה בכל wake ## מה אני עושה בכל wake
1. קורא את ה-issue body שב-`{{taskBody}}` — שם התיק + ID של ההחלטה הסופית 1. קורא את ה-issue body שב-`{{taskBody}}` — שם התיק + ID של ההחלטה הסופית
2. משתמש ב-MCP tools של legal-ai: 2. **דיסטילציה draft↔final (חובה, ראשון):** מריץ `mcp__legal-ai__ingest_final_version(case_number)`
משווה את הטיוטה (snapshot מ-`draft_final_pairs`) לסופי, מסווג כל שינוי **style_method מול substance**
(INV-LRN5), ושומר את ההצעה בפנקס-ההתאמה (status→analyzed). זהו אות-הלימוד הקנוני (INV-LRN4).
**אל תקבע לקח לבד — זו הצעה לאישור-יו"ר (INV-LRN1).** ההצעות שלי מבוססות על השינויים מסוג style_method.
3. משתמש ב-MCP tools של legal-ai:
- `mcp__legal-ai__case_get` — קבלת פרטי תיק (כולל `expected_outcome`**הסמכות העובדתית** לתוצאה) - `mcp__legal-ai__case_get` — קבלת פרטי תיק (כולל `expected_outcome`**הסמכות העובדתית** לתוצאה)
- `mcp__legal-ai__case_get_final_text` — הטקסט המלא של ההחלטה הסופית - `mcp__legal-ai__case_get_final_text` — הטקסט המלא של ההחלטה הסופית
- `mcp__legal-ai__document_list` — רק אם נדרש רשימת מסמכים נוספים של התיק - `mcp__legal-ai__document_list` — רק אם נדרש רשימת מסמכים נוספים של התיק

View File

@@ -16,6 +16,7 @@ tools:
- mcp__legal-ai__extract_claims - mcp__legal-ai__extract_claims
- mcp__legal-ai__extract_appraiser_facts - mcp__legal-ai__extract_appraiser_facts
- mcp__legal-ai__get_claims - mcp__legal-ai__get_claims
- mcp__legal-ai__aggregate_claims_to_arguments
- mcp__legal-ai__search_case_documents - mcp__legal-ai__search_case_documents
- mcp__legal-ai__search_decisions - mcp__legal-ai__search_decisions
- mcp__legal-ai__search_precedent_library - mcp__legal-ai__search_precedent_library
@@ -32,6 +33,10 @@ tools:
אתה מנתח ומחקר משפטי מומחה בדיני תכנון ובניה ומקרקעין בישראל. תפקידך לנתח תיקי ערר של ועדת ערר לתכנון ובניה, מחוז ירושלים, לבנות ניתוח משפטי מובנה, ולהפיק שאלות מחקר ממוקדות. אתה מנתח ומחקר משפטי מומחה בדיני תכנון ובניה ומקרקעין בישראל. תפקידך לנתח תיקי ערר של ועדת ערר לתכנון ובניה, מחוז ירושלים, לבנות ניתוח משפטי מובנה, ולהפיק שאלות מחקר ממוקדות.
## קרא לפני פעולה (INV-AG1)
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/02-data-model.md` + `03-retrieval.md` + `04-analysis-writing.md`. אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ). מסמכי-ה-`docs/` שלהלן משלימים — ספ-התחום קודם.
## לפני שאתה מתחיל — קרא ## לפני שאתה מתחיל — קרא
1. **`docs/decision-methodology.md`** — מתודולוגיה אנליטית: איך לחשוב על החלטה מעין-שיפוטית, מבנה סילוגיסטי, סדר סוגיות, טיפול בטענות 1. **`docs/decision-methodology.md`** — מתודולוגיה אנליטית: איך לחשוב על החלטה מעין-שיפוטית, מבנה סילוגיסטי, סדר סוגיות, טיפול בטענות
@@ -118,6 +123,7 @@ tools:
- **טיפול בכשל:** אם `extract_claims` החזיר `partial=true` או 0 טענות ממסמך לא ריק — נסה שוב פעם אחת. אם עדיין נכשל — סטטוס issue = `blocked`, פרסם comment עם הפירוט. - **טיפול בכשל:** אם `extract_claims` החזיר `partial=true` או 0 טענות ממסמך לא ריק — נסה שוב פעם אחת. אם עדיין נכשל — סטטוס issue = `blocked`, פרסם comment עם הפירוט.
5. **חלץ עובדות שמאי** — לכל מסמך `doc_type='appraisal'` בתיק, הרץ `extract_appraiser_facts(case_number)` (פעם אחת לתיק, מטפל בכל השומות). **חובה בכל ערר השבחה (8xxx) ופיצויים (9xxx) — בלי זה ה-writer לא יוכל לכתוב את בלוק ז עם מספרים מדויקים.** 5. **חלץ עובדות שמאי** — לכל מסמך `doc_type='appraisal'` בתיק, הרץ `extract_appraiser_facts(case_number)` (פעם אחת לתיק, מטפל בכל השומות). **חובה בכל ערר השבחה (8xxx) ופיצויים (9xxx) — בלי זה ה-writer לא יוכל לכתוב את בלוק ז עם מספרים מדויקים.**
6. וודא שכל פריט מסווג ל-claim_type הנכון 6. וודא שכל פריט מסווג ל-claim_type הנכון
7. **קבץ טענות לטיעונים משפטיים** — לאחר שכל הטענות חולצו וסוּוגו, הרץ `aggregate_claims_to_arguments(case_number)` שמקבץ את הפרופוזיציות הגולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד). זהו קלט מובנה לבלוק ז (טענות הצדדים) ולבלוק י (דיון) — הכותב נשען עליו. אם 0 טענות חולצו — דלג. הפלט עובר שער-אישור (ראה `get_legal_arguments`).
### שלב 2: ניתוח מעמיק ### שלב 2: ניתוח מעמיק
הצג במבנה הבא: הצג במבנה הבא:

View File

@@ -38,6 +38,8 @@ tools:
- mcp__legal-ai__precedent_library_list - mcp__legal-ai__precedent_library_list
- mcp__legal-ai__halacha_review - mcp__legal-ai__halacha_review
- mcp__legal-ai__halachot_pending - mcp__legal-ai__halachot_pending
- mcp__legal-ai__halacha_corroboration
- mcp__legal-ai__corroboration_rebuild
- mcp__legal-ai__extract_appraiser_facts - mcp__legal-ai__extract_appraiser_facts
- mcp__legal-ai__write_interim_draft - mcp__legal-ai__write_interim_draft
- mcp__legal-ai__export_interim_draft - mcp__legal-ai__export_interim_draft
@@ -47,6 +49,10 @@ tools:
אתה מנהל תהליך כתיבת החלטות של ועדת ערר לתכנון ובניה, מחוז ירושלים. יו"ר הוועדה היא עו"ד דפנה תמיר. אתה מנהל תהליך כתיבת החלטות של ועדת ערר לתכנון ובניה, מחוז ירושלים. יו"ר הוועדה היא עו"ד דפנה תמיר.
## קרא לפני פעולה (INV-AG1)
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז — כיוון שאתה ה**מתזמר** וצריך תמונה מלאה — את **כל קבצי-הספ** (`00``07`, `X1``X5`) תחת `~/legal-ai/docs/spec/`; לניתוב comments בפרט → `X3-integration-deploy.md §1ב`. אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
## שפה ## שפה
עבוד תמיד בעברית. עבוד תמיד בעברית.
@@ -206,6 +212,7 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים - אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
- אם ה-reason מכיל `precedent_extraction_`**דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה. - אם ה-reason מכיל `precedent_extraction_`**דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
- אם ה-reason מכיל `weekly-feedback-job`**דלג לסעיף "ניתוח פידבק שבועי"**. אל תיגע בתיקים פעילים. - אם ה-reason מכיל `weekly-feedback-job`**דלג לסעיף "ניתוח פידבק שבועי"**. אל תיגע בתיקים פעילים.
- אם ה-reason מכיל `feedback_fold_`**דלג לסעיף "קיפול הערת יו\"ר"**. אל תיגע בתיקים — זו משימת תחזוקת ידע.
- אחרת → המשך לשלב A (heartbeat רגיל) - אחרת → המשך לשלב A (heartbeat רגיל)
### חילוץ פסיקה אוטומטי ### חילוץ פסיקה אוטומטי
@@ -227,8 +234,20 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
mcp__legal-ai__precedent_process_pending(kind="halacha") mcp__legal-ai__precedent_process_pending(kind="halacha")
``` ```
הכלי מעבד את **כל** הפסיקות שבתור — אם תוקיע אחת והגיעו עוד בינתיים, גם הן יעובדו. הכלי מעבד את **כל** הפסיקות שבתור — אם תוקיע אחת והגיעו עוד בינתיים, גם הן יעובדו.
4. כשמסתיים: כתוב comment קצר ב-issue (`mcp__legal-ai__precedent_process_pending` מחזיר את התוצאה — סכם בעברית: כמה הלכות חולצו, אילו שדות מטא-דאטה הושלמו, ו-status לכל פסיקה). 4. **תיקוף-ציטוטים (X11, אחרי חילוץ ההלכות):** הרץ
5. סמן את ה-issue כ-`done`. ```
mcp__legal-ai__corroboration_rebuild()
```
(ארגומנט ריק = כל הקורפוס; `case_law_id="<uuid>"` = רק התקדים שעובד עכשיו — מהיר יותר). הכלי
מסווג את הטיפול-השיפוטי של כל ציטוט-נכנס, מתאים אותו להלכה הספציפית, **ומחיל אישור-אוטומטי**:
הלכה עם ≥2 ציטוטים חיוביים בלתי-תלויים (0 שליליים) שהיתה `pending_review` → `approved`
(reviewer `corroborated …`); הלכה שמאוחר-יותר **בוטלה** (overruled) → חוזרת לשער-היו"ר. הוא
idempotent ולא נוגע במצבים סופיים (`published`/`rejected`). אם הכלי לא קיים → ה-MCP server לא
עלה מחדש מאז Phase 2; דלג ודווח (אל תיכשל על זה).
5. כשמסתיים: כתוב comment קצר ב-issue (`precedent_process_pending` + `corroboration_rebuild`
מחזירים את התוצאות — סכם בעברית: כמה הלכות חולצו, אילו שדות מטא-דאטה הושלמו, status לכל פסיקה,
וכמה הלכות אושרו/הודחו בתיקוף-ציטוטים — `{approved, demoted}`).
6. סמן את ה-issue כ-`done`.
**אל**: אל תיצור issues של ביצוע בתיקי ערר, אל תיכנס לתהליך כתיבת החלטה — זו רק עבודת תחזוקה של ספריית הפסיקה. **אל**: אל תיצור issues של ביצוע בתיקי ערר, אל תיכנס לתהליך כתיבת החלטה — זו רק עבודת תחזוקה של ספריית הפסיקה.
@@ -252,6 +271,29 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
**כלל:** אל תגע בתיקים פעילים, אל תעיר סוכנים אחרים, אל תבצע heartbeat רגיל — זו משימת תחזוקה בלבד. **כלל:** אל תגע בתיקים פעילים, אל תעיר סוכנים אחרים, אל תבצע heartbeat רגיל — זו משימת תחזוקה בלבד.
### קיפול הערת יו"ר (feedback_fold)
**מתי:** `$PAPERCLIP_WAKE_REASON` מכיל `feedback_fold_`
מופעל כשהיו"ר סימנה הערת פידבק בודדת כ"יושמה" בדף `/feedback`. נוצר issue בפרויקט "ספריית פסיקה" המשויך אליך, ו**תיאור ה-issue מכיל את כל מה שצריך**: טקסט ההערה, הלקח שהופק, הקטגוריה, ויעד הקיפול לפי הקטגוריה.
**⚠️ MCP startup race** — חל גם כאן (ראה אזהרת חילוץ פסיקה). אם הכלי הראשון מחזיר "No such tool available" — המתן 3 שניות ונסה שוב.
**מה לעשות:**
1. **קרא את תיאור ה-issue** (`$PAPERCLIP_TASK_ID`) — הוא מכיל את ההערה, הלקח, הקטגוריה, ושדה **"יעד קיפול"**.
2. **rubric ניתוב לפי קטגוריה** (מופיע גם בתיאור ה-issue — זה מקור האמת):
| קטגוריה | קובץ יעד |
|---------|----------|
| `style` | `skills/decision/SKILL.md` |
| `wrong_structure` | `docs/block-schema.md` + `docs/legal-decision-lessons.md` |
| `missing_content` / `factual_error` / `wrong_tone` | `docs/legal-decision-lessons.md` |
| `other` | שיקול דעת — אם זה באג מערכת ולא לקח כתיבה → **אל תוסיף לקובץ**, פתח/עדכן משימת TaskMaster |
3. **קרא את קובץ היעד** והבן מה כבר מתועד שם.
4. **הוסף את הלקח רק אם אינו קיים** (לא כפל). פורמט: משפט עברי ברור + שורת **Rule** באנגלית, בעקבות הסגנון הקיים בקובץ.
5. **סגור את ה-issue** (`status=done`) עם comment קצר בעברית: לאיזה קובץ קופל ומה נוסף (או "כבר קיים — לא נוסף").
**כלל:** אל תגע בתיקים פעילים, אל תעיר סוכנים אחרים. משימת תחזוקת ידע בלבד.
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה ### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
בכל heartbeat **רגיל** (לא comment routing): בכל heartbeat **רגיל** (לא comment routing):

View File

@@ -26,6 +26,10 @@ tools:
אתה סוכן שמבצע את התהליך הסופי של הכנת טיוטת החלטה לעיון. תפקידך: בדיקה אחרונה, ייצוא ל-DOCX מעוצב, ושמירה מסודרת. אתה סוכן שמבצע את התהליך הסופי של הכנת טיוטת החלטה לעיון. תפקידך: בדיקה אחרונה, ייצוא ל-DOCX מעוצב, ושמירה מסודרת.
## קרא לפני פעולה (INV-AG1)
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/06-export.md` (ייצוא DOCX לפי תבנית דפנה). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
## שפה ## שפה
עבוד תמיד בעברית. עבוד תמיד בעברית.

View File

@@ -18,6 +18,10 @@ tools:
אתה מגיה מסמכים משפטיים. תפקידך לבדוק טקסט שחולץ מסריקות (OCR) ולתקן שגיאות לפני שהמנתח המשפטי עובד איתו. אתה מגיה מסמכים משפטיים. תפקידך לבדוק טקסט שחולץ מסריקות (OCR) ולתקן שגיאות לפני שהמנתח המשפטי עובד איתו.
## קרא לפני פעולה (INV-AG1)
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/01-ingest.md` (קליטה / טקסט-מחולץ). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
## שפה ## שפה
עבוד תמיד בעברית. עבוד תמיד בעברית.

View File

@@ -25,6 +25,10 @@ tools:
אתה בודק איכות מומחה. תפקידך לבדוק שהחלטה מוכנה לייצוא ולחתימת יו"ר הוועדה. אתה בודק איכות מומחה. תפקידך לבדוק שהחלטה מוכנה לייצוא ולחתימת יו"ר הוועדה.
## קרא לפני פעולה (INV-AG1)
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/05-qa-review.md` (שערי QA + שערים אנושיים). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
## שפה ## שפה
עבוד תמיד בעברית. עבוד תמיד בעברית.

View File

@@ -19,7 +19,7 @@ tools:
- mcp__legal-ai__extract_references - mcp__legal-ai__extract_references
- mcp__legal-ai__precedent_attach - mcp__legal-ai__precedent_attach
- mcp__legal-ai__precedent_list - mcp__legal-ai__precedent_list
- mcp__legal-ai__precedent_search_library - mcp__legal-ai__search_case_precedents
- mcp__legal-ai__search_precedent_library - mcp__legal-ai__search_precedent_library
- mcp__legal-ai__internal_decision_upload - mcp__legal-ai__internal_decision_upload
- mcp__legal-ai__precedent_library_upload - mcp__legal-ai__precedent_library_upload
@@ -30,6 +30,7 @@ tools:
- mcp__legal-ai__precedent_process_pending - mcp__legal-ai__precedent_process_pending
- mcp__legal-ai__halacha_review - mcp__legal-ai__halacha_review
- mcp__legal-ai__halachot_pending - mcp__legal-ai__halachot_pending
- mcp__legal-ai__halacha_corroboration
- mcp__legal-ai__missing_precedent_create - mcp__legal-ai__missing_precedent_create
- mcp__legal-ai__missing_precedent_list - mcp__legal-ai__missing_precedent_list
- mcp__legal-ai__missing_precedent_close - mcp__legal-ai__missing_precedent_close
@@ -42,6 +43,10 @@ tools:
אתה חוקר משפטי מומחה בתכנון ובניה ישראלי. תפקידך לנתח את מסמכי הרקע בתיק ערר — פסיקה, תכניות, פרוטוקולים, החלטות ביניים. אתה חוקר משפטי מומחה בתכנון ובניה ישראלי. תפקידך לנתח את מסמכי הרקע בתיק ערר — פסיקה, תכניות, פרוטוקולים, החלטות ביניים.
## קרא לפני פעולה (INV-AG1)
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/03-retrieval.md` (3 קורפוסים, hybrid/RRF, attribution); לקליטת-פסיקה → `01-ingest.md`. אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
## שפה ## שפה
עבוד תמיד בעברית. עבוד תמיד בעברית.
@@ -186,7 +191,7 @@ mcp__legal-ai__internal_decision_upload(
**שלושת הקורפוסים — אל תבלבל:** **שלושת הקורפוסים — אל תבלבל:**
- `search_precedent_library` = פסיקה חיצונית סמכותית עם הלכות מאושרות (עליון/מנהלי/ועדות ערר אחרות) + supporting_quote מוכן. - `search_precedent_library` = פסיקה חיצונית סמכותית עם הלכות מאושרות (עליון/מנהלי/ועדות ערר אחרות) + supporting_quote מוכן.
- `search_decisions` = החלטות דפנה (style_corpus) — הקאנון האישי שלה. - `search_decisions` = החלטות דפנה (style_corpus) — הקאנון האישי שלה.
- `precedent_search_library` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents). - `search_case_precedents` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
#### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה #### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה
@@ -280,7 +285,7 @@ search_internal_decisions(
#### 2ב.5 — תיעוד פסיקה חסרה (`missing_precedent_create`) — חובה #### 2ב.5 — תיעוד פסיקה חסרה (`missing_precedent_create`) — חובה
**מתי לקרוא:** לכל ציטוט שהצדדים הביאו (בכתב ערר / תגובה / תגובת ועדה) **שלא נמצא בקורפוס** אחרי חיפוש מובנה לפי פרוטוקול 2ב.4א (`search_precedent_library` + `search_internal_decisions` + `precedent_search_library`, כולל שאילתה עם הקשר/מספר תיק). **מתי לקרוא:** לכל ציטוט שהצדדים הביאו (בכתב ערר / תגובה / תגובת ועדה) **שלא נמצא בקורפוס** אחרי חיפוש מובנה לפי פרוטוקול 2ב.4א (`search_precedent_library` + `search_internal_decisions` + `search_case_precedents`, כולל שאילתה עם הקשר/מספר תיק).
**למה זה חשוב:** **למה זה חשוב:**
- ה-writer יודע שלא להסתמך על פסיקה שלא ב-DB ("טוענים שמופיע" ≠ "אומת") - ה-writer יודע שלא להסתמך על פסיקה שלא ב-DB ("טוענים שמופיע" ≠ "אומת")

View File

@@ -33,6 +33,10 @@ tools:
אתה כותב משפטי מומחה. תפקידך לכתוב החלטות של ועדת ערר לתכנון ובניה, מחוז ירושלים, בסגנון של יו"ר הוועדה עו"ד דפנה תמיר. אתה כותב משפטי מומחה. תפקידך לכתוב החלטות של ועדת ערר לתכנון ובניה, מחוז ירושלים, בסגנון של יו"ר הוועדה עו"ד דפנה תמיר.
## קרא לפני פעולה (INV-AG1)
לפני העבודה המהותית — קרא **תחילה** את חוקת המערכת `~/legal-ai/docs/spec/00-constitution.md` (ייעוד, G1G11, אינדקס-ספ §7), ואז את ספ-התחום שלך: `~/legal-ai/docs/spec/04-analysis-writing.md` + `05-qa-review.md` (אתה כותב מול שערי-QA). אינך פועל "מהזיכרון" — המקור הקנוני להתנהגות הוא החוקה + ספ-התחום. ראה גם [HEARTBEAT.md](HEARTBEAT.md) ("קריאת-ספ") ו-`~/legal-ai/docs/spec/X4-agents.md` (מפת תפקיד→ספ).
## שפה ## שפה
עבוד תמיד בעברית. עבוד תמיד בעברית.
@@ -347,7 +351,7 @@ fi
**הבחנה בין כלים:** **הבחנה בין כלים:**
- `search_decisions` = החלטות דפנה עצמה (סגנון, אסטרטגיה, ג'וריספרודנציה אישית). - `search_decisions` = החלטות דפנה עצמה (סגנון, אסטרטגיה, ג'וריספרודנציה אישית).
- `search_precedent_library` = פסיקה חיצונית סמכותית (מחייבת או משכנעת — בית המשפט העליון, מנהלי, ועדות ערר אחרות). - `search_precedent_library` = פסיקה חיצונית סמכותית (מחייבת או משכנעת — בית המשפט העליון, מנהלי, ועדות ערר אחרות).
- `precedent_search_library` (שונה!) = ציטוטים שדפנה צירפה ידנית לתיקים בעבר. לא לבלבל. - `search_case_precedents` (שונה!) = ציטוטים שדפנה צירפה ידנית לתיקים בעבר. לא לבלבל.
חפש לפי `practice_area` (rishuy_uvniya / betterment_levy / compensation_197) ולפי `subject_tag` רלוונטי. הלכות שלא אושרו ע"י דפנה לא מוחזרות מהכלי — אם החיפוש ריק, חזור ל-`search_decisions` בלבד. חפש לפי `practice_area` (rishuy_uvniya / betterment_levy / compensation_197) ולפי `subject_tag` רלוונטי. הלכות שלא אושרו ע"י דפנה לא מוחזרות מהכלי — אם החיפוש ריק, חזור ל-`search_decisions` בלבד.

View File

@@ -9,7 +9,7 @@
3. שלוף את תבנית ההחלטה עם get_decision_template 3. שלוף את תבנית ההחלטה עם get_decision_template
לכל סעיף: לכל סעיף:
4. השתמש ב-draft_section כדי לקבל הקשר מלא (מסמכי התיק + תקדימים + סגנון) 4. השתמש ב-get_block_context(case_number, block_id) כדי לקבל הקשר מלא לבלוק (מסמכי התיק + תקדימים + סגנון). [draft_section הישן deprecated — GAP-50]
5. נסח את הסעיף בסגנון דפנה על בסיס ההקשר 5. נסח את הסעיף בסגנון דפנה על בסיס ההקשר
6. הצג למשתמש ובקש אישור/עריכה לפני המשך לסעיף הבא 6. הצג למשתמש ובקש אישור/עריכה לפני המשך לסעיף הבא

29
.claude/settings.json Normal file
View File

@@ -0,0 +1,29 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/scripts/spec-guard.sh"
}
]
}
],
"WorktreeRemove": [
{
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.path // empty' | { read -r wt; [ -n \"$wt\" ] && git worktree remove --force \"$wt\" 2>/dev/null; git worktree prune 2>/dev/null; } || true"
}
]
}
]
},
"worktree": {
"baseRef": "fresh",
"symlinkDirectories": ["web-ui/node_modules"]
}
}

View File

@@ -0,0 +1,32 @@
<!--
תבנית PR — עוזר משפטי. מאכפת את "פרוטוקול כתיבת-קוד" (CLAUDE.md §פרוטוקול כתיבת-קוד):
כל PR מצהיר אילו invariants הוא נוגע בהם / מקיים. ראה docs/spec/00-constitution.md (G1G11).
מלא את הסעיפים; מחק את ההערות בסוגריים <!-- -->.
-->
## מה ולמה
<!-- תיאור קצר: מה ה-PR משנה ולמה. אם קשור ל-FU/GAP — ציין (למשל "FU-10 / GAP-30..34"). -->
## Invariants — הצהרה (חובה)
<!--
אילו invariants הנדסיים (G1G10) או INV-* מקבצי-תחום ה-PR נוגע בהם או מקיים?
דוגמה: "G2 (מקור-אמת יחיד) — איחדתי 2 לקוחות Paperclip למסלול קנוני אחד; INV-INT4."
תוכן משפטי → G11.
-->
- **נוגע / מקיים:**
## צ'קליסט — פרוטוקול כתיבת-קוד
- [ ] קראתי את `docs/spec/00-constitution.md` + ספ-התחום הרלוונטי לפני הכתיבה
- [ ] השינוי **לא** יוצר מסלול מקביל ליכולת קיימת (G2) ולא מתקן תסמין בקריאה (G1)
- [ ] אין בליעה שקטה של שגיאות — רשומה חסרה/פגומה מסומנת ומדווחת (כלל-הנדסה §6)
- [ ] בדקתי מול `docs/spec/gap-audit.md` — אם נגעתי ב-GAP/FU ממופה, התאמתי ליחידת-התיקון
- [ ] בדיקות עוברות (אם רלוונטי) / לא נדרשות
- [ ] **אם data-migration** — גיבוי + manifest ל-`data/audit/` לפני `--apply` (chair-gated אם נדרש)
## אימות
<!-- איך נבדק end-to-end: פקודות/tools/בדיקות שהורצו ותוצאתן. -->

View File

@@ -56,3 +56,23 @@ jobs:
curl -sf \ curl -sf \
"http://coolify:8080/api/v1/deploy?uuid=gyjo0mtw2c42ej3xxvbz8zio&force=true" \ "http://coolify:8080/api/v1/deploy?uuid=gyjo0mtw2c42ej3xxvbz8zio&force=true" \
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}" -H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
- name: Prune old build images and cache
if: always()
run: |
BASE="${{ env.REGISTRY }}/${{ env.IMAGE }}"
KEEP=5
# Keep the newest $KEEP build-NNN tags; remove the rest.
# The build daemon is the shared host daemon, so these images
# otherwise accumulate in /var/lib/docker (~1.3GB each).
docker images "${BASE}" --format '{{.Tag}}' \
| grep -E '^build-[0-9]+$' \
| sort -t- -k2 -nr \
| tail -n +$((KEEP + 1)) \
| while read -r tag; do
echo "🗑️ Removing ${BASE}:${tag}"
docker rmi "${BASE}:${tag}" || true
done
# Dangling images + build cache older than 72h (keeps recent layers warm)
docker image prune -f || true
docker builder prune -f --filter 'until=72h' || true

2
.gitignore vendored
View File

@@ -16,3 +16,5 @@ legacy/
kiryat-yearim/ kiryat-yearim/
continuation-prompt.md continuation-prompt.md
node_modules/ node_modules/
data/eval/eval-report-*
.claude/worktrees/

File diff suppressed because it is too large Load Diff

10
.worktreeinclude Normal file
View File

@@ -0,0 +1,10 @@
# קבצים מקומיים (gitignored) שמועתקים אוטומטית לכל worktree חדש שה-harness יוצר.
# תחביר .gitignore. מועתק רק אם הקובץ קיים *וגם* gitignored — קבצים tracked לעולם לא משוכפלים.
# ראה docs: https://code.claude.com/docs/en/worktrees#copy-gitignored-files-into-worktrees
# allowlist ההרשאות — בלעדיו כל worktree מציף אישורי-הרשאה מחדש
.claude/settings.local.json
# קבצי-סביבה מקומיים (כיום אין; proactive — בלתי-מזיק אם חסר)
.env
web-ui/.env.local

View File

@@ -22,6 +22,18 @@
4. **סיוע בכתיבה** — ייצור טיוטות לפי ארכיטקטורת 12 בלוקים בסגנון דפנה 4. **סיוע בכתיבה** — ייצור טיוטות לפי ארכיטקטורת 12 בלוקים בסגנון דפנה
5. **ייצוא DOCX** — מסמך מעוצב מוכן להגשה 5. **ייצוא DOCX** — מסמך מעוצב מוכן להגשה
### ⭐ יעד-העל: רכישת-הסגנון של דפנה (Style Acquisition)
**היעד הראשי של המערכת הוא שהסוכנים יכתבו וינתחו עררים בדיוק כמו עו"ד דפנה תמיר** — לא רק לייצר טיוטה תקנית, אלא להפנים את **הקול והשיטה** שלה. זה מחייב **הפרדה מובהקת בין שתי תת-מערכות**:
1. **מערכת-הכתיבה (Writing)** — מייצרת טיוטות (analyst/writer/qa/ceo). **צרכן read-only** של artifacts-הקול.
2. **מערכת רכישת-הסגנון (Style Acquisition)** — לומדת *איך* דפנה כותבת מכל זוג "טיוטה שלנו → סופי שלה", ומזינה חזרה את מערכת-הכתיבה. **היחידה שכותבת ל-artifacts-הקול** — תמיד דרך שער-יו"ר (INV-G10).
**הגישה (state-of-the-art לדאטה-מועט):** Text Style Transfer מבוסס **Authorial Style Profiling** — להכליל את סגנון דפנה ולהתאים לתיק. העתקת פסקאות מותרת לתוכן קבוע/נוסחאי; ניתוח ספציפי → להכליל; **מהות משפטית (הלכה/עובדה) — אסור להעתיק מתיק לתיק**. *לא* fine-tuning של משקולות (Opus סגור; קורפוס קטן מדי).
**כלל-העל — INV-LRN4:** כל החלטה אינה "סגורה" עד שהושוותה מול הגרסה הסופית של דפנה; כל סופי מנותח מול הטיוטה. כך לומדים מכל החלטה. **INV-LRN5:** שכבת-ידע-הקול לא תכיל מהות ספציפית — רק סגנון ושיטה.
ספ מלא: [`docs/spec/07-learning.md`](docs/spec/07-learning.md) §0. ארכיטקטורה ומשימות: תוכנית `style-acquisition-subsystem`.
### מה היה קודם (Legacy) ### מה היה קודם (Legacy)
המערכת הקודמת היתה **Obsidian vault** עם Claude Code skills על שרת אחר. פותחו: המערכת הקודמת היתה **Obsidian vault** עם Claude Code skills על שרת אחר. פותחו:
- ניתוח סגנון של 3 החלטות (הכט — דחייה, בית הכרם — קבלה חלקית, אריאלי — השוואה) - ניתוח סגנון של 3 החלטות (הכט — דחייה, בית הכרם — קבלה חלקית, אריאלי — השוואה)
@@ -38,6 +50,9 @@
| מסמך | תוכן | מתי לקרוא | | מסמך | תוכן | מתי לקרוא |
|------|-------|-----------| |------|-------|-----------|
| [`docs/spec/00-constitution.md`](docs/spec/00-constitution.md) | **חוקת המערכת** — ייעוד, 11 invariants גלובליים (G1G11), כללי-הנדסה, אינדקס-ספ | **לפני כל כתיבת/שינוי קוד** (ראה §פרוטוקול כתיבת-קוד) |
| [`docs/spec/README.md`](docs/spec/README.md) | **אינדקס ספ-המערכת** — מחזור-חיים (0107) + חוצי-שלבים (X1X11). מקור-האמת ל"מהו תקין" | **לפני כל כתיבת/שינוי קוד** |
| [`docs/spec/gap-audit.md`](docs/spec/gap-audit.md) | **מפת-פערים** — 62 ממצאים → 15 יחידות-תיקון (FU); invariant מופר + file:line + תיקון מוצע | לפני נגיעה ב-GAP/FU קיים או תכנון FU חדש |
| [`docs/architecture.md`](docs/architecture.md) | ארכיטקטורת המערכת, תרשים רכיבים, זרימת נתונים, 4 שכבות DB | לפני עבודה על תשתית | | [`docs/architecture.md`](docs/architecture.md) | ארכיטקטורת המערכת, תרשים רכיבים, זרימת נתונים, 4 שכבות DB | לפני עבודה על תשתית |
| [`docs/block-schema.md`](docs/block-schema.md) | הגדרת 12 בלוקים — content model, constraints, processing params | **לפני כל כתיבת החלטה** | | [`docs/block-schema.md`](docs/block-schema.md) | הגדרת 12 בלוקים — content model, constraints, processing params | **לפני כל כתיבת החלטה** |
| [`docs/migration-plan.md`](docs/migration-plan.md) | תוכנית מעבר vault → DB — טבלאות, עדיפויות, כמויות | לפני ייבוא נתונים | | [`docs/migration-plan.md`](docs/migration-plan.md) | תוכנית מעבר vault → DB — טבלאות, עדיפויות, כמויות | לפני ייבוא נתונים |
@@ -61,6 +76,60 @@
--- ---
## פרוטוקול כתיבת-קוד — קודם הספ ⚠️
> **כלל-על.** המקור הקנוני ל"מהו תקין הנדסית" הוא ספ-המערכת תחת [`docs/spec/`](docs/spec/) — לא
> הרגלים, לא "הקוד הקיים נראה ככה". כל קוד שנכתב בלי לעבור דרך הספ מסתכן בהחזרת **כשל-השורש**
> שהספ בא לייבש: מסלולים/קורפוסים מקבילים שמתפצלים (drift). זהו המקבילה האינטראקטיבית ל-INV-AG1
> שכבר אוכף על סוכני Paperclip ([HEARTBEAT.md](.claude/agents/HEARTBEAT.md) §"קריאת-ספ").
**לפני יצירה/שינוי של קוד ב-`web/`, `mcp-server/`, `web-ui/`, `scripts/`:**
1. **קרא** [`docs/spec/00-constitution.md`](docs/spec/00-constitution.md) — ייעוד, ה-invariants הגלובליים G1G11, וכללי-ההנדסה (§6). אינדקס-הספ ב-§7.
2. **קרא את ספ-התחום הרלוונטי** לפי האינדקס (§7) — לדוגמה: אחזור→[`03-retrieval.md`](docs/spec/03-retrieval.md), קליטה→[`01-ingest.md`](docs/spec/01-ingest.md), נתונים→[`02-data-model.md`](docs/spec/02-data-model.md), כלי-MCP→[`X9-mcp-tool-contract.md`](docs/spec/X9-mcp-tool-contract.md), UI↔API→[`X6-ui-api-contract.md`](docs/spec/X6-ui-api-contract.md), Paperclip→[`X3`](docs/spec/X3-integration-deploy.md)/[`X7`](docs/spec/X7-paperclip-client-params.md), env/secrets→[`X10-deploy-env-secrets.md`](docs/spec/X10-deploy-env-secrets.md).
3. **ודא שהשינוי *מקיים* את ה-invariants** — לא יוצר מסלול מקביל ליכולת קיימת ([G2](docs/spec/00-constitution.md)), לא מתקן תסמין בקריאה במקום נרמול במקור (G1), לא בולע שגיאות בשקט (כלל-הנדסה §6).
4. **בדוק מול** [`gap-audit.md`](docs/spec/gap-audit.md) — אם אתה נוגע ב-GAP/FU שכבר ממופה, התאם את העבודה ליחידת-התיקון; אל תפתור מחדש.
5. **כל PR מצהיר invariants** — אילו G*/INV-* ה-PR נוגע בהם / מקיים (ראה תבנית ה-PR ב-[`.gitea/PULL_REQUEST_TEMPLATE.md`](.gitea/PULL_REQUEST_TEMPLATE.md)).
> **שתי שכבות-כללים מובחנות, שתיהן חלות:**
> - **הנדסה (G1G10)** — הסעיף הזה + `docs/spec/`. סמכות: ≥3 מקורות חיצוניים.
> - **תוכן משפטי (G11)** — סעיף "עקרונות כתיבה קריטיים" למטה (12 בלוקים, רקע ניטרלי...). סמכות: היו"ר + מסמכי-הפרויקט.
>
> אכיפה אוטומטית: hook `PreToolUse` ([scripts/spec-guard.sh](scripts/spec-guard.sh)) מזכיר את הפרוטוקול בכל Edit/Write על נתיב-קוד.
---
## בידוד-סשנים — worktree מבודד חובה ⚠️
> **כלל קשיח.** בכל רגע נתון רצים **כמה סשנים במקביל** על אותו עץ-עבודה (`~/legal-ai`) — סשנים אינטראקטיביים של chaim **וגם** סוכני Paperclip. עץ-עבודה אחד = ענף-גיט אחד משותף, כך שסשן אחד מחליף branch / משאיר שינויים לא-מתויקים תוך כדי שאחר עובד → **דריסה הדדית ומירוץ-ענף** ([[feedback_shared_worktree_branch_race]]).
**לכן — כל סשן שעומד לכתוב/לשנות קוד או תיעוד חייב לעבוד ב-git worktree מבודד משלו. אסור לערוך/לתייק בעץ-העבודה הראשי `~/legal-ai` כשייתכן שסשן אחר פעיל.**
הבידוד **נתמך-סביבה** — לא רק כלל-משמעת. ההגדרות נשמרות ב-repo (`.claude/settings.json`, `.worktreeinclude`, `.gitignore`) כך שכל worktree שה-harness יוצר מקבל אוטומטית בסיס נקי, את התלויות, ואת ההרשאות. מקורות רשמיים: [Run parallel sessions with worktrees](https://code.claude.com/docs/en/worktrees), [Settings → worktree](https://code.claude.com/docs/en/settings).
### הדרך המומלצת — worktree של ה-harness
```bash
cd ~/legal-ai && claude --worktree <slug> # או, בתוך סשן: "עבוד ב-worktree" (כלי EnterWorktree)
```
נוצר תחת `.claude/worktrees/<slug>/` על ענף `worktree-<slug>`, ומקבל **אוטומטית**:
- **בסיס נקי מ-`origin/main`** — דרך `worktree.baseRef: "fresh"` ב-`.claude/settings.json`.
- **`web-ui/node_modules` (789MB) כסימלינק** — דרך `worktree.symlinkDirectories`; אין צורך ב-`npm ci`. (אם משנים deps של web-ui — עשו זאת בעץ הראשי או היו מודעים שה-node_modules משותף.)
- **`.claude/settings.local.json` + קבצי-env מקומיים** — מועתקים דרך `.worktreeinclude` (מונע הצפת אישורי-הרשאה).
- **ניקוי אוטומטי ביציאה** — כולל עקיפת באג סימלינק [#40259](https://github.com/anthropics/claude-code/issues/40259) דרך `WorktreeRemove` hook עם `--force`.
### הפרוטוקול (חל על שתי הדרכים)
1. **בתחילת עבודת-כתיבה** — צור worktree (מומלץ: `claude --worktree`; ידני-fallback: `git worktree add -b <branch> .claude/worktrees/<slug> origin/main`**תחת `.claude/worktrees/`** כדי שההגדרות יחולו).
2. **אמת ענף לפני כל commit**`git branch --show-current` (הרגל קשיח; ה-harness עלול להתעלם מ-`baseRef:"fresh"` — באג [#60588](https://github.com/anthropics/claude-code/issues/60588) — אז ודא שהבסיס באמת `origin/main`).
3. **push + PR + merge** כרגיל ([[feedback_always_pr_merge]]) — PR תמיד ל-`main`. הרץ tests לפני merge.
4. **נקה אחרי מיזוג** — יציאת הסשן מנקה worktree של ה-harness אוטומטית; ידני: `git worktree remove .claude/worktrees/<slug> && git worktree prune && git branch -D worktree-<slug>`.
5. **קריאה-בלבד** (חקירה, סריקה, הרצת בדיקות ללא שינוי) — מותר בעץ הראשי; אין צורך ב-worktree.
6. **אל תיגע** בשינויים לא-מתויקים שאינם שלך בעץ הראשי — הם של סשן אחר. אם העץ הראשי על ענף זר — אל תתייק עליו.
> **בידוד-DB:** ה-worktree מבודד-קבצים בלבד — לא בידוד-repo ולא בידוד-DB. **אל תריץ migrations מ-2 worktrees במקביל** על Postgres המשותף (`localhost:5433`) — סכמה שאף סשן לא מצפה לה ([Run agents in parallel](https://code.claude.com/docs/en/agents)).
> **סוכני Paperclip — אינם מבודדים (אומת 2026-06-06):** 14 מתוך 16 הסוכנים רצים על אדפטר `claude_local` הרשמי, שמריץ `claude -p` ב-`adapter_config.cwd=/home/chaim/legal-ai` **המשותף** — אין לו אופציית `worktreeMode`/`-w` (קיימת רק ב-fork ה-deepseek שלנו). כלומר **כל סוכני Paperclip חולקים את עץ-העבודה הראשי**. הסיכון ממותן ע"י כלל הסשנים נתמך-הסביבה למעלה + תזמור סדרתי ע"י ה-CEO — **לא** ע"י בידוד-worktree per-agent. הניתוח המלא והדרכים שנשקלו: TaskMaster `legal-ai` #104 (נסגר כ-cancelled — "לתעד, לא לבדד").
---
## שרת Nautilus (158.178.131.193) ## שרת Nautilus (158.178.131.193)
| שירות | תפקיד | כתובת | | שירות | תפקיד | כתובת |
@@ -68,7 +137,6 @@
| Coolify | ניהול containers | `http://158.178.131.193:8000` | | Coolify | ניהול containers | `http://158.178.131.193:8000` |
| PostgreSQL + pgvector | בסיס נתונים ראשי | `legal-ai-postgres` | | PostgreSQL + pgvector | בסיס נתונים ראשי | `legal-ai-postgres` |
| Redis | תור משימות | `legal-ai-redis` | | Redis | תור משימות | `legal-ai-redis` |
| n8n | אוטומציית workflows | להגדרה |
| Gitea | מאגר קוד | `gitea.nautilus.marcusgroup.org/ezer-mishpati` | | Gitea | מאגר קוד | `gitea.nautilus.marcusgroup.org/ezer-mishpati` |
| ezer-mishpati-web | ממשק העלאת מסמכים (Docker/Coolify) | `legal-ai.nautilus.marcusgroup.org` | | ezer-mishpati-web | ממשק העלאת מסמכים (Docker/Coolify) | `legal-ai.nautilus.marcusgroup.org` |
| Paperclip | סוכן AI — מריץ Claude Code agents (pm2, מקומי) | `localhost:3100` | | Paperclip | סוכן AI — מריץ Claude Code agents (pm2, מקומי) | `localhost:3100` |

View File

@@ -32,9 +32,10 @@ RUN pip install --no-cache-dir ./mcp-server
FROM python:3.12-slim AS runner FROM python:3.12-slim AS runner
WORKDIR /app WORKDIR /app
# Install Node.js 20.x # Install Node.js 20.x + LibreOffice Writer (headless .doc→.docx conversion
# in extractor.py:_extract_doc — needed for legacy Hebrew .doc precedents).
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates git \ curl ca-certificates git libreoffice-writer-nogui \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \ && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,26 @@
# X11 Phase 2 — Corroboration Backfill (2026-06-01)
`corroboration.build_all()` over the full corpus after wiring the approval gate.
## Result
```
{"precedents": 12, "citations": 26, "linked": 20, "approved": 0, "demoted": 0}
```
## Treatment distribution (20 stored links)
- followed: 18 · explained: 1 · mentioned: 1 · **negatives: 0**
## Per-halacha corroboration
- 14 halachot carry corroboration rows; **4 are corroborated** (≥2 distinct positive sources, 0 negatives).
- **All 14 were already `approved`** (13 by confidence ≥0.80, 1 by דפנה).
## Why 0 approved / 0 demoted (correct, not a bug)
- **0 approved:** `approve_halacha_by_corroboration` only transitions `pending_review`. Every corroborated halacha was already approved → nothing to promote this run. The citation-corroboration set currently **fully overlaps** the confidence-approved set.
- **0 demoted:** the corpus has **no negative treatments** → nothing overruled to demote.
## Verification
- Counts before == after (approved=1415, pending=196, published=0, rejected=1) — idempotent, no chair-final state touched.
- Approve path proven end-to-end in a **rolled-back transaction**: a corroborated halacha set to `pending_review` flipped back to `approved` with reviewer `corroborated (2 judicial citations ≥ 2)`; prod row restored.
## Going-forward value
The corroboration approval path matters for (a) future halachot extracted **below** the confidence threshold but **citation-corroborated**, and (b) **overruled-demotion** once negative treatment appears in the citation graph. Re-runnable anytime via the `corroboration_rebuild` MCP tool (empty arg = full backfill).

70
data/eval/baseline.json Normal file
View File

@@ -0,0 +1,70 @@
{
"gold_size": 86,
"retrieval_config": {
"MULTIMODAL_ENABLED": true,
"VOYAGE_RERANK_ENABLED": false,
"VOYAGE_MODEL": "voyage-3",
"MULTIMODAL_TEXT_WEIGHT": 0.65,
"MULTIMODAL_RRF_K": 60,
"BM25_HYBRID_ENABLED": true
},
"overall": {
"P@5": 0.2465,
"R@5": 0.9938,
"nDCG@5": 0.9597,
"P@10": 0.1244,
"R@10": 0.9961,
"nDCG@10": 0.9611,
"MRR": 0.9535
},
"by_corpus": {
"internal_decisions": {
"P@5": 0.2037,
"R@5": 1.0,
"nDCG@5": 0.978,
"P@10": 0.1019,
"R@10": 1.0,
"nDCG@10": 0.978,
"MRR": 0.9722
},
"precedent_library": {
"P@5": 0.3188,
"R@5": 0.9833,
"nDCG@5": 0.9288,
"P@10": 0.1625,
"R@10": 0.9896,
"nDCG@10": 0.9326,
"MRR": 0.9219
}
},
"by_practice_area": {
"betterment_levy": {
"P@5": 0.2051,
"R@5": 1.0,
"nDCG@5": 0.9621,
"P@10": 0.1026,
"R@10": 1.0,
"nDCG@10": 0.9621,
"MRR": 0.9487
},
"compensation_197": {
"P@5": 0.2,
"R@5": 1.0,
"nDCG@5": 1.0,
"P@10": 0.1,
"R@10": 1.0,
"nDCG@10": 1.0,
"MRR": 1.0
},
"rishuy_uvniya": {
"P@5": 0.2059,
"R@5": 1.0,
"nDCG@5": 0.9976,
"P@10": 0.1029,
"R@10": 1.0,
"nDCG@10": 0.9976,
"MRR": 1.0
}
},
"generated_at": "20260603T084350Z"
}

86
data/eval/gold-set.jsonl Normal file
View File

@@ -0,0 +1,86 @@
{"id": "g-2ab91a37e3", "query": "אברהם אגסי", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["1a87efe5-6e13-4ed4-a9ec-3f2f7d61e4ec"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-3572817c30", "query": "אברהם אנשין", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["8aeee5cc-26a0-475a-b4e4-c2570e4333f5"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-66dbb8ac16", "query": "אהרון ברק - תכנית רחביה", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["e151fc25-cf12-4563-b638-a86323f8413b"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-3588230bc4", "query": "אואקנין", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["405d51ac-deef-4bdf-aaea-f39b4aaa84fd"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-ff905fe19d", "query": "ב.דייניש", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["f3ab6507-6475-4230-ad96-70d4177a9f72"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-fa8f479ae1", "query": "בוטיק הנביאים", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["691e8220-745b-4631-aff4-338c164ba988"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-4b2c6a86ec", "query": "בית אגודת ישראל", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["7a71adbc-6a21-41a4-a98d-8fdd3f6e7b62"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-e9d5fc6d9b", "query": "בית חנינא מגרש 2010", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["fa0dab0c-bafc-4239-bba4-33cc9790f69f"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-8280afc216", "query": "בית חנינא — אום כולתום", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["a1e51703-474a-44d0-b8c8-5ae8bffb4782"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-e814cc43fa", "query": "בן זאב רמות", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["53c1adb6-81fd-4d0a-b3de-ffe2e6c5b6b3"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-7b1ef92188", "query": "בר-און", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["a60dc67d-67ab-4615-b148-34794d728687"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-9b17fb63a3", "query": "ג'רוזלם הומס אינק", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["9af224ef-5325-488c-a28c-de8ab059dfa3"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-c763aa9a45", "query": "גבאי וזוסמן", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["65065d5b-c0b2-4be3-970c-6b76842da054"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-ac23569fec", "query": "גפטו-פיצריה בצור הדסה", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["496c945a-9ab6-402c-9f9e-39f7af88b7cd"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-8dc2a68af8", "query": "דב ויעל ירון", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["a4716706-b2af-424d-98d8-d7ec45f9aeea"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-94196a641c", "query": "דור ודורשיו 18", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["a3ca3f83-3831-457d-8eed-b5654a201348"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-e19550a361", "query": "האורן 51 מבשרת ציון", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["3e112944-2a0d-4175-bcb6-69e19828b8ad"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-9612266af6", "query": "ההסתדרות הציונית העולמית", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["20999cb0-d9bd-4c4a-a18d-304451e1a30f"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-c39b2a42c7", "query": "הוועדה המקומית ירושלים נ' סופר נוח", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["04b2f953-efce-4e11-b9b5-e583b393c335"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-a145777626", "query": "הכט וסדובסקי", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["ffbd9963-099f-4bf5-b888-af993844e80a"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-33059ab228", "query": "המרכז הארצי לטהרת המשפחה", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["cd815101-e153-468d-a7bc-be1ac88105ae"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-8af7c5a180", "query": "השלום 63 מבשרת ציון", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["ee2104c8-2d31-4173-839c-8b61dcaf2a31"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-0494e34a1d", "query": "וינפלד", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["bd5d849c-c15f-43c3-96ab-d44337af9cb5"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-beca7df79f", "query": "זעיתר", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["098535ec-55c0-44dd-b058-ddaeac8b4cd7"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-f1a9633456", "query": "חוכרת הר חומה", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["e40110b4-9364-4cc7-a5b8-cee9bbedb172"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-3d12dcc821", "query": "חלוואני", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["9d8da0a6-e4dc-4c9b-85ab-36fa5ecbd12f"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-77ae0a9368", "query": "טביסל דניאל", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["f39f807d-90a6-4950-b10f-485dbf7e2ef6"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-4dec58a380", "query": "יסמין 54 מבשרת ציון", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["ac1a34c4-52c5-4e91-b6a7-297f11fe0460"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-776cecae74", "query": "ירושלים שקופה", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["ecc63119-6977-4d8e-930d-609dbd990494", "438d693c-6dfd-4a65-a48c-f8e2011bcc10"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (2 same-named)"}
{"id": "g-824f0d2ca8", "query": "ירושלים שקופה (1112/22)", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["446e96f1-a896-435d-bc33-a9b61b6d0b6c"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-454e470bb4", "query": "ליאור אהרון", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["a5ba233d-27aa-432b-bbef-093a2d49d80a"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-09c8b87f35", "query": "מוצא עילית", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["048af29a-d356-454f-acd6-5d1de32ecb94"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-5055a61633", "query": "מילי וישראל גלון", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["cc812e7b-cf9b-44af-8dfa-36541cb0b72d"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-8a15965c4f", "query": "מנץ", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["ed7ac419-f359-4b51-8e21-adec141629c7"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-48ae72c484", "query": "מפלגת נעם", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["5897b4e1-1fa2-4d83-816d-51f7cdf7cdee"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-ca171fdb45", "query": "מצפה בית שמש", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["8ba7f873-0da4-49cd-955e-98f579e61fb2"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-7e54e8b69b", "query": "מרדכי שטיין", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["228de6b5-b731-4959-a448-e9e941790420"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-62befb6c18", "query": "מרכז קהילתי בית הכרם", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["e73ec1d1-e89e-4d5b-a870-84cbf7b09106"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-cb0a295129", "query": "נחמיה פרומר", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["ab039082-47d1-4f79-9db9-d97c53e3bc80"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-4f9a788676", "query": "נילי אמיתי", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["d3fd9310-621b-4b76-a71f-729dd2044108"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-e9b1ce30da", "query": "סלונים", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["add3da4c-fda0-48d0-8109-957fc9f924a7"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-23b50ceb0d", "query": "סקולוסקי", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["18846024-d630-4a33-9024-6b2388df7007"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-93531bf772", "query": "עוררי רכס חלילים", "practice_area": "compensation_197", "corpus": "internal_decisions", "relevant_case_law_ids": ["288326ca-bf9c-48fe-ba6b-8ef9e65bd0a0"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-f1e0ebc751", "query": "עזבון אליהו הרנון ז\"ל נ' הוועדה המקומית ירושלים", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["6774fe43-0ba9-4409-b128-cacbd168afc3"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-f3c29ce2f8", "query": "עמותת ישיבת טעלז", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["30a606ac-5ba4-46d5-86d4-075564e30d2d"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-0a595fd872", "query": "ערן סופר", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["9c63985a-211f-4af9-a145-c674bdcdb0f6"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-fd95fc1bc0", "query": "פייר קניג 36", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["5cc53869-9e85-469e-85bb-986ac646de07"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-04f32ade81", "query": "פרויקט מגרש 902 בית שמש", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["810f8315-26cf-4069-be16-b5fee7f16a56"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-445fa07583", "query": "קו אופ ופרטוש", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["62c517c8-ab8d-48b1-8472-1f6adc6e3817"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-9f2c58a190", "query": "קרן יעקב הלפרן", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["921d36df-76be-4a53-823b-0d2ac1f79f2e"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-43fff5d955", "query": "קרקעות ירושלים 2", "practice_area": "compensation_197", "corpus": "internal_decisions", "relevant_case_law_ids": ["730d6f21-08e4-4ae0-8b7e-017dde61003e"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-78610b8e8a", "query": "שכן הכלנית 54 מבשרת ציון", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["88e2d381-2e34-49b2-8225-5e72b487854d"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-d043d7c75f", "query": "ששת הימים 6 רמת אשכול", "practice_area": "betterment_levy", "corpus": "internal_decisions", "relevant_case_law_ids": ["a87d30d4-d3a3-439d-9909-c282024aafba"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-1cdefcfaba", "query": "תמ\"א רש\"י 32 תל אביב", "practice_area": "rishuy_uvniya", "corpus": "internal_decisions", "relevant_case_law_ids": ["3cbd2d6c-ff20-4af2-ab92-c105bb30fbc6"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-a65f37501c", "query": "אגא וכט", "practice_area": "rishuy_uvniya", "corpus": "precedent_library", "relevant_case_law_ids": ["1847e97e-6e38-494f-b079-0fc59066788a"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-10e5dca5b8", "query": "אהוד שפר", "practice_area": "rishuy_uvniya", "corpus": "precedent_library", "relevant_case_law_ids": ["9024da7b-f408-4b6f-808f-c514a83728e4"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-b42d0ceaaa", "query": "אירוס הגלבוע", "practice_area": "rishuy_uvniya", "corpus": "precedent_library", "relevant_case_law_ids": ["b673d649-d162-4f81-a323-c7d89e8334ce"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-4d50ccd2dd", "query": "אנטרים", "practice_area": "rishuy_uvniya", "corpus": "precedent_library", "relevant_case_law_ids": ["48909f09-8a65-4a2d-8697-e2f50bf9a756"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-bbf0e30d31", "query": "ארגון עמק שווה", "practice_area": "rishuy_uvniya", "corpus": "precedent_library", "relevant_case_law_ids": ["41d5a21c-a28a-428f-a35e-bc7d0dc89539"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-dac18ac10f", "query": "ב. דייניש", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["950d8c1b-4976-4a68-8b8e-7d0bdd056e1d"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-0d130898bb", "query": "בולקינד", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["e57c4a6b-66a0-4d52-85af-5018f03cf295"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-789c4ff1a7", "query": "בית אגודת ישראל", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["aadedc2d-e990-4d6d-9dd1-8be4fa6dcbe2", "ced7ea50-689b-465d-bf79-99e22a72e0df"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (2 same-named)"}
{"id": "g-06b07271bb", "query": "ברק - תכנית רחביה", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["57be0d1a-293f-481f-aa5b-bfa7dc73f99e"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-4160927269", "query": "גבעת האירוסים", "practice_area": "rishuy_uvniya", "corpus": "precedent_library", "relevant_case_law_ids": ["e26f2fa2-50e5-407d-8724-8c707dcda51b"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-4fe81acc94", "query": "הבית ברחוב שמעוני", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["53ccf47e-0fc7-4248-b486-02f57a9c689c"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-faa7cc3548", "query": "הקדש עדת הבוכרים", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["587381e4-d194-4d37-b00f-ccf7242ba228"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-0901d5d211", "query": "כנסייה אוונגלית אפיסקופלית", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["4bde8ca8-7862-4b19-9dd7-de2e31d82721"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-62fd2080df", "query": "לויתן אדיב שמואל", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["b80d94a0-b836-44f5-8cc6-18d8cf26e41d"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-9f934d9159", "query": "לויתן וקלמנוביץ", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["436efd48-c8ab-49f0-b3a9-52bf15ea806d"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-9e829d5277", "query": "מועצה אזורית מטה בנימין", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["d7b635b1-6607-46ac-9868-44e4fd598e5a"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-b3acf850af", "query": "משה ירושלמי", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["e18aa906-e0f5-452f-a17a-f1c299095340"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-631a47d8b0", "query": "משרד התחבורה נ' גלר", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["8bfcd217-cde3-4930-a058-c9a59182c338"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-f8aaaa60d7", "query": "נווה שלום", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["4f85e3f1-237a-4dac-b949-87a43ee6f633"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-dbb1358ccf", "query": "ניצני עוז", "practice_area": "rishuy_uvniya", "corpus": "precedent_library", "relevant_case_law_ids": ["e08f81d3-6183-494c-aec3-f20d39e2755e"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-ae5917860b", "query": "סרוזברג ואח'", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["d9772726-9766-4509-8067-b20fa625a1a9"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-e1e175248c", "query": "עמותת העצמאים באילת", "practice_area": "rishuy_uvniya", "corpus": "precedent_library", "relevant_case_law_ids": ["f59e74c2-6433-47c9-bd0e-580cf4171fbb"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-86116ced86", "query": "שמי אשקלוני", "practice_area": "betterment_levy", "corpus": "precedent_library", "relevant_case_law_ids": ["7352e510-c769-45e4-b4ef-d85271743506"], "source": "bootstrap_known_item", "note": "known-item: search by case_name → expect the case itself (1 same-named)"}
{"id": "g-7e9438b730", "query": "פטור מהיטל השבחה למוסד ציבורי לפי סעיף 19(ב)(4)", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["ced7ea50-689b-465d-bf79-99e22a72e0df", "aadedc2d-e990-4d6d-9dd1-8be4fa6dcbe2", "587381e4-d194-4d37-b00f-ccf7242ba228", "4bde8ca8-7862-4b19-9dd7-de2e31d82721", "4f85e3f1-237a-4dac-b949-87a43ee6f633"], "source": "chair", "note": "semantic query (chair-approved 2026-05-31)"}
{"id": "g-89bc8d6161", "query": "נטרול תרומת תמ\"א 38 בשומת \"מצב קודם\"", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["436efd48-c8ab-49f0-b3a9-52bf15ea806d", "b80d94a0-b836-44f5-8cc6-18d8cf26e41d", "57be0d1a-293f-481f-aa5b-bfa7dc73f99e", "7352e510-c769-45e4-b4ef-d85271743506", "53ccf47e-0fc7-4248-b486-02f57a9c689c"], "source": "chair", "note": "semantic query (chair-approved 2026-05-31)"}
{"id": "g-f4c06ec2f9", "query": "פטור מהיטל בתמ\"א 38 — מימוש במכר מול מימוש בהיתר", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["53ccf47e-0fc7-4248-b486-02f57a9c689c", "e57c4a6b-66a0-4d52-85af-5018f03cf295", "7352e510-c769-45e4-b4ef-d85271743506"], "source": "chair", "note": "semantic query (chair-approved 2026-05-31)"}
{"id": "g-8c8b82486c", "query": "נטרול ציפיות לתכנית עתידית בשווי מצב קודם (אקו-סיטי/לוסטרניק)", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["950d8c1b-4976-4a68-8b8e-7d0bdd056e1d", "7352e510-c769-45e4-b4ef-d85271743506", "436efd48-c8ab-49f0-b3a9-52bf15ea806d"], "source": "chair", "note": "semantic query (chair-approved 2026-05-31)"}
{"id": "g-bbe92ea5e3", "query": "היתר לשימוש חורג בקרקע חקלאית — סטייה ניכרת ומגמת תכנון", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["e08f81d3-6183-494c-aec3-f20d39e2755e", "e26f2fa2-50e5-407d-8724-8c707dcda51b", "b673d649-d162-4f81-a323-c7d89e8334ce", "f59e74c2-6433-47c9-bd0e-580cf4171fbb"], "source": "chair", "note": "semantic query (chair-approved 2026-05-31)"}
{"id": "g-19376b63de", "query": "זכות עמידה / זכות התנגדות לבקשה להיתר בנייה", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["48909f09-8a65-4a2d-8697-e2f50bf9a756", "9024da7b-f408-4b6f-808f-c514a83728e4"], "source": "chair", "note": "semantic query (chair-approved 2026-05-31)"}
{"id": "g-3d2f9fc270", "query": "היקף התערבות בית המשפט בשיקול דעת תכנוני של ועדה", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["41d5a21c-a28a-428f-a35e-bc7d0dc89539", "9024da7b-f408-4b6f-808f-c514a83728e4", "e26f2fa2-50e5-407d-8724-8c707dcda51b"], "source": "chair", "note": "semantic query (chair-approved 2026-05-31)"}
{"id": "g-9e96222cc5", "query": "אמת המידה להתערבות ועדת ערר בשומת שמאי מכריע", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["8bfcd217-cde3-4930-a058-c9a59182c338", "1847e97e-6e38-494f-b079-0fc59066788a"], "source": "chair", "note": "semantic query (chair-approved 2026-05-31)"}
{"id": "g-181b020ea9", "query": "חובת ועדת ערר להעביר השגות שמאיות לשמאי מייעץ (ס'197)", "practice_area": "", "corpus": "precedent_library", "relevant_case_law_ids": ["e18aa906-e0f5-452f-a17a-f1c299095340", "8bfcd217-cde3-4930-a058-c9a59182c338"], "source": "chair", "note": "semantic query (chair-approved 2026-05-31)"}

View File

@@ -327,6 +327,7 @@ Conclusion → Rule → Explanation → Application → Conclusion.
- MUST NOT: ניתוח מעמיק (→ block-yod), הכרעה בין פרשנויות - MUST NOT: ניתוח מעמיק (→ block-yod), הכרעה בין פרשנויות
- Dependencies: block-chet (מספור), block-vav (הגדרות תכניות) - Dependencies: block-chet (מספור), block-vav (הגדרות תכניות)
- Condition: **אופציונלי** — רק כשיש מורכבות תכנונית (תכניות סותרות, תמ"א 38 + שימור, פרשנות) - Condition: **אופציונלי** — רק כשיש מורכבות תכנונית (תכניות סותרות, תמ"א 38 + שימור, פרשנות)
- **סדר בתיקי רישוי (1xxx):** בלוק ט מופיע **לפני** בלוק ז (טענות) — הסדר ה→ו→ט→ז→ח→י→יא→יב. הקורא חייב להכיר את המסגרת הנורמטיבית (התכניות) לפני שהוא קורא את טענות הצדדים על פרשנותן. (לקח מ-1200-25 קרית ענבים; ראה legal-decision-lessons.md #41)
**Weight:** **Weight:**

View File

@@ -181,11 +181,12 @@
מבוסס על קריאת ה-10 החלטות + ההשוואה לטיוטות ה-AI: מבוסס על קריאת ה-10 החלטות + ההשוואה לטיוטות ה-AI:
### 3.1 ❌ אסור: רשימה ממוספרת בתוך פסקה ### 3.1 ❌ אסור: רשימת-מיני ממוספרת בתוך פסקת-אנליזה (פיצול טיעון ל-`(1)...(2)...`)
**ב-0/33** מהחלטות הסופיות יש `(1) ... (2) ... (3) ...` בתוך פסקת אנליזה אחת. **ב-0/33** מהחלטות הסופיות יש `(1) ... (2) ... (3) ...` המפצל טיעון בתוך פסקת אנליזה אחת. טענות וניתוח נכתבים כ**נרטיב רציף** עם ביטויי-מעבר ("עוד נטען", "באשר ל-", "יתרה מכך"), לא כרשימת-מיני.
**ב-3/3 טיוטות AI** שראיתי הופיעה רשימה ממוספרת — שהוסרה בעריכה.
⚠️ **הבחנה חשובה**: זה שונה ממספור פסקאות סדרתי (1, 2, 3 ... כאוטוט-של-פסקאות), שכן עד 2025 דפנה כן השתמשה במספור סדרתי (כמו פסיקה מסורתית). מ-2025-מאוחר זה נטוש; ההחלטות החדשות (1126-25, 1128-25, 1130-25, 1194-25) **ללא** מספור פסקאות. **המגמה החדשה** היא נרטיב רציף ללא מספור. **ההחלטה כן ממוספרת — תמיד.** פסקאות ההחלטה ממוספרות סדרתית (1, 2, 3 ... עד הסוף), כמקובל בפסיקה.
**הכותב מקדים כל פסקת-החלטה ב-"N. " בתחילת שורה** (1., 2., 3. ... סדרתי). זהו ה-signal שמנוע-הייצוא מזהה (`docx_exporter._NUM_PREFIX_RE`): הוא **מסיר את הקידומת הידנית וממיר אותה למספור-אוטומטי אמיתי של Word** (`_ensure_decision_numbering` — רשימה עשרונית רציפה, RTL). כך ה-DOCX מתמספר מעצמו (מתעדכן בעריכה, copy/paste נקי ללא ספרות תועות).
⚠️ **המספר חייב להיות בתחילת השורה בלבד** — מספר *באמצע* פסקה הוא רשימת-מיני אסורה (§3.1 לעיל). (תיקון 2026-06-06: ההנחה ש"ההחלטות החדשות ללא מספור" הייתה ארטיפקט-חילוץ; וההנחה ש"הכותב לא יקליד מספרים" שגויה — הקידומת בתחילת-שורה היא ה-signal לייצוא, שמומר ל-auto-numbering.)
### 3.2 ⚠️ מותנה: כותרת משנה בלב בלוק י ### 3.2 ⚠️ מותנה: כותרת משנה בלב בלוק י

View File

@@ -0,0 +1,37 @@
# רובריקת "הכללים המחמירים" לחילוץ הלכות — להחלה על הלכות קיימות
אתה בודק רשימת הלכות שחולצו מפסק דין **אחד**, ומחליט לכל אחת: לשמור או לחתוך (ובאיזו עילה).
המטרה: שיישארו רק **עקרונות משפטיים אמיתיים, מובחנים, בני-הכללה ובני-הסתמכות** — לא ציטוטים, לא אמרות-אגב, לא יישומים ספציפיים-לתיק, לא כפילויות.
## עילות חיתוך (verdict)
1. **cut_duplicate** — ההלכה מבטאת את **אותו עיקרון משפטי** של הלכה אחרת באותו פסק, גם אם בניסוח שונה / ציטוט שונה.
- קבץ את כל המופעים של אותו עיקרון. שמור **נציג אחד** בלבד; סמן את השאר cut_duplicate.
- בחירת הנציג (canonical): עדיפות rule_type (binding > interpretive > procedural > obiter) → confidence גבוה → quote_verified=true → הניסוח המלא/הברור ביותר.
- דווח `cluster_canonical_index` = ה-halacha_index של הנציג שנשמר.
2. **cut_obiter** — אמרת-אגב שהערכאה **לא הכריעה בה**. סימנים: "אין צורך להכריע", "מבלי לקבוע מסמרות", "איני רואה לקבוע מסמרות", "לא ראינו לקבוע", "ניתן/יש להניח ... אך", "למעלה מן הצורך", "אגב אורחא", או הסתמכות על "לכאורה" כבסיס.
- מבחן Wambaugh: אם שלילת הכלל **לא** הייתה משנה את תוצאת הפסק → obiter.
3. **cut_application** — קביעה שתלויה ב**עובדות התיק הספציפי** ואינה בת-הכללה: שמות צדדים ("המשיבים", "המערערים", שם משפחה), "במקרה דנן/שבפנינו", סכומים/תאריכים/מספרים ספציפיים למחלוקת, יישום הכלל על המבנה/ההיתר הקונקרטי. זהו "ציטוט שטוב שיש" — המחשה, לא הלכה.
4. **cut_thin** — restatement דק: ה-rule_statement כמעט מעתיק את supporting_quote בלי הפשטה; **או** הכלל מנוסח כרקע/מוסכמה ("אין חולק כי...") ולא כהכרעה.
5. **cut_quote** — ה-supporting_quote קטוע באמצע משפט / חסר, או quote_verified=false וההלכה נשענת עליו.
6. **keep** — עיקרון משפטי אמיתי, מובחן, בר-הכללה, שהוכרע, עם ציטוט תומך שלם.
## כללי הכרעה — רמה אגרסיבית
המטרה: להשאיר רק את **גרעין העקרונות המובחנים**. עדיף תמציתי ומדויק על פני שלם-ומנופח.
- **cut_application אסרטיבי:** כל קביעה שנשענת על עובדות/צדדים/סכומים ספציפיים לתיק → cut_application, גם אם משתמעת ממנה הלכה. ההלכה המופשטת כבר אמורה להופיע בנפרד; היישום עצמו מיותר.
- **מיזוג facets חופפים (cut_duplicate מורחב):** אם שתי הלכות עונות על **אותה שאלה משפטית** גם אם מזווית/פן שונה — מזג לנציג הכללי/binding ביותר. דוגמאות למיזוג: עקרונות-משנה בתוך אותו נושא (סמכות ועדת הערר, מתחם שיקול-הדעת התכנוני, מיצוי הליכים, בטלות יחסית).
- **גבול המיזוג (שמור):** אל תמזג הלכות שעונות על **שאלות משפטיות שונות** (למשל "מועד 30 יום להגשת ערר" ≠ "עקרון מיצוי ההליכים"; "פרשנות תיקון 43" ≠ "סמכות לפי סיווג הבקשה"). מזג פנים-של-אותה-שאלה, לא בין-שאלות.
- **dedup מושגי הוא העיקרי:** רוב החיתוך מ-cut_duplicate. שים לב לעקרונות שחוזרים 3-5 פעמים בניסוחים שונים וגם ל-facets שחוזרים סביב אותו נושא.
- בספק בין keep ל-cut בקטגוריה מאבדת-מידע: ברמה זו **נטה לחתוך** (אך לעולם לא למזג שאלות-משפטיות שונות).
## פלט (JSON בלבד)
מערך, פריט לכל הלכה:
```json
[{"halacha_index": <int>, "verdict": "keep|cut_duplicate|cut_obiter|cut_application|cut_thin|cut_quote", "cluster_canonical_index": <int או null>, "reason": "<משפט אחד>"}]
```

View File

@@ -446,3 +446,88 @@ The draft's biggest structural error was adding the "נבאר" doctrinal paragra
- [ ] Update block-schema.md: block order for 1xxx cases (ט before ז) - [ ] Update block-schema.md: block order for 1xxx cases (ט before ז)
- [ ] Update daphna-architecture-by-outcome.md: add "bridge planning" analysis for rejections - [ ] Update daphna-architecture-by-outcome.md: add "bridge planning" analysis for rejections
- [ ] Update writer system prompt: mandatory "להלן מתוך" pattern - [ ] Update writer system prompt: mandatory "להלן מתוך" pattern
---
## Lessons from Weekly Feedback (May 31, 2026)
### Source
- Chair feedback summary for week ending 2026-05-31
- Case: 8126-03-25 (ערר על חבות בהיטל השבחה - יעקב עמיאל), entries from CMPA-62
### 34. Don't Manufacture Doubt About Clear Statutes
- **Lesson:** סעיף 19(ג)(2) לתוספת השלישית קובע באופן חד-משמעי כי תקופת המגורים היא ארבע שנים מגמר הבנייה — אסור להציע "פרשנות חלופית" של שנה אחת או להכניס שאלות פתוחות על נוסח חוק שהוא ברור; הצגת ספק מלאכותי בכלל ברור מערפלת את הניתוח ומחלישה את הכרעה.
- **Rule:** When a statutory provision is unambiguous on its face, the analysis must state it as the binding rule — not as one possible reading among others. Spurious interpretive doubt is a methodology failure, not a sign of intellectual humility.
### 35. Writer/QA Sync Gap — Two Sources of Truth
- **Problem:** legal-writer updates `decision_blocks` in the DB, but legal-qa reads from `drafts/decision.md` on disk. In CMPA-62 the writer reported updating block headers in DB but the file did not re-sync, causing QA-2 to fail on exactly the same issue twice.
- **Lesson:** Single source of truth is mandatory — either the writer must write to BOTH the DB and the decision.md file in one atomic step, or there must be an automatic `regenerate-draft` hook that runs after every block update so the file always reflects the latest DB state. Two unsynchronized sources will keep producing the same false-fail loop.
- **Owner:** Infrastructure task — not a writer/QA prompt fix.
- **✅ RESOLVED (GAP-88, 2026-06-06):** `block_writer._update_draft_file` is now an automatic regenerate hook called from `store_block` (every persist) **and** `renumber_all_blocks` — so `drafts/decision.md` always reflects `decision_blocks`. legal-qa already validates against the DB; both sides are now identical.
---
## Lessons from Chair Feedback Backlog (June 6, 2026)
### Source
- Consolidation of all unresolved `chair_feedback` entries (21 items) from cases
1033-25, 1130-25 (קרית יערים), 1200-25 (קרית ענבים), 8126-03-25, 8137-24.
- Folded manually as part of closing the feedback→agent-knowledge loop. Some
overlap with earlier sections (1200-25, weekly-feedback) is intentional — this
section is the authoritative roll-up of the backlog.
### 36. Planning Background Is Argumentation, Not "General Info" (1130-25)
- **Lesson:** רקע תכנוני בהחלטה אינו "מידע כללי" — הוא משרת סוגיה ספציפית ומנוסח כחלק מהארגומנטציה הסילוגיסטית. בניתוח שינוי נסיבות, היסטוריית התכנון מראש ועד הפסקה האחרונה חיונית: היא ההנחה התחתונה (עובדות) של הסילוגיזם, לא רקע ניטרלי.
- **Rule:** When the discussion turns on change-of-circumstances, write the full planning history (every plan, every amendment, with years) as the factual premise of the argument — not as background filler.
### 37. Detail the Content of Another Body's Actions When Cited as Evidence (1130-25)
- **Lesson:** כשעמדת ועדת הערר מסתמכת על פעולות של גוף אחר (ועדה מחוזית) כראיה לשינוי נסיבות — חובה לפרט את **תוכן** אותן פעולות (מה התבקש, מה אושר, אילו תנאים), לא רק לציין שהתרחשו.
- **Rule:** "The district committee approved similar plans in 2023 and 2024" is insufficient — specify what each plan requested and what was approved, so the reader can judge whether it's truly comparable.
### 38. Map/GIS Images Are Visual Evidence, Not Decoration (1130-25)
- **Lesson:** תמונות מפה/GIS בהחלטות תכנון ובניה הן חלק מהארגומנטציה — ראיה ויזואלית שמשלימה את הניתוח הטקסטואלי (מיקום חלקות, סמיכות גיאוגרפית, כבישים ותשתיות מתוכננות). הכותב יסמן placeholder `[תמונה: <תיאור>]` והיו"ר תכניס בעריכה הסופית.
- **Rule:** When geographic proximity or planned infrastructure matters to the analysis, insert an image placeholder in the discussion — it is evidence, treated like any other.
### 39. Address Parallel Appeals in the Same Area Explicitly (1130-25)
- **Lesson:** כשיש עררים מקבילים באותו אזור (למשל ערר 1194-25 בחלקה סמוכה) — ההחלטה צריכה להתייחס לכך במפורש, לציין את ההבחנה בין התיקים, ולהבהיר שכל בקשה נבחנת לגופה. "אפקט דומינו" שהתממש הוא עובדה תכנונית, לא חשש תיאורטי.
- **Rule:** Name the parallel appeal, state how the present case differs, and reaffirm case-by-case examination.
### 40. The Chair's Text Skeleton Is a Structural Directive (1130-25)
- **Lesson:** שלד טקסט שהיו"ר מספקת (זרימה נרטיבית + נקודות מפתח ממוספרות) הוא הנחיה מבנית מחייבת — הכותב צריך לעקוב אחרי המבנה ולמלא בתוכן מלא, לא לנסח מחדש את הסדר. ה-placeholder "..." מסמן מעבר שצריך להשלים.
- **Rule:** When `get_chair_directions` / analysis-and-research.md contains a narrative skeleton, follow it step-by-step; treat each numbered point as a required paragraph.
### 41. Block Order in Licensing (1xxx): ט Before ז (1200-25)
- **Lesson:** בתיקי רישוי (1xxx) — בלוק ט (תכניות חלות) צריך להופיע **לפני** בלוק ז (טענות), לא אחריו. הסדר הנכון: ה→ו→ט→ז→ח→י→יא→יב. הרציונל: הקורא צריך להכיר את המסגרת הנורמטיבית (התכניות) לפני שהוא קורא את טענות הצדדים על פרשנותן.
- **Rule:** For 1xxx cases, emit applicable plans (ט) before the parties' claims (ז). See `docs/block-schema.md`.
### 42. "להלן מתוך [מסמך]:" Is Mandatory (1200-25)
- **Lesson:** תבנית "להלן מתוך [שם המסמך]:" היא חובה בכל מקום שמתייחסים למסמך מקור — placeholder להכנסת ציטוט ישיר/תמונה. דוגמאות: "להלן מתוך הוראות התכנית:", "להלן מתוך פרוטוקול הדיון:", "להלן מתוך הבקשה להיתר:". See `skills/decision/SKILL.md`.
- **Rule:** Every reference to a source document gets a "להלן מתוך [exact doc name]:" placeholder.
### 43. Block ו Must Contain a Full Timeline (1200-25)
- **Lesson:** בלוק ו חייב לספר את "הסיפור" המלא של התיק עם ציר זמן: מתי הוגשה הבקשה, מתי פורסמה, כמה התנגדויות הוגשו, מתי התקיימו דיונים בוועדה מקומית ומה הוחלט בכל אחד, ומתי הוגש הערר. כל ישיבה עם תאריך + תוצאה.
- **Rule:** Block ו is a dated narrative, not a one-liner.
### 44. Point-Plan vs. Comprehensive-Plan Harmony (1200-25)
- **Lesson:** בתיק רישוי שבו המבקש מקדם גם תכנית — חובה לנתח האם התכנית הנקודתית תואמת את התכנית הכוללנית. אם יש סתירה (למשל השוואה כמותית: הכוללנית מקצה 4,404 מ"ר לכל המסחר ביישוב, מול 1,425 מ"ר בבקשה אחת) — זה **מחזק** את הדחייה. מסגרת "גשר תכנוני": שימוש חורג יכול לגשר על פער תכנוני רק אם התכנית המקודמת תואמת את הכיוון הכולל (כוכבה תורן).
- **Rule:** Check `search_case_documents` for pending plans; compare point-plan to comprehensive-plan; a contradiction strengthens rejection.
### 45. Don't Skip the "Non-Profit Institution" Threshold in s.19(ב)(4) (8137-24)
- **Lesson:** כשמסמכי יסוד של מוסד מוגשים, אין לדלג על תנאי "המוסד שאין עיסוקו לשם קבלת רווחים" בס' 19(ב)(4) — זהו התנאי **הראשון** ויש לבססו על ציטוט פסקאות ספציפיות מתעודות היסוד (חוקה, תקנון, הסכמים), לא על רישום מלכ"ר בלבד. רישום ≠ ראיה חלוטה (תקדים הלפרן, ערר מרכז 8013-03-21). יש לתחם: הפרק מכריע בתנאי הזהות+אי-רווח בלבד; תנאי השימוש לפרק נפרד.
- **Rule:** In betterment-levy exemption cases, the non-profit-identity condition is condition #1 — prove it via specific cited paragraphs of the foundational documents, never via registration status alone.
### 46. Distinguish Appeal-Letter Claims from Correspondence Claims (1033-25)
- **Lesson:** בדיקת כיסוי הטענות (claims_coverage) צריכה להבחין בין טענות שעלו בכתב הערר (חובה לענות) לבין טענות שעלו בתכתובות/תגובות בין הצדדים (לא חייבות מענה עצמאי, במיוחד כשהערר מתקבל במלואו וההחלטה בוטלה). סימון טענות-תכתובת כ"לא נענו" הוא false-positive.
- **Rule:** Only claims raised in the appeal letter itself require a dedicated answer; correspondence-only claims do not, especially when the appeal is fully accepted. (Also tracked as a system task — the automated check needs this distinction.)
### System/Infrastructure Items (NOT writer lessons)
These two entries are technical gaps, not decision-writing lessons — captured in TaskMaster, not consumed by the writer:
- **claims_coverage check** (1033-25): the automated coverage check must distinguish appeal-letter claims from correspondence claims (see #46).
- **DB↔file sync gap** (8126-03-25): see #35 above — writer writes to `decision_blocks` (DB) while QA reads `drafts/decision.md` (disk). Infrastructure fix.
### Note on case-specific issue-ordering entries
Two 1200-25 entries recorded a case-specific issue order (threshold → plan interpretation
→ ancillary-vs-primary → significant-deviation → comprehensive-plan → grouped: reasoning,
traffic) with no generalizable rule. They are case artifacts, captured in that case's
analysis-and-research.md — no general lesson folded.

View File

@@ -178,10 +178,21 @@ ISO 15489-1:2016 (records authenticity/integrity) | סטטוס: verified
### INV-G10: המערכת מסייעת — שערים אנושיים הם invariant ### INV-G10: המערכת מסייעת — שערים אנושיים הם invariant
**כלל:** המערכת **מסייעת ואינה מחליפה את שיקול-הדעת האנושי**. השערים האנושיים (אישור **כלל:** המערכת **מסייעת ואינה מחליפה את שיקול-הדעת האנושי**. השערים האנושיים (אישור
הלכה, בחירת תוצאה, פידבק היו"ר) הם **invariant — חובה, לא רשות**. הלכה, בחירת תוצאה, פידבק היו"ר) הם **invariant — חובה, לא רשות**.
**תיקון (החלטת-יו"ר 2026-05-31):** שער אישור-ההלכה יכול להיות מסופק ע"י **טיפול שיפוטי מצטבר**
(citator פנימי), לא רק ע"י היו"ר — הלכה ש**אומצה (followed) ע"י ≥N ערכאות/ועדות מצטטות, ללא
טיפול שלילי**, מאושרת אוטומטית. זהו **שיפוט אנושי** (של המצטטים), לא שיפוט-AI (ה-AI רק מזהה
ומסווג את הטיפול הקיים). **שער-היו"ר נשאר חובה** לזנב הלא-מצוטט ולכל טיפול שלילי
(distinguished/overruled). מפורט ב-[X11-citation-corroboration.md](X11-citation-corroboration.md)
(INV-COR1COR6).
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* ("never replace human **מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* ("never replace human
judgment") · CEPEJ (2018, under user control) · Federal Judicial Center — *Judicial Writing judgment") · CEPEJ (2018, under user control) · Federal Judicial Center — *Judicial Writing
Manual* (2d ed.) | סטטוס: verified Manual* (2d ed.) · [לתיקון — מקורות פתוחים:] Fowler et al., *Network Analysis and the Law*
**אכיפה:** שערים אנושיים בקוד-הזרימה (gate לא ניתן לעקיפה); מפורט ב-[05-qa-review.md](05-qa-review.md). (Political Analysis 15:3, 2007) — ציטוטים-נכנסים = מדד-סמכות · Demir & Canbaz, *Validate Your
Authority: Benchmarking LLMs on Multi-Label Precedent Treatment Classification* (NLLP/ACL, 2025) ·
Hellyer (Law Library Journal 110:4, 2018, open-access) — טיפול-שיפוטי-מצטבר כמתודולוגיה מתועדת
| סטטוס: verified
**אכיפה:** שערים אנושיים בקוד-הזרימה (gate לא ניתן לעקיפה); מסלול-corroboration ב-
[X11](X11-citation-corroboration.md); מפורט ב-[05-qa-review.md](05-qa-review.md).
**הפרה ידועה:** 10/19 הלכות מאושרות, התגלה במקרה — שער ידני שקוף בלי נראות backlog → **הפרה ידועה:** 10/19 הלכות מאושרות, התגלה במקרה — שער ידני שקוף בלי נראות backlog →
ממצא ל-[audit](../audit-report.md). ממצא ל-[audit](../audit-report.md).
@@ -216,7 +227,7 @@ Manual* (2d ed.) | סטטוס: verified
## 7. אינדקס הספ ## 7. אינדקס הספ
> הערה: כל קבצי הספ (00, 0107, X1X5) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה. > הערה: כל קבצי הספ (00, 0107, X1X10) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה.
| קובץ | תפקיד | אוכף invariants | | קובץ | תפקיד | אוכף invariants |
|------|--------|-----------------| |------|--------|-----------------|
@@ -233,6 +244,16 @@ Manual* (2d ed.) | סטטוס: verified
| [X3-integration-deploy.md](X3-integration-deploy.md) | Paperclip (wakeup, ניתוב comments, webhooks) · Coolify/pm2 | G2, G9 (תפעולי) | | [X3-integration-deploy.md](X3-integration-deploy.md) | Paperclip (wakeup, ניתוב comments, webhooks) · Coolify/pm2 | G2, G9 (תפעולי) |
| [X4-agents.md](X4-agents.md) | מפת הסוכנים (דומיין + סוכני-התהליך) | G10 | | [X4-agents.md](X4-agents.md) | מפת הסוכנים (דומיין + סוכני-התהליך) | G10 |
| [X5-audit-provenance.md](X5-audit-provenance.md) | audit-trail לשימוש ב-AI · עקיבוּת כל מקור מצוטט · שלמות-רשומה | G5, G9 | | [X5-audit-provenance.md](X5-audit-provenance.md) | audit-trail לשימוש ב-AI · עקיבוּת כל מקור מצוטט · שלמות-רשומה | G5, G9 |
| [X6-ui-api-contract.md](X6-ui-api-contract.md) | web-ui ↔ API: OpenAPI=SSoT · response models · envelope · SSE · חוזי-טופס + כללי-עיצוב | G2, G4, G9 (UI) |
| [X7-paperclip-client-params.md](X7-paperclip-client-params.md) | לקוח-Paperclip קנוני · IDs/env/keys מ-config · webhook idempotency/אירוע מגורס | G2, G9 (תפעולי) |
| [X8-field-provenance.md](X8-field-provenance.md) | מקור-מילוי כל שדה (דטרמיניסטי/Opus/ידני/נגזר) · preservation · trust · verbatim-quote | G9, G10 |
| [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) | חוזה 71 כלי-ה-MCP: envelope · שמות · idempotency · extract/get-symmetry · שלמות-הרשאות | G2, G3, G10 |
| [X10-deploy-env-secrets.md](X10-deploy-env-secrets.md) | env-catalog SSoT · מקור-config יחיד (Coolify) · ללא hardcode · secrets · drift | G2, G4, G9 |
| [X11-citation-corroboration.md](X11-citation-corroboration.md) | citator פנימי — תיקוף הלכות בטיפול-שיפוטי מצטבר · תיקון-G10 מבוקר · סף-corroboration · התאמה-להלכה | G9, G10 |
> **X6X10 (מחזור-2):** מכסים את 8 משטחי-האפליקציה שמחוץ לצינור-הליבה (אינטגרציה, web-ui, מילוי-שדות,
> אחסון-ניתוחים, כלי-MCP, deploy/env). הממצאים ב-[gap-audit.md](gap-audit.md) (GAP-24..62 → FU-9..15)
> וב-[ui-audit.md](ui-audit.md). הרחבות-אחות: [02-data-model](02-data-model.md) (INV-DM4DM6), [X4-agents](X4-agents.md) (INV-AG3).
**עקרונות:** כל קובץ עצמאי, ממוקד, agent-readable, יעד ≤~500 שורות (תפיחה = סימן **עקרונות:** כל קובץ עצמאי, ממוקד, agent-readable, יעד ≤~500 שורות (תפיחה = סימן
לפיצול). מסמכים קיימים (`architecture.md`, `product-specification.md`, `block-schema.md`…) לפיצול). מסמכים קיימים (`architecture.md`, `product-specification.md`, `block-schema.md`…)

View File

@@ -76,6 +76,19 @@ proceeding_type)`. לכן המזהה הקנוני הוא **(`case_number` מנו
- `decision_blocks` → usable: `block_id`∈12-הבלוקים; "מוכן": `status=final` ו-`content` לא-ריק. - `decision_blocks` → usable: `block_id`∈12-הבלוקים; "מוכן": `status=final` ו-`content` לא-ריק.
- `chair_feedback` → usable: `feedback_text`+`category` מהמילון; "פתוח" עד `resolved=true`. - `chair_feedback` → usable: `feedback_text`+`category` מהמילון; "פתוח" עד `resolved=true`.
### 2ג. ישויות-נגזרות (אחסון-ניתוחים)
מעבר לישויות-המקור, המערכת **שומרת ניתוחים נגזרים** — תוצרי-חילוץ של LLM/קוד. אלו כפופים לכללי
ה-provenance של [X8](X8-field-provenance.md) ולשערי [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant):
| ישות-נגזרת | מקור-מילוי | שער-אישור | קישור-מקור |
|------------|------------|-----------|------------|
| `claims` | OPUS (`extract_claims`) | — | `source_document` (string, לא-FK) |
| `legal_arguments` (+`legal_argument_propositions`) | OPUS (`aggregate_claims_to_arguments`) | **חסר** (בניגוד ל-halachot) | `cited_precedents TEXT[]` (לא-FK) |
| `appraiser_facts` | OPUS (`extract_appraiser_facts`) | — | `document_id` (FK); `appraiser_side` default `''` |
| `halachot` | OPUS (`halacha_extractor`) | **`review_status`** ✓ | `case_law_id` (FK); `quote_verified` |
| `decision_blocks` / `decision_paragraphs` | Opus/script (`write_block`) | `status` | `model_used` + audit-event provenance (FU-7); `citations JSONB` ללא-FK |
--- ---
## 3. Invariants של התחום ## 3. Invariants של התחום
@@ -120,6 +133,28 @@ RAG freshness (Lewis et al., 2020, NeurIPS) | סטטוס: verified
[G6](00-constitution.md#inv-g6-re-index-בכל-שינוי-תוכן). [G6](00-constitution.md#inv-g6-re-index-בכל-שינוי-תוכן).
**הפרה ידועה:** **הפרה ידועה:**
### INV-DM4: לכל ישות-נגזרת — provenance מוצהר
**כלל:** כל ישות-נגזרת (claims, legal_arguments, appraiser_facts, decision_blocks, halachot) נושאת
**provenance** — מי/מה הפיק (מודל, גרסה, זמן) ולאילו chunks/מקורות היא קשורה. מופע של
[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai); מקביל ל-[X8 INV-FP1](X8-field-provenance.md).
**מקורות:** ISO 8000-110 (data lineage) · DAMA-DMBOK2 (lineage) · ISO 15489-1:2016 (records authenticity) | סטטוס: verified
**אכיפה:** עמודות-provenance + קישור block→source (חלקית דרך audit-event ב-FU-7/GAP-19; ל-legal_arguments טרם).
**הפרה ידועה:** `legal_arguments` ללא provenance; `embedding` ללא model/version ([gap-audit GAP-42](gap-audit.md)).
### INV-DM5: פלט-ניתוח של LLM נכנס בשער-אישור (כמו halachot)
**כלל:** ישות-נגזרת שמוּלאת ע"י LLM ומשפיעה על ההחלטה נכנסת **לא-מאושרת** עד אישור-יו"ר — אותו שער כמו
`halachot.review_status`. מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant); תואם [X8 INV-FP3](X8-field-provenance.md).
**מקור-סמכות:** דפוס `halachot.review_status` (`db.py:659`); [05-qa-review.md](05-qa-review.md). (פרויקטלי-תפעולי — משרת G10.)
**אכיפה:** שדה-סטטוס-אישור על ישויות-נגזרות מהותיות.
**הפרה ידועה:** `legal_arguments` **חסר** שער-אישור — נכתב ומשמש ללא בקרת-יו"ר ([gap-audit GAP-39](gap-audit.md)).
### INV-DM6: ולידציה — CHECK-enums, FK לציטוטים, ללא טבלאות-מקבילות
**כלל:** ערכי-enum נאכפים ב-CHECK (לא TEXT חופשי); ציטוט-מקור נשמר כ-FK (לא string/array חופשי); אין שתי
טבלאות לאותה ישות. מופע של [G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש) ו-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים). **הנדסי.**
**מקורות:** E.F. Codd (referential integrity, CACM 1970) · ISO 8000 (validity) · Kleppmann *DDIA* | סטטוס: verified
**אכיפה:** CHECK על enums; FK על `cited_precedents`/`decision_paragraphs.citations`; איחוד `case_precedents``case_law`.
**הפרה ידועה:** 20+ enums כ-TEXT חופשי; `legal_arguments.cited_precedents TEXT[]` ללא-FK (הזיות-LLM נבלעות); `case_precedents` מול `case_law` מקבילות ([gap-audit GAP-40/42/43](gap-audit.md)).
--- ---
## 4. מצב קיים מול יעד — audit-findings ## 4. מצב קיים מול יעד — audit-findings
@@ -153,3 +188,5 @@ RAG freshness (Lewis et al., 2020, NeurIPS) | סטטוס: verified
- [03-retrieval.md](03-retrieval.md) — שכבת-האחזור שאוכפת את הסינון searchable + re-index. - [03-retrieval.md](03-retrieval.md) — שכבת-האחזור שאוכפת את הסינון searchable + re-index.
- [X1-identifiers.md](X1-identifiers.md) — נרמול המזהה הקנוני בכתיבה (בסיס ל-INV-DM2). - [X1-identifiers.md](X1-identifiers.md) — נרמול המזהה הקנוני בכתיבה (בסיס ל-INV-DM2).
- [X5-audit-provenance.md](X5-audit-provenance.md) — שלמות-רשומה + עקיבוּת-מקור. - [X5-audit-provenance.md](X5-audit-provenance.md) — שלמות-רשומה + עקיבוּת-מקור.
- [X8-field-provenance.md](X8-field-provenance.md) — מקור-מילוי השדות (בסיס ל-INV-DM4/DM5).
- [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) — הכלים שמייצרים את הישויות-הנגזרות.

View File

@@ -19,6 +19,41 @@
--- ---
## 0. תת-מערכת רכישת-הסגנון (Style Acquisition) — יעד-העל וההפרדה מהכתיבה
**יעד-העל של legal-ai:** שהסוכנים יכתבו וינתחו עררים **בדיוק כמו עו"ד דפנה תמיר** — להפנים את הקול והשיטה, לא רק לייצר טיוטה תקנית. ל-end זה מחייב **הפרדה מובהקת בין שתי תת-מערכות**:
| | **Writing Subsystem** | **Style-Acquisition Subsystem** |
|---|---|---|
| שאלה | "איך אכתוב את התיק כמו דפנה?" | "מה למדנו מהפער בין מה שכתבנו למה שדפנה חתמה?" |
| טריגר | issue כתיבה | `mark-final` |
| פלט | 12 בלוקים | עדכוני-קול מאושרים + מדד-מרחק |
| סוכנים | writer/analyst/qa/ceo | hermes-curator (מורחב) |
| יחס ל-artifacts-הקול | **צרכן read-only** | **היחיד שכותב** (דרך שער INV-G10) |
### 0.1 הגישה: Authorial Style Profiling, לא fine-tuning
היעד הוא **Text Style Transfer** מבוסס **פרופיל-סגנון מופשט** — להכליל את סגנון/שיטת דפנה ולהתאים לתיק הספציפי. fine-tuning של משקולות **לא רלוונטי**: המודל (Opus) סגור, והקורפוס (~48 החלטות, יו"ר חדשה) קטן מדי — מצב שבו הספרות מראה שפרופיל-מופשט + דוגמאות מנצח (≈+15% מעל RAG-בלבד). **מדיניות-העתקה לפי סוג-תוכן:** קבוע/נוסחאי (פתיחים דוקטרינליים, תבניות-סיום) → מותר להעתיק; ניתוח/טענות ספציפיים → להכליל ולהתאים; מהות (הלכה/עובדה מתיק אחר) → אסור (INV-LRN5).
### 0.2 שלושת ערוצי-ההזנה לכותב
1. **A — פרופיל-מופשט (ראשי):** voice-fingerprint + author-features כמותיים, מוזרק לכתיבה.
2. **B — דוגמאות + תבניות (תומך):** פסקאות-בלוק אמיתיות + Copy-Paste Templates + contrastive.
3. **C — deep-read (נקודתי):** voice-XXXX.md — worked example לתיק-מופת.
### 0.3 הצינור החוזר per-final (7 שלבים)
`mark-final` → [1] INTAKE (snapshot של הטיוטה) → [2] PAIRING (בלוק↔בלוק) → [3] ALIGNMENT (diff פר-בלוק) → [4] DISTILLATION (מפריד סגנון↔מהות) → [5] CURATION (Hermes + שער-יו"ר) → [6] FEEDBACK (ניתוב לערוץ A/B/C) → [7] MEASUREMENT (מדד-מרחק-סגנון).
### 0.4 ניהול ב-UI
`/methodology` = **עורך-הפרופיל** (declarative: יחסי-זהב, כללי-דיון, צ׳קליסטים, ביטויי-מעבר, אנטי-דפוסים, voice-invariants). `/training` = **שולחן-הלמידה** (קורפוס, פורטרט-סגנון, השוואת draft↔final, curator, מדד-מרחק, פנקס-התאמה).
### 0.5 Invariants חדשים
**INV-LRN4 (ניגוד-אמת → G10/G9):** למידת-קול מבוססת **pairing draft↔final ברמת-בלוק**, לא קריאת-final בלבד. כל החלטה אינה "סגורה" עד שהושוותה מול הסופי; כל סופי מנותח מול הטיוטה. נשמר פנקס-התאמה (`draft_final_pairs`) עם מצב-חיים `draft_done → final_received → analyzed → lessons_folded`.
*מקורות:* imitation-learning-from-expert-edits · contrastive personalization (arxiv 2504.08745) · author-profiling. *סטטוס: verified.*
**INV-LRN5 (טוהר-הקול → G4/G11):** שכבת-ידע-הקול (voice-fingerprint, style_patterns, exemplars) **לא תכיל הלכות/עובדות ספציפיות** — רק סגנון ושיטה. מהות מנותבת ל-precedent_library/halacha. ה-distillation מפריד במקור.
*מקורות:* quality-at-source (Data Mesh) · separation-of-concerns. *סטטוס: verified.*
---
## 1. שלוש לולאות-המשנה ## 1. שלוש לולאות-המשנה
הלמידה אינה אירוע יחיד אלא **שלוש לולאות** המתנקזות לאותם מסמכי-ידע מוסמכים הלמידה אינה אירוע יחיד אלא **שלוש לולאות** המתנקזות לאותם מסמכי-ידע מוסמכים

View File

@@ -3,5 +3,9 @@
זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md). זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md).
כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר. כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר.
מבנה: 00 חוקה · 0107 מחזור-חיים · X1X5 חוצי-שלבים. ראה אינדקס מלא בחוקה. מבנה: 00 חוקה · 0107 מחזור-חיים · X1X10 חוצי-שלבים. ראה אינדקס מלא בחוקה.
- X1X5: מזהים · רב-חברתי · אינטגרציה+deploy · סוכנים · audit.
- X6X10 (מחזור-2, 8 משטחי-האפליקציה): חוזה UI↔API · לקוח-Paperclip · מילוי-שדות · חוזה כלי-MCP · deploy/env/secrets.
מפות-ממצאים: [gap-audit.md](gap-audit.md) (GAP-01..62 → FU-1..15; מחזור-1 ✅ הושלם, מחזור-2 פתוח) · [ui-audit.md](ui-audit.md) (ביקורת 13 דפי-UI).
בסיס-עיצוב: docs/superpowers/specs/2026-05-30-system-spec-design.md בסיס-עיצוב: docs/superpowers/specs/2026-05-30-system-spec-design.md

View File

@@ -0,0 +1,86 @@
# X10 — Deploy, סביבה וסודות (Deploy, Environment & Secrets)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-deep-dive על **קונפיגורציה, משתני-סביבה
וסודות** — מה שהיה מכוסה כחצי-deploy בלבד ב-[X3 §2](X3-integration-deploy.md). הוא מגדיר את חוזה-ה-env
(SSoT אחד), מקור-ה-config (Coolify), טיפול-הסודות, ואי-ה-hardcode. X3 נשאר הבעלים של **זרימות**-האינטגרציה;
X10 הבעלים של **הקונפיגורציה וה-deploy**.
> **invariant פרויקטלי-תפעולי + הנדסי.** ENV1/ENV3/ENV4/ENV5 נשענים על עקרונות-הנדסה מוכרים (12-Factor,
> ניהול-סודות) — ≥3 מקורות. ENV2 (מקור-config של *מערכת זו*) הוא תפעולי, נקשר ל-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
---
## 1. מצב קיים (מאומת מול הקוד)
- **מודל-deploy:** legal-ai = Coolify Docker (UUID `gyjo0mtw2c42ej3xxvbz8zio`, build_pack `dockerimage`);
ה-env **מוזרק ישירות מ-Coolify**, לא מ-Infisical ([X3 §2](X3-integration-deploy.md); זיכרון `reference_legal_ai_env_architecture`).
- **40+ משתני-env** נקראים על-פני [config.py](../../mcp-server/src/legal_mcp/config.py), [web/app.py](../../web/app.py),
[paperclip_api.py](../../web/paperclip_api.py)/[paperclip_client.py](../../web/paperclip_client.py),
[gitea_client.py](../../web/gitea_client.py), [chat_proxy.py](../../web/chat_proxy.py).
- **קטלוג-UI** ([mcp_env_catalog.py](../../web/mcp_env_catalog.py)) מכסה **13 בלבד** מתוך ה-40+ → השאר בלתי-נראים
לדף-ההגדרות ולגילוי-drift.
- **Infisical:** קוד-ה-SDK ב-[config.py](../../mcp-server/src/legal_mcp/config.py) קורא `INFISICAL_TOKEN`, אך
בקונטיינר הוא **לעולם לא מוגדר** → קוד מת; ה-priority בפועל = Coolify-env בלבד.
---
## 2. Invariants של התחום
### INV-ENV1: env-catalog יחיד = SSoT לכל משתני-הסביבה
**כלל:** קיים **קטלוג-env יחיד** המתאר את **כל** המשתנים (שם, ברירת-מחדל, סוד?, מי-קורא, מה-שולט). אין משתנה
שנקרא-בקוד אך לא-בקטלוג, ואין משתנה-בקטלוג שלא-נקרא. מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
ו-[G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש) (שלמות-הקטלוג). **הנדסי.**
**מקורות:** *The Twelve-Factor App — III. Config* (https://12factor.net/config) · OWASP — *Configuration / Secrets Management Cheat Sheet*
(https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html) · Kleppmann *DDIA* (config as data) | סטטוס: verified
**אכיפה:** קטלוג מקיף + בדיקה ש-getenv call-sites ⊆ קטלוג. **כיום:** 13/40+ בלבד ([gap-audit GAP-60](gap-audit.md)).
**הפרה ידועה:** `PAPERCLIP_BOARD_API_KEY`/`GITEA_*`/`CHAT_SERVICE_URL`/`LEGAL_CHAT_SHARED_SECRET` לא בקטלוג; `GITEA_ACCESS_TOKEN` מול `GITEA_TOKEN` (שני שמות) ([gap-audit GAP-58](gap-audit.md)).
### INV-ENV2: מקור-config יחיד ומתועד (Coolify) — בלי קוד-מת
**כלל:** למערכת **מקור-config אחד מתועד** (Coolify-env לקונטיינר), והקוד אינו מניח מקור-שני שאינו פעיל.
אין "Infisical priority" מדומה כשאין `INFISICAL_TOKEN`. מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
(מקור-אמת יחיד) וכלל "אין בליעה שקטה" ([§6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)). **פרויקטלי-תפעולי.**
**מקור-סמכות:** זיכרון `reference_legal_ai_env_architecture`; `feedback_infisical_coolify_drift`; [X3 §2](X3-integration-deploy.md).
**אכיפה:** לתעד Coolify כ-SSoT; להסיר/לבודד את קוד-ה-Infisical או להפעילו אמיתית.
**הפרה ידועה:** קוד-Infisical ב-[config.py](../../mcp-server/src/legal_mcp/config.py) מת בקונטיינר; ה-priority המתועד לא תואם מציאות ([gap-audit GAP-55](gap-audit.md)).
### INV-ENV3: ללא hardcode — IDs/URLs/נתיבים מ-config
**כלל:** מזהים (company/agent), כתובות (Paperclip/Coolify/Gitea/chat/frontend), פורטים ונתיבים **נגזרים מ-config**,
לא קבועים בקוד. אין `/home/chaim` קשיח ואין UUID קשיח. מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
(SSoT) — תואם [X7 INV-INT5](X7-paperclip-client-params.md). **הנדסי.**
**מקורות:** *Twelve-Factor App — III. Config* · *Twelve-Factor — X. Dev/prod parity* (https://12factor.net/dev-prod-parity) ·
Google *SRE / configuration as data* (https://sre.google/workbook/configuration-design/) | סטטוס: verified
**אכיפה:** grep-gate נגד literals (UUID/URL/path) בקוד-חדש. **כיום אין.**
**הפרה ידועה:** UUIDs קשיחים ([paperclip_client.py:36-62](../../web/paperclip_client.py), [app.py:3976](../../web/app.py)); URLs קשיחים (`pc.nautilus...`, `coolify...`, `legal-ai-next...`); `LEGAL_AI_WORKSPACE_CWD="/home/chaim/legal-ai"`; chat-URL `10.0.1.1` מול תיעוד `host.docker.internal` ([gap-audit GAP-56/59/61](gap-audit.md)).
### INV-ENV4: אין secrets בקוד/בברירות-מחדל — fail-loud
**כלל:** שום סוד (creds/key/token) אינו בקוד או בברירת-מחדל; היעדר-סוד **נכשל בקול** (לא נופל לברירת-מחדל
שקטה עם creds). אין סוד מודלף ל-log או ל-git. מופע של [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)
(integrity) וכלל "אין בליעה שקטה" ([§6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)). **הנדסי.** תואם זיכרון `feedback_secrets_first`.
**מקורות:** OWASP — *Secrets Management Cheat Sheet* (https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html) ·
*Twelve-Factor — III. Config* (no secrets in code) · CWE-798 — *Use of Hard-coded Credentials* (https://cwe.mitre.org/data/definitions/798.html) | סטטוס: verified
**אכיפה:** ברירות-מחדל ריקות + כישלון-מפורש; secret-scan ב-CI.
**הפרה ידועה:** `PAPERCLIP_DB_URL` ברירת-מחדל `postgresql://paperclip:paperclip@...` (creds plaintext) ב-3 מקומות ([paperclip_client.py:21](../../web/paperclip_client.py), [app.py:3789,3964](../../web/app.py)) ([gap-audit GAP-57](gap-audit.md)).
### INV-ENV5: drift-detection מכסה את כל המשתנים הקריטיים
**כלל:** מנגנון גילוי-ה-drift (Coolify↔container) מכסה את **כל** המשתנים הקריטיים, לא תת-קבוצה. מופע של
[G6](00-constitution.md#inv-g6-re-index-בכל-שינוי-תוכן) ברוח-שלו (freshness של config) ו-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים). **הנדסי.**
**מקורות:** *Twelve-Factor — III. Config* · Google *SRE — config drift* · HashiCorp — *config drift / desired state* (https://developer.hashicorp.com/well-architected-framework) | סטטוס: verified
**אכיפה:** הרחבת ה-catalog ל-drift-detection מלא בדף-ההגדרות.
**הפרה ידועה:** רק 13/40+ במנגנון; 8+ סודות קריטיים בלתי-מנוטרים ([gap-audit GAP-60](gap-audit.md)).
---
## 3. Deploy — עמידוּת (מ-X3 §2, מורחב)
- **מחזור:** commit→push→Gitea Actions→Coolify redeploy (~2-4 דק'); endpoint חדש דורש גם `npm run api:types` ([X3 §2](X3-integration-deploy.md), [INV-INT2](X3-integration-deploy.md)).
- **חולשות-עמידוּת שנמצאו:** [start.sh](../../start.sh) **אינו נכשל** אם uvicorn לא עולה (ה-UI עולה עם בקאנד שבור);
ה-curl ל-Coolify ב-[.gitea/workflows/deploy.yaml](../../.gitea/workflows/deploy.yaml) הוא fire-and-forget (אין אימות-הצלחה) ([gap-audit GAP-62](gap-audit.md)).
- **host.docker.internal:** ה-chat-service נדרש דרך gateway; תיעוד מול קוד לא-תואמים (10.0.1.1) — ENV3.
---
## 4. הפניות-אחיות
- [X3-integration-deploy.md](X3-integration-deploy.md) — זרימות-אינטגרציה + INV-INT2 (מחזור-deploy).
- [X7-paperclip-client-params.md](X7-paperclip-client-params.md) — IDs/keys של Paperclip (INV-INT5 תואם ENV3).
- [00-constitution.md](00-constitution.md) — [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים), [G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש), [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai), כלל "אין בליעה שקטה" ([§6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
- זיכרונות: `reference_legal_ai_env_architecture`, `feedback_infisical_coolify_drift`, `feedback_secrets_first`.
- [config.py](../../mcp-server/src/legal_mcp/config.py), [mcp_env_catalog.py](../../web/mcp_env_catalog.py), [Dockerfile](../../Dockerfile), [start.sh](../../start.sh), [.env.example](../../.env.example).

View File

@@ -0,0 +1,182 @@
# X11 — תיקוף-הלכות בציטוטים (Citation Corroboration / Internal Citator)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md). הוא מגדיר **שכבת citator פנימית**: שימוש
ב**ציטוטים-הנכנסים** לפסיקה (איך ערכאות וועדות מאוחרות *טיפלו* בה) כדי **לתקף ולחדד את ההלכות
שחולצו ממנה**, וכך לצמצם את היקף האישור-הידני של היו"ר. הוא אוכף את
[INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant) (כפי שתוקן —
ראה §6), נשען על [INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)
(עקיבוּת-מקור), ומעמיק את מודל-הציטוטים של [02-data-model.md](02-data-model.md).
> **TARGET, לא תיאור-מצב.** המנגנון כאן הוא היעד. רכיבים שטרם נבנו מסומנים מפורשות
> כ-audit-finding (§7), ולא כהתנהגות קיימת. כל טענה על הקוד מצוטטת `file:line`.
---
## 1. הרעיון — citator פנימי
בעולם המשפטי, הכלים שמאמתים פסיקה לפי הציטוטים-הנכנסים אליה הם **citators** (Shepard's של
LexisNexis, KeyCite של Westlaw, BCite של Bloomberg). הם עונים על שתי שאלות: *האם הפסק עדיין
"good law"?* ו-*איך ערכאות מאוחרות טיפלו בו?* — לפי **סיווג-טיפול** (treatment) של כל ציטוט-נכנס.
המערכת שלנו מחזיקה כבר את חומר-הגלם: גרף-ציטוטים פנימי (§2). מה שחסר הוא **השכבה שמחברת אותו
להלכות** — לתקף הלכה ספציפית לפי כך שערכאות/ועדות מאוחרות *אימצו* אותה בפועל. הלכה שאומצה
שוב-ושוב ע"י פאנלים אחרים אינה "ניחוש של מודל" — היא **טיפול שיפוטי אנושי מצטבר**, וזה הבסיס
שמאפשר אישור-אוטומטי בלי לפגוע בשיקול-הדעת האנושי (ראה תיקון INV-G10, §6).
---
## 2. חומר-הגלם הקיים — שני גרפי-ציטוט
| טבלה | קושר | הקשר נשמר | סיווג-טיפול |
|------|------|-----------|-------------|
| `case_law_citations` (`db.py:382`) | פסיקה ← **החלטת-ועדה פנימית** (`decisions`) | `context_text` | `citation_type` (support/distinguish/overrule/obiter) |
| `precedent_internal_citations` (`db.py:938`) | פסיקה ← **פסיקה אחרת** (`case_law`) | `match_context` | — (אין שדה-טיפול) |
**audit-finding (קיים):** ב-`precedent_internal_citations` **אין** שדה סיווג-טיפול, ו-ב-
`case_law_citations` שדה `citation_type` קיים אך **ברירת-המחדל `'support'`** (`db.py:387`) —
כלומר רוב הרשומות לא סווגו בפועל. סיווג-הטיפול הוא רכיב שיש לבנות (§4, INV-COR2).
---
## 3. תנאי-קדם — גרף-זהות נקי
ה-corroboration מצרף ציטוטים להלכות **דרך רשומת ה-`case_law`**. אם אותו תקדים מיוצג בשתי
רשומות (stub `cited_only` + רשומת-תוכן), הציטוטים יושבים על האחת וההלכות על האחרת — וה-join
נשבר. לכן **[INV-G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה)/[INV-ID1](X1-identifiers.md)
הם תנאי-קדם קשיח** ל-X11.
**הפרה ידועה (תוקנה 2026-05-31):** אהוד שפר עע"מ 317/10 הוחזק בשתי רשומות — `external_upload`
עם ציטוט-מלא כ-`case_number` (הפרת INV-ID2) + `cited_only` stub שתפס את 7 הציטוטים-הנכנסים בנפרד
מ-53 ההלכות. מוזג לרשומה קנונית אחת; סריקת-קורפוס מלאה (128 רשומות) אישרה **0** stubs עם
ציטוטים-תקועים שנותרו. ראה [#70 / FU-2c-b](../audit-report.md). הניקוי השוטף של 49 ה-`cited_only`
(הרחבת `_DOCKET_RE`, ציטוטים-משולבים) ממשיך תחת #70.
---
## 4. המנגנון (TARGET)
```
לכל הלכה h של תקדים P:
1. אסוף ציטוטים-נכנסים ל-P (שני הגרפים, §2).
2. סווג טיפול לכל ציטוט (followed / distinguished / criticized / overruled / explained)
מתוך ההקשר (context_text / match_context) — Opus 4.8 @ xhigh. [INV-COR2]
3. התאם כל ציטוט להלכה הספציפית: דמיון סמנטי בין ההקשר לבין rule_statement של h,
מעל רף; הציטוט נספר ל-h רק אם הוא נוגע *לאותה הלכה*, לא לפסק כולו. [INV-COR3]
4. ספֵר corroboration של h = מספר ציטוטים חיוביים בלתי-תלויים שהותאמו אליה.
5. אישור:
אם ≥N חיוביים בלתי-תלויים ∧ 0 שליליים → אישור-אוטומטי (corroborated). [INV-COR4]
אם יש טיפול שלילי (distinguished/criticized/overruled) → אסור אוטו;
דגל ליו"ר, ואף הדחה אם overruled. [INV-COR2]
אחרת (לא-מצוטט) → נשאר בשער-היו"ר הרגיל (סף-confidence). [INV-COR5]
6. העשרה (משני): נסח-מחדש/חדד את rule_statement לפי המסגור של הפאנל המצטט.
```
**N (סף-corroboration)** ייקבע אמפירית (≥2 ברירת-מחדל; ציטוט יחיד אינו מספיק — INV-COR4).
---
## 5. Invariants של התחום
### INV-COR1: corroboration = טיפול שיפוטי אנושי מצטבר, לא שיפוט-AI
**כלל:** אישור-הלכה מבוסס-ציטוט נשען על כך ש**ערכאות/ועדות אנושיות אימצו את ההלכה בפועל** —
לא על ציון-ביטחון של מודל. ה-AI רק **מזהה ומסווג** את הטיפול הקיים; ההכרעה הערכית שההלכה
תקפה ניתנה ע"י השופטים המצטטים. זהו הבסיס לתיקון INV-G10 (§6).
**מקורות (פתוחים):** Fowler, Johnson, Spriggs, Jeon & Wahlbeck, *Network Analysis and the Law:
Measuring the Legal Importance of Precedents at the U.S. Supreme Court* (Political Analysis 15:3,
2007) — סמכות-תקדים נמדדת מהציטוטים-הנכנסים, מאומת בניבוי ציטוט עתידי · *LePaRD: A Large-Scale
Dataset of Judicial Citations to Precedent* (arXiv 2311.09356, 2023) · Hellyer, *Evaluating
Shepard's, KeyCite, and BCite* (Law Library Journal 110:4, 2018, open-access) | סטטוס: verified
**אכיפה:** מנגנון §4 — corroboration נספר רק מטיפול שיפוטי מתועד, לא מ-confidence.
**הפרה ידועה:**
### INV-COR2: סיווג-טיפול חובה לפני ספירה — שלילי לעולם לא מאשר
**כלל:** כל ציטוט-נכנס מסווג ל**טיפול** (followed/explained = חיובי-נייטרלי;
distinguished/criticized/questioned/overruled = שלילי) לפני שהוא נספר. **טיפול שלילי לעולם אינו
תורם ל-corroboration ואינו מאשר אוטומטית**; overruled → הדחת ההלכה לבדיקת-יו"ר.
**מקורות (פתוחים):** Demir & Canbaz, *Validate Your Authority: Benchmarking LLMs on Multi-Label
Precedent Treatment Classification* (NLLP Workshop @ ACL, 2025) — LLM מסווג טיפול-תקדים
(Gemini 2.5 79.1% / GPT-5-mini 67.7%) · Galgani & Hoffmann, *LEXA* — knowledge bases for automatic
legal citation classification · *Towards Automatically Classifying Case Law Citation Treatment
Using Neural Networks* · UNC Law, *Describing Negative Legal Precedent in Citators* | סטטוס: verified
**אכיפה:** שלב 2+5 ב-§4; סכֵמת-טיפול ב-`precedent_internal_citations` (שדה חדש) +
`case_law_citations.citation_type` (לא להישען על ברירת-המחדל `'support'`).
**הפרה ידועה:** סיווג-טיפול לא קיים בפועל (§2) — רכיב לבנייה.
### INV-COR3: התאמה להלכה הספציפית — לא לפסק כולו
**כלל:** ציטוט נספר ל-corroboration של הלכה h **רק אם ההקשר המצטט נוגע לאותה הלכה** (דמיון
סמנטי מעל רף). פסק מצוטט לעניין A אינו מתקף הלכה B שחולצה מאותו פסק.
**מקורות (פתוחים):** Hellyer (2018, open-access) — *"a 'followed' tag might refer to a different
legal point than the one you care about"* · Zheng, Guha, Anderson, Henderson & Ho, *CaseHOLD*
(arXiv 2104.08671, 2021) — סיווג-טיפול ברמת ה-holding הבודד, לא הפסק כולו · UChicago Library /
Northwestern Pritzker — מדריכי-מחקר (treatment ≠ point-specific) | סטטוס: verified
**אכיפה:** שלב 3 ב-§4 — רף-דמיון סמנטי בין ההקשר ל-rule_statement; Opus 4.8 כשופט-התאמה.
**הפרה ידועה:**
### INV-COR4: סף ≥N ציטוטים בלתי-תלויים — ציטוט יחיד אינו מספיק
**כלל:** אישור-אוטומטי דורש **≥N ציטוטים חיוביים בלתי-תלויים** — כלומר מ-**מקורות-מצטטים
מובחנים** (החלטות/פסקים שונים; שני אזכורים באותה החלטה = ציטוט אחד). ברירת-מחדל N=2. מקור יחיד
אינו ראיה מספקת; citators עצמם מפספסים 2325% מהטיפול — לכן נדרשת חזרתיות חוצת-מקורות.
**מקורות (פתוחים):** Demir & Canbaz (NLLP/ACL 2025) — דיוק סיווג-טיפול 67.779.1% בלבד, לכן
סיווג בודד אינו ראיה מספקת ונדרשת חזרתיות · Fowler et al. (Political Analysis 2007) — סמכות =
*צבירת* ציטוטים, לא ציטוט יחיד · Hellyer (2018) — citator coverage gaps (פספוס 2325% מהטיפול)
· Manning, Raghavan & Schütze, *Introduction to Information Retrieval* (CUP 2008) — aggregation of
weak signals | סטטוס: verified
**אכיפה:** שלב 4-5 ב-§4; `HALACHA_CORROBORATION_MIN_CITES` (env-tunable, ברירת-מחדל 2).
**הפרה ידועה:**
### INV-COR5: השער האנושי נשמר לזנב הלא-מצוטט ולשלילי
**כלל:** corroboration **מצמצם** את היקף האישור-הידני; הוא **אינו מבטל** את שער-היו"ר. הלכות
לא-מצוטטות, וכל הלכה עם טיפול שלילי, **נשארות בשער-היו"ר**. גם ה-citators המקצועיים קובעים
ש"human review remains essential".
**מקורות (פתוחים):** Demir & Canbaz (NLLP/ACL 2025) — *"misclassification carries significant
risk"*, ה-citators האוטומטיים *not infallible* → עיון-אנוש נחוץ · Hellyer (2018) — *"There's no
substitute for reading the actual citing case"* · NCSC/JTC, *Principles & Practices for AI Use in
Courts* (human-in-the-loop) · CEPEJ (2018, user-control) | סטטוס: verified
**אכיפה:** שלב 5 ב-§4; שער-היו"ר הקיים ([05-qa-review.md](05-qa-review.md)) נשאר על הזנב.
**הפרה ידועה:**
### INV-COR6: עקיבוּת — כל אישור-אוטומטי שומר את ראיית-הציטוט
**כלל:** הלכה שאושרה ב-corroboration **שומרת את הציטוטים המתקפים** (מזהי-המקור + ההקשר +
הטיפול) כ-provenance הניתן לביקורת — מי אישר, על סמך אילו פסקים, ובאיזה טיפול.
**מקורות:** [INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai) · ISO 15489-1:2016
(records authenticity) · CEPEJ (2018, transparency) | סטטוס: verified (נגזר מ-G9)
**אכיפה:** `halachot.reviewer` = `corroborated (≥N judicial citations)` + טבלת-קישור
הלכה↔ציטוטים-מתקפים; מוצג ביו"ר-UI.
**הפרה ידועה:**
---
## 6. תיקון INV-G10 (מבוקר)
INV-G10 קובע ששער אישור-ההלכה הוא invariant אנושי-חובה. **התיקון** (החלטת-יו"ר 2026-05-31)
אינו מבטל את השער אלא **מרחיב את מקור-הסמכות האנושית שלו**: השער מסופק ע"י **טיפול שיפוטי
מצטבר** (ערכאות/ועדות מצטטות) עבור תת-הקבוצה ה-corroborated החיובית, בעוד **שער-היו"ר נשאר חובה**
לזנב הלא-מצוטט ולכל טיפול-שלילי. הנוסח המתוקן + המקורות נכתבים ב-
[00-constitution.md INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant).
עיקרון-העל (INV-COR1) שומר על רוח G10: זהו שיפוט אנושי (של המצטטים), לא שיפוט-AI.
---
## 7. מצב קיים מול יעד — audit-findings
- **קישור הלכה↔ציטוט לא קיים.** אין טבלה/שאילתה שמצרפת ציטוט-נכנס להלכה ספציפית — רכיב-ליבה
לבנייה (§4 שלב 3).
- **סיווג-טיפול חסר.** `precedent_internal_citations` ללא שדה-טיפול; `case_law_citations.citation_type`
על ברירת-מחדל `'support'` (`db.py:387`) — לא מסווג בפועל (§2, INV-COR2).
- **אישור-אוטומטי כיום מבוסס-confidence בלבד.** `db.store_halachot` מאשר ב-`confidence ≥
HALACHA_AUTO_APPROVE_THRESHOLD` (`db.py:3221`, ברירת-מחדל 0.80) — לא מבוסס-ציטוט. X11 מוסיף
מסלול-אישור שני (corroboration) לצד/מעל סף-ה-confidence.
- **גרף-זהות.** תוקן לשפר + dedup content-affecting (§3); המשך ניקוי ב-#70.
---
## 8. הפניות-אחיות
- [00-constitution.md](00-constitution.md) — INV-G9 (provenance), INV-G10 (שער אנושי, מתוקן §6),
פרוטוקול ≥3-מקורות.
- [02-data-model.md](02-data-model.md) — טבלות הציטוטים (`case_law_citations`,
`precedent_internal_citations`) + ישות `halachot`.
- [05-qa-review.md](05-qa-review.md) — שער אישור-ההלכה הקיים (נשאר על הזנב, INV-COR5).
- [07-learning.md](07-learning.md) — צמיחת-קורפוס + לולאת-הלכות.
- [X1-identifiers.md](X1-identifiers.md) — תנאי-הקדם: זהות קנונית (INV-ID1/ID2).
- [#70 / FU-2c-b](../audit-report.md) — dedup של `cited_only` (תנאי-קדם, §3).

View File

@@ -80,6 +80,9 @@ PUT /api/cases/{n} → [BackgroundTask] emit_case_status_webhook()
([paperclip_api.py:120-165](../../web/paperclip_api.py)) ו-`emit_export_complete_webhook` ([paperclip_api.py:120-165](../../web/paperclip_api.py)) ו-`emit_export_complete_webhook`
([paperclip_api.py:168+](../../web/paperclip_api.py)). ([paperclip_api.py:168+](../../web/paperclip_api.py)).
> **חוזה ה-webhook (idempotency / at-least-once / אירוע מגורס)** מפורט ב-[X7 INV-INT7/INT8](X7-paperclip-client-params.md):
> ה-emitter הנוכחי fire-and-forget בולע שגיאות וללא event-id/dedup — יעד FU-9.
### 1ד. כל קריאת-API דרך helper — לא curl/httpx ישיר ### 1ד. כל קריאת-API דרך helper — לא curl/httpx ישיר
קריאות ל-Paperclip עוברות תמיד דרך helper, לא דרך לקוח גולמי: קריאות ל-Paperclip עוברות תמיד דרך helper, לא דרך לקוח גולמי:
@@ -97,6 +100,9 @@ PUT /api/cases/{n} → [BackgroundTask] emit_case_status_webhook()
## 2. מודל ה-Deploy — שני מודלים דו-קיימים ## 2. מודל ה-Deploy — שני מודלים דו-קיימים
> **קונפיגורציה, env וסודות** — ה-deep-dive המלא (catalog ה-env, מקור-config, secrets, hardcode,
> drift) ב-[X10-deploy-env-secrets.md](X10-deploy-env-secrets.md). כאן נשאר רק מודל-ההרצה.
על שרת Nautilus דרים **שני מודלי-הרצה**. ערבוב ביניהם הוא הטעות הנפוצה ביותר על שרת Nautilus דרים **שני מודלי-הרצה**. ערבוב ביניהם הוא הטעות הנפוצה ביותר
([root CLAUDE.md](../../../CLAUDE.md) "Deploy architecture"; [legal-ai/CLAUDE.md](../../CLAUDE.md) ([root CLAUDE.md](../../../CLAUDE.md) "Deploy architecture"; [legal-ai/CLAUDE.md](../../CLAUDE.md)
"ארכיטקטורת Deploy"). "ארכיטקטורת Deploy").
@@ -210,3 +216,5 @@ audit-trail עקבי).
- [.claude/agents/HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md) — §0 (pc.sh), §4ג§4ד (wake CEO + payload). - [.claude/agents/HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md) — §0 (pc.sh), §4ג§4ד (wake CEO + payload).
- [web/paperclip_api.py](../../web/paperclip_api.py) — `pc_request`, `emit_case_status_webhook`. - [web/paperclip_api.py](../../web/paperclip_api.py) — `pc_request`, `emit_case_status_webhook`.
- [scripts/pc.sh](../../scripts/pc.sh) — helper ה-bash. - [scripts/pc.sh](../../scripts/pc.sh) — helper ה-bash.
- [X7-paperclip-client-params.md](X7-paperclip-client-params.md) — שכבת-הלקוח + פרמטרי-החיבור (INV-INT4INT8).
- [X10-deploy-env-secrets.md](X10-deploy-env-secrets.md) — env/secrets/deploy deep-dive (INV-ENV1ENV5).

View File

@@ -60,6 +60,25 @@ Paperclip בקונפליקט (project-specific מנצח default), אך אינו
- **company_id פר-סוכן.** כל שורה בטבלה מיוצגת פעמיים (CMP + CMPA); ה-CEO לכל חברה שונה - **company_id פר-סוכן.** כל שורה בטבלה מיוצגת פעמיים (CMP + CMPA); ה-CEO לכל חברה שונה
([X2 §1](X2-multi-company.md)). הסוכן פועל רק בטווח-החברה שלו ([X2 §2](X2-multi-company.md)). ([X2 §1](X2-multi-company.md)). הסוכן פועל רק בטווח-החברה שלו ([X2 §2](X2-multi-company.md)).
### 2א. מפת-הרשאות (tool grants) — frontmatter מול הוראות
כל קובץ-סוכן מצהיר ב-frontmatter `tools:` (כולם: `Read/Bash/Grep/Glob` + תת-קבוצת `mcp__legal-ai__*`).
מפת-ההרשאות חייבת **לתאום** את מה שהוראות-הסוכן מצריכות ([X9 INV-TOOL6](X9-mcp-tool-contract.md), INV-AG3 להלן).
**סטטוס FU-13 — נסגר (2026-06-06):** GAP-46 טופל בהכרעת-יו"ר "היבריד". התברר שהפער שמופה ב-31.5
היה רחב מדי — הכלים יוחסו לפי *תיאור-התפקיד*, לא לפי ההוראות בפועל. ההכרעה:
| סוכן | מצב בפועל | פעולה ב-FU-13 |
|------|-----------|----------------|
| legal-researcher | כבר מעניק `extract_references` + `precedent_extract_halachot`/`precedent_extract_metadata`/`precedent_process_pending` (frontmatter) | ✅ אין פער — היה מיושן |
| legal-analyst | חסר `aggregate_claims_to_arguments`; הוראותיו לא השתמשו בו | ✅ נוסף ל-frontmatter + שלב 7 ב-"שלב 1" (קיבוץ טענות→טיעונים) |
`extract_references` / `extract_internal_citations` הם **מטלת-מחקר** (חילוץ ציטוטים/רפרנסים) ושייכים
ל-`legal-researcher` (שמחזיק אותם) — **לא** ל-`legal-analyst`, שמאמת פסיקה דרך *חיפוש* (§8א בקובץ-הסוכן),
לא חילוץ. לכן הוסרו מרשימת "החסרים" של ה-analyst (INV-AG3 "לא עודף").
→ [gap-audit GAP-46](gap-audit.md).
--- ---
## 3. סוכני-התהליך (תת-פרויקט 5) — סעיף שמור (RESERVED) ## 3. סוכני-התהליך (תת-פרויקט 5) — סעיף שמור (RESERVED)
@@ -95,8 +114,10 @@ Paperclip בקונפליקט (project-specific מנצח default), אך אינו
קבצי-הסוכן תחת [.claude/agents/](../../.claude/agents/) (frontmatter + instructions) + קבצי-הסוכן תחת [.claude/agents/](../../.claude/agents/) (frontmatter + instructions) +
[00-constitution.md §7](00-constitution.md#7-אינדקס-הספ) (אינדקס הספ — איזה קובץ אוכף איזה invariant). [00-constitution.md §7](00-constitution.md#7-אינדקס-הספ) (אינדקס הספ — איזה קובץ אוכף איזה invariant).
(invariant פרויקטלי-תפעולי — ללא פרוטוקול ≥3-המקורות; משרת את העיקרון הגלובלי G10.) (invariant פרויקטלי-תפעולי — ללא פרוטוקול ≥3-המקורות; משרת את העיקרון הגלובלי G10.)
**אכיפה:** נוהל — ה-checklist ב-HEARTBEAT + הפניות-הספ בקבצי-הסוכן. **אין אכיפה אוטומטית** **אכיפה:** נוהל — **מחוּוט** (FU-8b, 2026-05-31): סעיף "קריאת-ספ — קודם החוקה (00), אז ספ-התחום"
שתכריח קריאת-ספ לפני פעולה (ראה §5 — זה היעד). ב-[HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md) (כולל טבלת תפקיד→ספ) + סעיף "קרא לפני פעולה (INV-AG1)"
בכל אחד מ-8 קבצי-הסוכן. אכיפה **פרוצדורלית** (נוהל לפני עבודה), לא אוטומטית: אין שער-קוד שמכריח
את הקריאה — זה גלום בטבע ה-invariant (פרויקטלי-תפעולי, מבוצע ע"י הסוכן). ראה §5.
**הפרה ידועה:** **הפרה ידועה:**
### INV-AG2: סוכן דומייני פועל רק בתחום-החברה שלו ### INV-AG2: סוכן דומייני פועל רק בתחום-החברה שלו
@@ -111,18 +132,29 @@ CMPA→8xxx/9xxx). אסור ליצור פרויקט/issue/תוכן לתיק מח
another company`, [X2 §2](X2-multi-company.md)). another company`, [X2 §2](X2-multi-company.md)).
**הפרה ידועה:** **הפרה ידועה:**
### INV-AG3: מפת-ההרשאות תואמת את הוראות-הסוכן — לא חסר ולא עודף
**כלל:** ה-frontmatter `tools:` של כל סוכן מעניק **בדיוק** את הכלים שהוראותיו דורשות — כל כלי שההוראות
מצריכות מוענק, וכלי שמוענק-ולא-בשימוש נבחן. מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
(שערים מוגדרים) ו-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים); מקביל ל-[X9 INV-TOOL6](X9-mcp-tool-contract.md).
**מקור-סמכות:** frontmatter `tools:` מול ה-instructions בקבצי-[.claude/agents/](../../.claude/agents/). (פרויקטלי-תפעולי.)
**אכיפה:** בדיקת-עקביות tools↔instructions (FU-13 ✅ 2026-06-06). אכיפה אוטומטית עתידית — בתת-פרויקט 5 (spec-guardian).
**הפרה ידועה:** — (טופל ב-FU-13: legal-analyst קיבל `aggregate_claims_to_arguments`; researcher כבר היה תקין; `extract_references`/`extract_internal_citations` הם מטלת-researcher, לא analyst — ראה §2א).
--- ---
## 5. מצב קיים מול יעד — חיווט הספ לסוכנים ## 5. חיווט הספ לסוכנים — בוצע (FU-8b)
ספ-המערכת (קבצי 0007, X1X5) הוא **חדש** — קבצי-הסוכן וה-HEARTBEAT עדיין **אינם מפנים אליו** עד FU-8b קבצי-הסוכן וה-HEARTBEAT **לא הפנו** לספ-המערכת במפורש; הם הפנו ל-CLAUDE.md, למסמכי-`docs/`
במפורש; הם מפנים ל-CLAUDE.md, למסמכי-`docs/` הישנים, ול-skills. זהו פער אמיתי: הישנים, ול-skills. **בוצע ב-2026-05-31 (FU-8b / GAP-23):**
- **קיים:** HEARTBEAT אוכף checklist הפעלה (סינון-חברה, comments, pc.sh) אך **לא** מחייב קריאת - **HEARTBEAT.md:** נוסף סעיף עליון "קריאת-ספ — קודם החוקה (00), אז ספ-התחום — לפני פעולה מהותית
`00-constitution.md` או ספ-התחום. (INV-AG1)", **לפני** §0§8 התפעוליים, ובו טבלת תפקיד→ספ (זהה לסעיף 2 כאן). זה ממקם את קריאת-החוקה
- **יעד:** לחווט את HEARTBEAT וקבצי-הסוכן כך שיחייבו במפורש את INV-AG1 — קריאת החוקה + ספ-התחום קודם ל-checklist ההפעלה ("קודם החוקה (00) + ספ-התחום, אז ה-HEARTBEAT התפעולי").
הרלוונטי (לפי הטבלה בסעיף 2) לפני עבודה מהותית. זהו תנאי-מוקדם לסוכני-התהליך (סעיף 3), שכל - **8 קבצי-הסוכן:** כל אחד קיבל סעיף "קרא לפני פעולה (INV-AG1)" בראש גוף-הקובץ — קריאת
עבודתם היא "לקרוא את הספ ולעשות שיעורי-בית". `00-constitution.md` תחילה, ואז ספ-התחום הרלוונטי לתפקידו (לפי הטבלה בסעיף 2).
- **אופי האכיפה:** פרוצדורלית (נוהל), לא שער-קוד — ראה INV-AG1 "אכיפה".
זהו תנאי-מוקדם לסוכני-התהליך (סעיף 3), שכל עבודתם היא "לקרוא את הספ ולעשות שיעורי-בית".
--- ---
@@ -138,3 +170,5 @@ another company`, [X2 §2](X2-multi-company.md)).
[05-qa-review.md](05-qa-review.md), [06-export.md](06-export.md), [07-learning.md](07-learning.md). [05-qa-review.md](05-qa-review.md), [06-export.md](06-export.md), [07-learning.md](07-learning.md).
- [.claude/agents/HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md) + קבצי-הסוכן תחת - [.claude/agents/HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md) + קבצי-הסוכן תחת
[.claude/agents/](../../.claude/agents/) — frontmatter (תפקיד) + instructions (סינון-חברה, זרימה). [.claude/agents/](../../.claude/agents/) — frontmatter (תפקיד) + instructions (סינון-חברה, זרימה).
- [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) — חוזה-הכלים שההרשאות (INV-AG3 / §2א) מעניקות.
- [skills/](../../skills/) — 5 skills (decision, assistant, docx, dafna-decision-template, new-company-setup); עקביות-skills↔סוכן + dedup → FU-13.

View File

@@ -0,0 +1,108 @@
# X6 — חוזה UI↔API וכללי-עיצוב הממשק (UI↔API Contract & Design Rules)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-deep-dive על **הממשק (web-ui) וחוזה
ה-API בינו לבקאנד** — שלא היה מכוסה בספ עד כה. הוא מגדיר: (א) חוזה-הקשר פרונט↔בק (OpenAPI כ-SSoT,
מודלי-תשובה, envelope, SSE, טיפול-שגיאות); (ב) **כללי-עיצוב הממשק** — מקור-אמת יחיד ל-enums/תוויות,
helpers משותפים, וחוזה-טופס לכל סוג-מסמך. הממצאים בפועל מתועדים ב-[ui-audit.md](ui-audit.md).
> **שני סוגי invariant כאן.** UI1UI5 הם **הנדסיים** (חוזה-API/קליינט כללי — ≥3 מקורות + סטטוס).
> UI6 (חוזה-טופס) הוא **פרויקטלי-תפעולי**, נגזר מ-[X8](X8-field-provenance.md), ומשרת
> [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai).
---
## 1. ארכיטקטורה קיימת
- **web-ui** — Next.js 16 + TS + Tailwind v4 + shadcn + TanStack Query. 13 דפים (ראה [ui-audit.md](ui-audit.md)).
- **Proxy** — [next.config.ts](../../web-ui/next.config.ts): `/api/*``NEXT_PUBLIC_API_ORIGIN` (ברירת-מחדל `http://127.0.0.1:8000`); `/openapi.json` → schema של ה-FastAPI.
- **לקוח** — [client.ts](../../web-ui/src/lib/api/client.ts): `apiRequest<T>` + `ApiError` + `makeQueryClient`. 18 מודולי-API.
- **טיפוסים** — [types.ts](../../web-ui/src/lib/api/types.ts) (auto-gen `openapi-typescript`, 124 operations). `npm run api:types`.
- **SSE** — [sse.ts](../../web-ui/src/lib/sse.ts): `openSSE` (progress של העלאות/עיבוד).
- **בקאנד** — [web/app.py](../../web/app.py): 143 endpoints, מונוליטי, **~60% ללא Pydantic response model**.
---
## 2. Invariants של התחום
### INV-UI1: ה-OpenAPI schema הוא ה-SSoT לחוזה — טיפוסי-לקוח נגזרים, לא ידניים-סוטים
**כלל:** חוזה ה-API מוגדר **פעם אחת** ב-OpenAPI (שמופק מהבקאנד); טיפוסי-ה-frontend **נגזרים** ממנו
(`openapi-typescript`), ואינם מתוחזקים ידנית במקביל. אין "טיפוס-מראה" מקומי שמשכפל endpoint וסוטה ממנו.
מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) (מקור-אמת יחיד).
**מקורות:** OpenAPI Specification 3.1 (single contract / source of truth; JSON-Schema 2020-12)
(https://spec.openapis.org/oas/latest.html) · Pact — *consumer-driven contract testing*
(https://docs.pact.io/) · Speakeasy — *Pact vs OpenAPI* (provider-driven SSoT)
(https://www.speakeasy.com/blog/pact-vs-openapi) | סטטוס: verified
**אכיפה:** `npm run api:types` ב-CI; איסור טיפוסי-מראה ידניים. **כיום אין** — ה-frontend מתחזק טיפוסים ידניים.
**הפרה ידועה:** [cases.ts:1-9](../../web-ui/src/lib/api/cases.ts) מתעד מפורשות שה-`/api/cases` מחזיר `unknown`
ולכן מוחזק טיפוס `CaseDetail` ידני; `PracticeArea` מוגדר ב-3 מקומות עם ערכים שונים ([ui-audit.md](ui-audit.md), [gap-audit GAP-30/31](gap-audit.md)).
### INV-UI2: לכל endpoint נצרך — response model מפורש (חוזה-שלמות API)
**כלל:** כל endpoint שה-UI צורך נושא **response model מפורש** (Pydantic), כך ש-OpenAPI מפיק טיפוס אמיתי
(לא `unknown`/`object`). זהו פאֶט של [G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש) (שלמות-חוזה לפני צריכה).
**מקורות:** OpenAPI 3.1 (schema objects) · Zalando *RESTful API Guidelines* (explicit schemas)
(https://opensource.zalando.com/restful-api-guidelines/) · FastAPI *Response Model* docs
(https://fastapi.tiangolo.com/tutorial/response-model/) | סטטוס: verified
**אכיפה:** linter/CI שמסמן endpoint נצרך ללא response_model. **כיום אין** — ~60% מהendpoints ללא מודל.
**הפרה ידועה:** רוב ה-endpoints ב-[app.py](../../web/app.py) מחזירים dict חופשי → `unknown` ב-types.ts ([gap-audit GAP-30](gap-audit.md)).
### INV-UI3: envelope-תשובה ושגיאה עקבי על-פני ה-API
**כלל:** כל ה-endpoints חולקים **מבנה-תשובה ומבנה-שגיאה אחיד** (לא string-לפעמים-JSON-לפעמים). שגיאות
לפי תבנית סטנדרטית (Problem Details). מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
**מקורות:** RFC 9457 — *Problem Details for HTTP APIs*
(https://www.rfc-editor.org/rfc/rfc9457) · Zalando *RESTful API Guidelines* (consistent responses) ·
Microsoft *REST API Guidelines* (error structure)
(https://github.com/microsoft/api-guidelines) | סטטוס: verified
**אכיפה:** envelope משותף ב-app.py + handler-שגיאות גלובלי. **כיום אין** — מעורב string/JSON/`{error}`/`{detail}`.
**הפרה ידועה:** [search.py](../../web/app.py) מחזיר `"לא נמצאו תוצאות."` או JSON; חלק מהכלים `{error:...}`, חלק raise ([gap-audit GAP-32](gap-audit.md), [X9 INV-TOOL1](X9-mcp-tool-contract.md)).
### INV-UI4: אין בליעת-שגיאה ב-UI
**כלל:** כל מצב-שגיאה (fetch/mutation) **מוצג או מטופל מפורשות** — error boundary ו/או טיפול ב-`error`
של `useQuery`/`useMutation`. אין כשל שקט שמשאיר את המשתמש בלי משוב. תואם כלל "אין בליעה שקטה"
([חוקה §6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
**מקורות:** React docs — *Error Boundaries*
(https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary) ·
TanStack Query — *Error handling* (https://tanstack.com/query/latest/docs/framework/react/guides/query-functions#handling-and-throwing-errors) ·
Nielsen Norman Group — *Error-Message Guidelines* (https://www.nngroup.com/articles/error-message-guidelines/) | סטטוס: verified
**אכיפה:** error boundary ברמת-האפליקציה + רכיב-שגיאה משותף; code-review. **כיום חלקי** — חלק מהדפים אינם
מטפלים ב-`error`; כרטיסי-שגיאה משוכפלים ולא-עקביים.
**הפרה ידועה:** [ui-audit.md](ui-audit.md) — כרטיס-שגיאה משוכפל ×3, fallback של SSE שמסתיר כישלון כ-"completed" ([gap-audit GAP-32/33](gap-audit.md)).
### INV-UI5: חוזה-SSE/progress עם terminal states מוגדרים
**כלל:** ערוץ ה-progress (SSE) נושא **terminal states מפורשים** (completed/failed/timeout). אין הנחת-השלמה
שקטה על timeout; אי-התאמות-TTL (frontend↔backend) נמנעות. נקשר ל-freshness ([G6](00-constitution.md#inv-g6-re-index-בכל-שינוי-תוכן)).
**מקורות:** WHATWG HTML — *Server-Sent Events / EventSource* (https://html.spec.whatwg.org/multipage/server-sent-events.html) ·
MDN — *Using server-sent events* (https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) ·
TanStack Query — *Important Defaults* (staleTime/refetch) (https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults) | סטטוס: verified
**אכיפה:** סכמת-אירוע SSE עם terminal state מפורש; יישור TTL. **כיום:** fallback של 10ש' מניח completed.
**הפרה ידועה:** [documents.ts:226-232](../../web-ui/src/lib/api/documents.ts) — timeout→`{status:"completed"}`; TTL 5ש' front מול 300ש' redis ([gap-audit GAP-33](gap-audit.md)).
### INV-UI6: חוזה-טופס מוצהר לכל סוג-מסמך + שיקוף מקור-המילוי
**כלל:** לכל סוג-מסמך (מסמך-תיק / פסיקה חיצונית / החלטה פנימית) יש **חוזה-טופס מוצהר** — אילו שדות,
חובה/רשות/אוטו/pending/editable — **נגזר מ-[X8](X8-field-provenance.md)**; וה-UI **משקף את מקור-המילוי**
(מסמן מה חולץ אוטומטית/ע"י-Opus מול מה שהיו"ר הזין), כדי שהיו"ר ידע מה לאמת. מופע של
[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai) (שקיפות-מקור). **invariant פרויקטלי-תפעולי.**
**מקור-סמכות:** [X8-field-provenance.md](X8-field-provenance.md) (טבלת-ה-provenance); feedback היו"ר.
**אכיפה:** רכיב-טופס נגזר-X8 + אינדיקציית "מולא-ע"י-Opus"/"ממתין"/`searchable`. **כיום אין** — שדות-Opus
מוצגים כשדות-עריכה רגילים ללא סימון.
**הפרה ידועה:** [precedents/[id]/page.tsx](../../web-ui/src/app/precedents/%5Bid%5D/page.tsx) — `summary`/`headnote`/`key_quote` ללא חיווי-מקור; אין חיווי `searchable` ([gap-audit GAP-36](gap-audit.md)).
---
## 3. כללי-עיצוב (Design Rules) — נגזרים מה-invariants
- **SSoT ל-enums/תוויות/tones:** כל enum (CaseStatus, PracticeArea, AppealSubtype, DocType, outcome) +
תוויותיו + צבעיו מוגדרים **פעם אחת** ונצרכים מיבוא — לא משוכפלים בין דפים/רכיבים (מופע UI1/G2).
- **helpers משותפים:** פירמוט-תאריך, builder ל-FormData (העלאות), רכיב-שגיאה, query-config (intervals) —
משותפים, לא מועתקים.
- **חוזי-טופס:** ראה INV-UI6 ([X8](X8-field-provenance.md)).
הממצאים הקונקרטיים (כפילויות, הגדרות-שגויות, redundancy) ב-[ui-audit.md](ui-audit.md); התיקון — **FU-10**.
---
## 4. הפניות-אחיות
- [ui-audit.md](ui-audit.md) — audit דף-אחר-דף (13 דפים) בתבנית-ה-gap.
- [X8-field-provenance.md](X8-field-provenance.md) — מקור-מילוי-שדות (בסיס ל-INV-UI6).
- [X7-paperclip-client-params.md](X7-paperclip-client-params.md) — חוזה-ה-API שהפלאגין צורך.
- [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) — חוזה-envelope מקביל בכלי-ה-MCP.
- [00-constitution.md](00-constitution.md) — [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים), [G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש), [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai), כלל "אין בליעה שקטה" ([§6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
- [web-ui/next.config.ts](../../web-ui/next.config.ts), [client.ts](../../web-ui/src/lib/api/client.ts), [types.ts](../../web-ui/src/lib/api/types.ts), [sse.ts](../../web-ui/src/lib/sse.ts).

View File

@@ -0,0 +1,155 @@
# X7 — לקוח-Paperclip ופרמטרי-חיבור (Paperclip Client & Connection Parameters)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) ומשלים את [X3](X3-integration-deploy.md):
בעוד X3 מתאר את **זרימות**-האינטגרציה (wakeup, ניתוב comments, webhook), קובץ זה הוא ה-deep-dive
על **שכבת-הלקוח והפרמטרים***איך* legal-ai מדבר עם Paperclip בקוד (אילו לקוחות, אילו מסלולים),
ועל **כל הפרמטרים המחברים** (מזהי-חברה/סוכן, env, מפתחות, `plugin_state`, גזירת `company_id`).
> **invariant פרויקטלי-תפעולי.** ה-invariants כאן הם עובדות על איך *מערכת זו* בנויה — אין להן
> סמכות חיצונית; מקור-הסמכות = ה-runbooks והקוד ([root CLAUDE.md](../../../CLAUDE.md),
> [legal-ai/CLAUDE.md](../../CLAUDE.md), [web/paperclip_api.py](../../web/paperclip_api.py),
> [web/paperclip_client.py](../../web/paperclip_client.py)). כל invariant **נקשר** ל-G גלובלי שהוא משרת —
> כאן בעיקר [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) (מסלול קנוני יחיד)
> ו-[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai) (עקיבוּת/audit), וכלל-ההנדסה "סימטריה" ([חוקה §6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
---
## 1. מצב קיים — שני לקוחות מקבילים
ל-legal-ai יש **שני לקוחות Paperclip שונים** שחיים בו-זמנית, וזהו מקור-השורש לרוב הפערים כאן:
| לקוח | קובץ | אופי | מה מנהל |
|------|------|------|---------|
| "current" (API) | [web/paperclip_api.py](../../web/paperclip_api.py) | HTTP דרך `pc_request` + board API key | webhooks יוצאים, wakeup חלקי |
| "legacy" (DB-ישיר) | [web/paperclip_client.py](../../web/paperclip_client.py) | **חיבור psql ישיר** ל-DB של Paperclip + API | projects, issues, comments, wakeup, queries |
[legal-ai/CLAUDE.md](../../CLAUDE.md) מתעד ש-`paperclip_client.py` הוא "legacy — השתמש ב-paperclip_api.py",
אך בפועל ה-legacy עדיין מבצע את **רוב העבודה הכבדה** (יצירת תיקים/issues, comments, wakeup-ים),
וחלקו דרך **`INSERT`/`SELECT` ישיר** ל-DB של Paperclip — מסלול-מקביל לעוקף את ה-API.
זוהי בדיוק התבנית ש-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) אוסר:
שני מסלולי-קוד מקבילים ליכולת אחת (גישה ל-Paperclip), שמתפצלים ועלולים לסטות.
---
## 2. הפרמטרים המחברים (Connection Parameters)
### 2א. משתני-סביבה
| Var | קורא | ברירת-מחדל | סוד? |
|-----|------|-----------|------|
| `PAPERCLIP_API_URL` | [paperclip_api.py](../../web/paperclip_api.py) | `http://localhost:3100` | לא |
| `PAPERCLIP_BOARD_API_KEY` | paperclip_api.py / paperclip_client.py | `""` | **כן** (board key long-lived, לא JWT) |
| `PAPERCLIP_DB_URL` | [paperclip_client.py:21](../../web/paperclip_client.py), [app.py:3789](../../web/app.py) | `postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip` | **כן — creds בתוך ברירת-המחדל** |
| `PAPERCLIP_COMPANY_ID` | [app.py:3976](../../web/app.py) | `42a7acd0-...` (CMP, hardcoded) | לא |
| `legalApiBaseUrl` | plugin (instance config) | `http://localhost:8085` | לא |
> ראה גם [X10-deploy-env-secrets.md](X10-deploy-env-secrets.md) — חוזה-ה-env המלא וטיפול-הסודות.
### 2ב. מזהים קשיחים בקוד (hardcoded) — סתירה ל-X3
[paperclip_client.py:36-62](../../web/paperclip_client.py) מכיל **מזהי-חברה וסוכן קשיחים**:
- `COMPANIES["licensing"] = "42a7acd0-..."` (CMP), `COMPANIES["betterment"] = "8639e837-..."` (CMPA)
- CEO/curator/analyst UUIDs לכל חברה (CMP CEO `752cebdd-...`, וכו').
- ה-plugin ([worker.ts](../../../plugin-legal-ai/src/worker.ts)) מכיל CEO IDs קשיחים משלו.
זו **סתירה ישירה** ל-[X3 §1א](X3-integration-deploy.md) הקובע "מזהה-ה-CEO נגזר מ-`$PAPERCLIP_COMPANY_ID`,
**לעולם לא UUID hardcoded**". הסתירה מתועדת כממצא ([gap-audit GAP-26](gap-audit.md), וכן GAP-56 ב-X10).
### 2ג. `plugin_state` keys (חוזה הקישור Paperclip↔legal-ai)
| `scope_kind` | `state_key` | ערך | משמעות |
|--------------|-------------|-----|--------|
| `issue` | `legal-case-number` | מספר-תיק | קישור issue→תיק |
| `issue` | `precedent-case-law-id` | case_law_id | קישור issue→פסיקה לחילוץ |
| `instance` | `webhook-idem-{requestId}` | timestamp | guard idempotency 5 דק' (inbound) |
### 2ד. גזירת `company_id` — שתי דרכים שונות
- **app.py**: נגזר מ-prefix מספר-התיק (`1`→licensing, `8/9`→betterment) ([X3 §1ג](X3-integration-deploy.md)).
- **paperclip_client.py**: מ-`_FALLBACK_APPEAL_TYPE_TO_COMPANY` (מיפוי tag→company) + lookup ב-DB.
שתי דרכי-גזירה לאותו ערך = drift פוטנציאלי ([gap-audit GAP-27](gap-audit.md)).
---
## 3. צד נכנס (Inbound) — הפלאגין
[plugin-legal-ai/src/worker.ts](../../../plugin-legal-ai/src/worker.ts) (לא בריפו זה) קורא ל-legal-ai דרך
`legalApiBaseUrl`. שלושה סוגי-משטח, שכולם חוזה-API שאינו מתועד היום ב-[X6](X6-ui-api-contract.md):
- **16 כלי `legal_*`** — עוטפים endpoints של `/api/cases/...`, `/api/search`, וכו'.
- **`onWebhook`** — מקבל את ה-webhook היוצא (ראה [X3 §1ג](X3-integration-deploy.md) ו-INV-INT8 להלן).
- **3 cron jobs** — `sync-case-status` (כל 15 דק'), `stale-case-reminder` (יומי), `weekly-feedback-analysis` (שבועי).
---
## 4. Invariants של התחום
### INV-INT4: לקוח-Paperclip קנוני יחיד — אין לקוח-מקביל ואין גישת-DB ישירה
**כלל:** כל גישה ל-Paperclip עוברת דרך **לקוח-API קנוני יחיד** (`pc_request`/`pc.sh`). **אסור** מסלול-מקביל —
לא לקוח שני, ולא `INSERT`/`SELECT`/`UPDATE` ישיר ל-DB של Paperclip. נתונים נקראים/נכתבים דרך ה-API
הרשמי בלבד; ה-DB של Paperclip הוא מקור-האמת של Paperclip, ו-legal-ai אינו מסלול-כתיבה מקביל אליו.
מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) וכלל "סימטריה" ([חוקה §6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
**מקור-סמכות:** [legal-ai/CLAUDE.md](../../CLAUDE.md) ("paperclip_client.py legacy — השתמש ב-paperclip_api.py";
"קריאות API — תמיד דרך helper"); [X3 INV-INT3](X3-integration-deploy.md). (פרויקטלי-תפעולי — משרת G2.)
**אכיפה:** איחוד שני הלקוחות ללקוח-API אחד; הסרת `PAPERCLIP_DB_URL` כמסלול-כתיבה. **כיום אין אכיפה**
שני הלקוחות דו-קיימים (יעד FU-9).
**הפרה ידועה:** [paperclip_client.py](../../web/paperclip_client.py) — `create_project`/`post_comment`-fallback
עושים `INSERT` ישיר ל-`projects`/`issues`/`comments`/`plugin_state` ([gap-audit GAP-24, GAP-25](gap-audit.md)).
### INV-INT5: מזהי-חברה/סוכן מ-config — לא hardcoded בקוד
**כלל:** מזהי-החברה (CMP/CMPA) ומזהי-הסוכנים (CEO/curator/analyst) **נגזרים מ-config** (env/טבלת-מיפוי),
**לא** קבועים בקוד. הוספת חברה/החלפת instance אינה דורשת שינוי-קוד. מופע של
[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) (SSoT למיפוי) — מקור-אמת יחיד למיפוי.
**מקור-סמכות:** [X3 §1א](X3-integration-deploy.md) ("לעולם לא UUID hardcoded"); [X2-multi-company.md](X2-multi-company.md).
(פרויקטלי-תפעולי — משרת G2.)
**אכיפה:** טבלת-מיפוי/env יחידה; code-review. **כיום אין אכיפה** — UUIDs קשיחים.
**הפרה ידועה:** [paperclip_client.py:36-62](../../web/paperclip_client.py) + [app.py:3976](../../web/app.py) +
[plugin worker.ts](../../../plugin-legal-ai/src/worker.ts) — IDs קשיחים. **סותר את X3 §1א** ([gap-audit GAP-26](gap-audit.md)).
### INV-INT6: גזירת `company_id` קנונית יחידה
**כלל:** ל-`company_id` יש **מסלול-גזירה אחד** מתוך מספר-התיק/סוג-הערר, במקום יחיד. אסור שתי לוגיקות-גזירה
מקבילות (prefix מול fallback-map) שעלולות לסטות. מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
**מקור-סמכות:** [X3 §1ג](X3-integration-deploy.md); [X2-multi-company.md](X2-multi-company.md). (פרויקטלי-תפעולי.)
**אכיפה:** פונקציית-גזירה יחידה משותפת ל-app.py ול-client.py (יעד FU-9). **כיום אין.**
**הפרה ידועה:** prefix ב-[app.py](../../web/app.py) מול `_FALLBACK_APPEAL_TYPE_TO_COMPANY` ב-[paperclip_client.py](../../web/paperclip_client.py) ([gap-audit GAP-27](gap-audit.md)).
### INV-INT7: webhook יוצא — at-least-once + idempotency + ללא בליעה שקטה
**כלל:** ה-webhook היוצא (legal-ai→plugin) מספק **at-least-once** עם **מפתח-idempotency יציב** (event id),
כך שמסירה-כפולה בטוחה בצד-המקבל; וכישלון-מסירה **נרשם ומדווח** (telemetry/health), לא נבלע בשקט.
זהו invariant **הנדסי** (סמנטיקת-מסירה כללית), הקשור ל-[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)
(עקיבוּת) ולכלל "אין בליעה שקטה" ([חוקה §6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
**מקורות:** Stripe — *Webhooks / at-least-once delivery & idempotency*
(https://docs.stripe.com/webhooks) · Hookdeck — *At-Least-Once vs Exactly-Once Webhook Delivery*
(https://hookdeck.com/webhooks/guides/webhook-delivery-guarantees) · Martin Kleppmann, *DDIA*
(O'Reilly 2017, idempotence & exactly-once semantics) | סטטוס: verified
**אכיפה:** event-id יציב + UNIQUE-dedup בצד-המקבל; ה-emitter רושם כישלון ל-telemetry (יעד). **כיום:**
inbound יש guard 5 דק' ([X3 §1ג](X3-integration-deploy.md)); **outbound אין idempotency**, וה-emitter בולע
שגיאות ב-`logger.warning` בלבד.
**הפרה ידועה:** `emit_*_webhook` ב-[paperclip_api.py](../../web/paperclip_api.py) — fire-and-forget, `try/except`
שמתעד warning ולעולם לא raise, ללא event-id/dedup ([gap-audit GAP-28](gap-audit.md)).
### INV-INT8: חוזה-אירועי-webhook מתוקען ומגורס
**כלל:** ל-webhook חוזה-אירוע **מפורש ומגורס**`eventType` מתוך קבוצה סגורה, סכמת-payload מתועדת לכל
סוג, וגרסה. אין `eventType` חופשי ואין "ברירת-מחדל שקטה". מופע של
[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)/[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai).
**מקור-סמכות:** [X3 §1ג](X3-integration-deploy.md) (3 סוגי-האירוע: `status_change`, `missing_precedent_created`,
`export_complete`); קוד ה-emitter ([paperclip_api.py:87+](../../web/paperclip_api.py)). (פרויקטלי-תפעולי — משרת G2/G9.)
**אכיפה:** enum + סכמה משותפים emitter↔handler. **כיום:** `eventType` נופל ל-`status_change` כברירת-מחדל
אם חסר/לא-מוכר ([gap-audit GAP-29](gap-audit.md)).
---
## 5. מצב קיים מול יעד — פער אכיפה
האינטגרציה נשענת על **נוהל + שני לקוחות**, לא על מסלול-קוד קנוני אחד:
- **לקוח (INV-INT4):** יעד — לקוח-API יחיד; הסרת מסלול-ה-DB הישיר.
- **מזהים (INV-INT5/INT6):** יעד — טבלת-מיפוי/env יחידה; פונקציית-גזירה אחת.
- **webhook (INV-INT7/INT8):** יעד — event-id + dedup + enum-אירוע מגורס + רישום-כישלון.
כל אלה מקובצים ל-**FU-9** ([gap-audit.md](gap-audit.md)).
---
## 6. הפניות-אחיות
- [X3-integration-deploy.md](X3-integration-deploy.md) — זרימות (wakeup, comments, webhook) + INV-INT1/2/3.
- [X10-deploy-env-secrets.md](X10-deploy-env-secrets.md) — חוזה-env מלא, סודות, hardcoded IDs/creds.
- [X2-multi-company.md](X2-multi-company.md) — CMP/CMPA, sync, company filtering.
- [X6-ui-api-contract.md](X6-ui-api-contract.md) — חוזה ה-API שהפלאגין (inbound) צורך.
- [00-constitution.md](00-constitution.md) — [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים), [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai), כלל "סימטריה" ([§6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
- [web/paperclip_api.py](../../web/paperclip_api.py), [web/paperclip_client.py](../../web/paperclip_client.py), [scripts/pc.sh](../../scripts/pc.sh).

View File

@@ -0,0 +1,118 @@
# X8 — כללי-מילוי-שדות וחילוץ (Field-Population & Extraction Rules)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-**SSoT לכללים שכרגע סמויים בקוד**:
כשמעלים החלטה/פסק-דין/מסמך-תיק — *איזה שדה מתמלא מאיזה מקור*, ומה הכללים על-גבי זה (אי-דריסת
ערך-יו"ר, שער-אישור, ציטוט-verbatim). הכללים האלה חיים היום מפוזרים על-פני 4 שירותים; כאן הם מאוחדים.
הוא משלים את [01-ingest.md](01-ingest.md) (הפייפליין) ו-[02-data-model.md](02-data-model.md) (הסכמה),
ומזין את [X6 INV-UI6](X6-ui-api-contract.md) (שיקוף-מקור ב-UI).
> **מודלי-סמכות מעורבים.** FP1 ו-FP4 הם **הנדסיים** (lineage/integrity — ≥3 מקורות). FP2/FP3/FP5 הם
> **פרויקטלי-תפעוליים** הנקשרים ל-[G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
> (שער אנושי) ו-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
---
## 1. ארבעת מקורות-המילוי
| מקור | הגדרה | דוגמאות |
|------|-------|---------|
| **DETERMINISTIC** | parse של שם-קובץ / מטא-PDF / OCR / regex — ללא LLM | `full_text`, `extraction_status`, `source_kind`, chunks, page_number |
| **OPUS-ANALYSIS** | Claude Opus קורא את כל המסמך, ממלא **רק שדה ריק/placeholder**, אסינכרוני | `headnote`, `summary`, `key_quote`, `subject_tags`, `case_name`, `court`, `date`, `appeal_subtype`, `precedent_level`, `source_type`, `citation_formatted`, halachot |
| **CHAIR-MANUAL** | היו"ר מזין בטופס; חובה או רשות | `citation`/`case_number` (חובה), והשאר נשאר לעריכה |
| **DERIVED** | מחושב משדות אחרים | `district` מ-court, `proceeding_type` מ-appeal_subtype, `searchable` |
---
## 2. טבלת-provenance לפי סוג-מסמך (ה-SSoT)
> מאומת מול [precedent_metadata_extractor.py](../../mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py),
> [halacha_extractor.py](../../mcp-server/src/legal_mcp/services/halacha_extractor.py),
> [ingest.py](../../mcp-server/src/legal_mcp/services/ingest.py), [db.py](../../mcp-server/src/legal_mcp/services/db.py).
### 2א. פסיקה חיצונית (`case_law`, source_kind=`external_upload`)
| שדה | מקור | הערה |
|-----|------|------|
| `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-הלכה |
| 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
ריקים ומטופל כריק ע"י ה-extractor.
### 2ג. מסמך-תיק (`documents`)
| שדה | מקור |
|-----|------|
| `case_id`, `title` | CHAIR |
| `doc_type` | DETERMINISTIC (local_classifier) → fallback Claude אם confidence<0.8 |
| `extracted_text`, `extraction_status`, `page_count` | DETERMINISTIC |
| chunks + `embedding` | DETERMINISTIC + Voyage |
| claims / appraiser_facts | OPUS (כלי-חילוץ נפרדים — ראה [X9](X9-mcp-tool-contract.md)) |
---
## 3. Invariants של התחום
### INV-FP1: לכל שדה מקור-מילוי מוצהר — הטבלה היא ה-SSoT
**כלל:** לכל שדה-מטא יש **מקור-מילוי מוצהר** (deterministic / opus / chair / derived), ב**מקום יחיד**
(טבלת §2). אין כללי-מילוי סמויים מפוזרים בין שירותים. מופע של
[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai) (lineage — מאיפה כל ערך). **הנדסי.**
**מקורות:** ISO 8000-110 (data quality — provenance) · DAMA-DMBOK2 (data lineage) · OpenLineage spec
(https://openlineage.io/) | סטטוס: verified
**אכיפה:** טבלת-provenance מוצהרת (§2) + עמודת-מקור-מילוי לכל שדה-נגזר (יעד; ראה [02-data-model.md](02-data-model.md)).
**הפרה ידועה:** הכללים מפוזרים על precedent_metadata_extractor/halacha_extractor/ingest/recompute_searchable; אין SSoT ([gap-audit GAP-35](gap-audit.md)).
### INV-FP2: חילוץ-LLM אינו דורס ערך שהוזן ידנית
**כלל:** חילוץ-Opus ממלא **רק שדה ריק/placeholder** — ערך שהיו"ר הזין **לעולם אינו נדרס**. סמכות-התוכן
היא היו"ר. מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant). **פרויקטלי-תפעולי.**
**מקור-סמכות:** [precedent_metadata_extractor.py](../../mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py)
(`apply_to_record` — compare-to-empty); feedback היו"ר. (משרת G10.)
**אכיפה:** לוגיקת compare-to-empty ב-extractor; convention placeholder מתועד.
**הפרה ידועה:** placeholder `"(טרם חולץ)"` כמחרוזת-קסם לא-מתועדת/שבירה ([gap-audit GAP-37](gap-audit.md)).
### INV-FP3: פלט-LLM נכנס כ-pending — רק אישור-יו"ר הופך אותו לשמיש
**כלל:** פלט-חילוץ של LLM (הלכות; ובהמשך גם טענות-משפטיות) נכנס במצב **לא-מאושר** (`pending_review`),
ואינו נחשף לחיפוש/החלטה עד **אישור-יו**. מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
(שער אנושי) — תואם [05-qa-review.md](05-qa-review.md). **פרויקטלי-תפעולי.**
**מקור-סמכות:** [halacha_extractor.py](../../mcp-server/src/legal_mcp/services/halacha_extractor.py) (review_status); [01-ingest.md](01-ingest.md).
**אכיפה:** `review_status` חוסם חיפוש עד `approved`/`published`.
**הפרה ידועה:** `legal_arguments` **חסר** שער-אישור מקביל ([gap-audit GAP-39](gap-audit.md); [02-data-model.md](02-data-model.md)).
### INV-FP4: supporting_quote חייב להיות verbatim
**כלל:** כל ציטוט-תומך (`supporting_quote` של הלכה, `key_quote`) חייב להופיע **מילה-במילה** בטקסט-המקור;
אחרת מסומן (`quote_verified=false`). מופע של [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)
(integrity). **הנדסי.**
**מקורות:** ISO 15489-1:2016 (records integrity/authenticity) · RAG attribution (Lewis et al., 2020, NeurIPS) ·
NCSC/JTC — *AI in Courts* (verifiable citation) | סטטוס: verified
**אכיפה:** `proofreader.verify_quote` בעת חילוץ → `quote_verified`.
**הפרה ידועה:** — (קיים; ה-flag נכתב, אך אין חיווי ב-UI — ראה [X6 INV-UI6](X6-ui-api-contract.md)).
### INV-FP5: חילוץ אסינכרוני דרך claude_session מקומי
**כלל:** חילוץ-LLM (מטא, הלכות) רץ **אסינכרוני, מתור**, דרך `claude_session` **מקומי בלבד** — לא חוסם את
ה-web, ולא קורא ל-LLM מהקונטיינר. מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
(מסלול-LLM קנוני יחיד). **פרויקטלי-תפעולי.** תואם זיכרון `feedback_claude_session_local_only`.
**מקור-סמכות:** [ingest.py](../../mcp-server/src/legal_mcp/services/ingest.py) (queue בצעד 12 → `process_pending_extractions`); [legal-ai/CLAUDE.md](../../CLAUDE.md) (claude_session local-only).
**אכיפה:** queue + `precedent_process_pending`; קריאות-LLM רק מ-MCP מקומי.
**הפרה ידועה:** תור-החילוץ **סמוי** (אין הבחנה pending-initial מול pending-review; אין extraction-job table) ([gap-audit GAP-45](gap-audit.md); [X9](X9-mcp-tool-contract.md)).
---
## 4. חוזה-searchable (תזכורת — מוגדר ב-02)
רשומת `case_law` היא `searchable` רק כשמתקיים חוזה-השלמות ([G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש),
[02-data-model.md](02-data-model.md), FU-2a): ≥1 chunk עם embedding · `extraction_status='completed'` ·
`case_number`/`source_kind` לא-ריקים · practice_area (לפנימי) · ≥1 שדה-מטא ({headnote/summary/subject_tags}).
ה-UI חייב **לשקף** את ה-flag הזה ([X6 INV-UI6](X6-ui-api-contract.md)).
---
## 5. הפניות-אחיות
- [01-ingest.md](01-ingest.md) — הפייפליין הקנוני (12 צעדים) שבו החילוץ יושב.
- [02-data-model.md](02-data-model.md) — סכמת השדות + חוזה-searchable + ישויות-נגזרות.
- [X6 INV-UI6](X6-ui-api-contract.md) — שיקוף מקור-המילוי ב-UI.
- [X9-mcp-tool-contract.md](X9-mcp-tool-contract.md) — כלי-החילוץ (claims/appraiser_facts/halachot/metadata).
- [00-constitution.md](00-constitution.md) — [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai), [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant), [G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש), [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).

View File

@@ -0,0 +1,103 @@
# X9 — חוזה כלי-ה-MCP (Agent MCP Tool Contract)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-deep-dive על **משטח כלי-ה-MCP**
71 הכלים ש-[mcp-server](../../mcp-server/) חושף לסוכני Paperclip (CEO/analyst/researcher/writer/qa/…).
עד כה הספ תיאר *מה הסוכנים עושים* ([X4-agents.md](X4-agents.md)) אך לא **חוזה-הכלים** עצמו: envelope,
שמות, idempotency, סימטריית extract/get, ומפת-הרשאות. הקובץ מגדיר את הכללים; הממצאים → [gap-audit.md](gap-audit.md).
> **מודלי-סמכות מעורבים.** TOOL1/TOOL2/TOOL3/TOOL5 הם **הנדסיים** (עיצוב-API/כלים — ≥3 מקורות).
> TOOL4 ו-TOOL6 הם **פרויקטלי-תפעוליים**, הנקשרים ל-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
> ו-[G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant).
---
## 1. אינוונטר (71 כלים, [server.py](../../mcp-server/src/legal_mcp/server.py))
| דומיין | כלים (מייצג) |
|--------|--------------|
| ניהול-תיק | case_create/list/get/update/delete, case_get_final_text |
| מסמכים | document_upload, document_upload_training, document_list/get_text/update, extract_references |
| טענות+טיעונים | extract_claims, get_claims, aggregate_claims_to_arguments, get_legal_arguments |
| **חיפוש (6 — חופפים)** | search_decisions, search_case_documents, find_similar_cases, search_internal_decisions, search_precedent_library, precedent_search_library |
| **כתיבת-בלוק (6 — חופפים)** | draft_section, get_block_context, write_block, write_all_blocks, write_interim_draft, save_block_content |
| ייצוא/QA | export_docx, export_interim_draft, validate_decision, revise_draft, list_bookmarks, apply_user_edit |
| פסיקה (3 תת-מערכות) | case-attached (precedent_attach/list/remove/search_library) · library (precedent_library_*) · internal (internal_decision_*) |
| הלכות | halacha_review, halachot_pending, precedent_extract_halachot/metadata, precedent_process_pending |
| ציטוטים | extract_internal_citations, list_internal_citations, list_incoming_citations |
| missing-precedents | missing_precedent_create/list/close |
| workflow/feedback | workflow_status, get_metrics, processing_status, set_outcome, brainstorm_directions, approve_direction, ingest_final_version, record/list_chair_feedback |
| appraiser/style | extract_appraiser_facts, style_corpus_enrich, style_corpus_pending_enrichment |
---
## 2. Invariants של התחום
### INV-TOOL1: envelope-תשובה עקבי לכל הכלים
**כלל:** כל כלי מחזיר **מבנה אחיד** (למשל `{status, data, message}`) — לא string-לפעמים-JSON-לפעמים-`{error}`.
שגיאה מובחנת ממצב-ריק ממצב-הצלחה באופן עקבי. מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים);
מקביל ל-[X6 INV-UI3](X6-ui-api-contract.md). **הנדסי.**
**מקורות:** Anthropic — *MCP / tool result conventions* (https://modelcontextprotocol.io/) ·
JSON-RPC 2.0 (result/error envelope) (https://www.jsonrpc.org/specification) · RFC 9457 (Problem Details) | סטטוס: verified
**אכיפה:** wrapper-תשובה משותף בכל הכלים — `tools/envelope.py` (`ok`/`empty`/`err``{status,data,message}`, status ∈ ok/empty/error — מבחין הצלחה/ריק/שגיאה), SSoT יחיד שמחליף את 5 ה-`_ok`/`_err` המשוכפלים. עיקרון: envelope-`status` משקף אם **הקריאה לכלי** הצליחה; תוצאות-עסקיות (failed_gates/results/...) נשמרות בתוך `data`. צרכני-API ב-`web/app.py` מפרקים דרך `envelope_unwrap` (+בדיקת `status=="error"`→4xx) כדי לשמר את חוזה-ה-UI↔API (X6) ללא-שינוי. **GAP-48 ✅ הושלם (2026-06-06):** כל ~12 משפחות-הכלים הומרו ל-envelope (search · precedent_library · citations · internal_decisions · missing_precedents · training_enrichment · precedents · legal_arguments · cases · documents · workflow · drafting). מסלול הפקת-ההחלטה (`export_docx` שער-QA) מאומת ב-`test_export_qa_gate`. 182/182 טסטים עוברים.
**הפרה ידועה:** — (נסגר)
### INV-TOOL2: שמות עקביים + חיפוש לפי-קורפוס
**כלל:** שמות-הכלים עוקבים אחר convention אחיד, ושם משקף התנהגות. כלי-חיפוש מובחנים **לפי הקורפוס**
(style / internal / external / case-attached), לא ב-6 שמות חופפים; כלי-כתיבת-בלוק אינם חופפים (context מול write).
מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) ("סימטריה", [§6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)). **הנדסי.**
**מקורות:** Anthropic — *Writing effective tools / clear names* (https://www.anthropic.com/engineering/writing-tools-for-agents) ·
Google *API Design Guide* (naming) (https://cloud.google.com/apis/design/naming_convention) ·
Zalando *RESTful API Guidelines* | סטטוס: verified
**אכיפה:** איחוד/מיזוג כלי-חיפוש + כלי-בלוק; rename של שמות-מטעים. **GAP-49 (חלק קריטי) ✅ נסגר (2026-06-06):** הכלי המטעה `precedent_search_library` (חיפוש ציטוטים מצורפים-לתיק) שונה ל-**`search_case_precedents`** — מבטל את ההיפוך המסוכן מול `search_precedent_library` (הספרייה הסמכותית); הישן נשמר כ-alias deprecated לתאימות. docstrings של שני הכלים הובהרו (case-attached מול authoritative). 5 כלי-החיפוש הנותרים (search_decisions=סגנון-דפנה · search_case_documents=תיק · find_similar_cases=cross-case · search_internal_decisions=ועדות-ערר · search_precedent_library=פסיקה-סמכותית) מחפשים קורפוסים מובחנים עם שמות סבירים.
**GAP-50 ✅ נסגר (2026-06-06, הכרעת-יו"ר):** הכפילות האמיתית היחידה — `draft_section` (הקשר לפי-סעיף, ישן) — סומנה **deprecated** לטובת `get_block_context` (הקשר לפי-בלוק, תואם 12-הבלוקים). שאר כלי-הכתיבה (`write_block`/`write_all_blocks`/`save_block_content`/`write_interim_draft`) **מובחנים בכוונה** — משרתים זרימות שונות (CLI/initial-draft מול תהליך-ה-writer שבו "התיקון חי בקובץ, לא ב-DB"), ולא מוזגו במכוון.
**הפרה ידועה:** — (נסגר)
### INV-TOOL3: idempotency בכל כלי-מוטציה
**כלל:** כלי שמשנה-מצב הוא **idempotent על מפתח דטרמיניסטי** — קריאה חוזרת אינה יוצרת כפילות. מופע של
[G3](00-constitution.md#inv-g3-ingest-אחיד-ו-idempotent). **הנדסי.**
**מקורות:** Stripe — *Idempotent requests* (https://docs.stripe.com/api/idempotent_requests) ·
Kleppmann *DDIA* (idempotence) · IETF — *Idempotency-Key header* draft (https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/) | סטטוס: verified
**אכיפה:** upsert/ON CONFLICT (או בדיקת-מפתח ברמת-אפליקציה) בכלי-מוטציה. **GAP-52 ✅ נסגר (2026-06-06):** `case_create` (מפתח case_number, UNIQUE), `precedent_attach` (מפתח case_id+section_id+citation+quote), `document_upload` (מפתח case_id+SHA-256 של הקובץ — מדלג על OCR/embed כפול) — כולם מחזירים את הקיים במקום כפילות. נבחרה בדיקת-מפתח ברמת-אפליקציה (לא UNIQUE-constraint) כדי לא לשבור startup על נתונים-קיימים כפולים. קודמים: `missing_precedent_create`/`precedent_link_cases`/`extract_internal_citations`.
**הפרה ידועה:**
### INV-TOOL4: סימטריית extract/get + persistence
**כלל:** לכל כלי-חילוץ שכותב ל-DB יש **כלי-קריאה (get) מקביל**, והפלט **נשמר durably** (לא מוחזר-ונאבד).
מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) (מקור-אמת נגיש). **פרויקטלי-תפעולי.**
**מקור-סמכות:** דפוס `extract_claims``get_claims`, `aggregate``get_legal_arguments` ב-[server.py](../../mcp-server/src/legal_mcp/server.py).
**אכיפה:** לכל extract — get מקביל. **GAP-44 ✅ + GAP-45 ✅ נסגרו (2026-06-06):** נוסף `get_appraiser_facts` (קורא `list_appraiser_facts`+`detect_appraiser_conflicts`, ללא חילוץ-מחדש); נוסף `extraction_status` שחושף את עומק תור-החילוץ (metadata/halacha) + גיל הבקשה הוותיקה — read-only. **GAP-47 (חלק provenance) ✅ נסגר (2026-06-06):** `draft_section` מחזיר `document_id`+`page`+`score` לכל קטע (provenance מ-`search_similar` שהיה נזרק) → מקור-אמת נגיש ובר-ציטוט (G9). נותר ב-GAP-47: הנחיות-יו"ר ל-DB (פרוסה נפרדת).
**הפרה ידועה:**
### INV-TOOL5: limit-caps על כל כלי-רשימה/חיפוש
**כלל:** לכל כלי שמחזיר רשימה יש **תקרת-limit נאכפת** (הגנה מפני עומס/DoS); pagination היכן שרלוונטי. **הנדסי.**
**מקורות:** OWASP API Security Top 10 — *API4:2023 Unrestricted Resource Consumption* (https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/) ·
Microsoft *REST API Guidelines* (pagination) · Stripe API (limit caps) | סטטוס: verified
**אכיפה:** clamp ל-max בכל כלי-רשימה. **GAP-53 ✅ נסגר (2026-06-06):** `_clamp_limit` (תקרה 200) על ~13 כלי list/search ב-[server.py](../../mcp-server/src/legal_mcp/server.py); `list_chair_feedback` קיבל param `limit` (server→workflow→db עם `LIMIT`).
**הפרה ידועה:**
### INV-TOOL6: שלמות-הרשאות — כל כלי שהוראות-הסוכן דורשות מוענק
**כלל:** מפת-ההרשאות (אילו כלים מוענקים לכל סוכן) **תואמת** את מה שהוראות-הסוכן מצריכות — לא חסר ולא עודף.
מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant) (שערים מוגדרים); מפורט ב-[X4-agents.md](X4-agents.md). **פרויקטלי-תפעולי.**
**מקור-סמכות:** frontmatter `tools:` ב-[.claude/agents/](../../.claude/agents/) מול הוראות-הסוכן.
**אכיפה:** בדיקת-עקביות tools↔instructions (יעד FU-13).
**הפרה ידועה:** legal-analyst חסר `aggregate_claims_to_arguments`/`extract_references`/`extract_internal_citations`; researcher חסר טריגרי-חילוץ ([gap-audit GAP-46](gap-audit.md)).
---
## 3. הערות-עיצוב
- **set_outcome — GAP-51 ✅ נסגר (2026-06-06):** SSoT יחיד = 3 תוצאות קנוניות `rejection/partial_acceptance/full_acceptance`
ב-`lessons.VALID_OUTCOMES`; `OUTCOME_LABELS_HE` = מפת-תוויות עברית אחת (אנגלית ב-DB, עברית ב-UI); `canonical_outcome()`
ממפה ערכי-legacy (rejected/accepted/partial). `betterment_levy` הוצא מהיותו תוצאה → `PRACTICE_AREA_OVERRIDES`
(override לפי practice_area מעל התוצאה). נתונים נורמלו (~9 שורות, גיבוי ב-`data/audit/gap51-outcome-backup-*`).
- **3 מסלולי-קליטת-פסיקה** (library / internal / training) עם ולידציה א-סימטרית — נקשר ל-[01-ingest.md](01-ingest.md) / GAP-01/05.
הממצאים המלאים + התיקון → **FU-14** ([gap-audit.md](gap-audit.md)).
---
## 4. הפניות-אחיות
- [X4-agents.md](X4-agents.md) — מפת-הסוכנים + ההרשאות (INV-TOOL6).
- [X8-field-provenance.md](X8-field-provenance.md) — כלי-החילוץ ומה שהם שומרים.
- [X6-ui-api-contract.md](X6-ui-api-contract.md) — envelope מקביל בצד-ה-API.
- [01-ingest.md](01-ingest.md), [03-retrieval.md](03-retrieval.md) — מסלולי-קליטה/חיפוש שהכלים עוטפים.
- [00-constitution.md](00-constitution.md) — [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים), [G3](00-constitution.md#inv-g3-ingest-אחיד-ו-idempotent), [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant).
- [mcp-server/src/legal_mcp/server.py](../../mcp-server/src/legal_mcp/server.py), [tools/](../../mcp-server/src/legal_mcp/tools/).

View File

@@ -17,6 +17,10 @@
## 23 הממצאים ## 23 הממצאים
> **סטטוס מחזור-1 (עודכן 31.5.2026):** כל 23 הממצאים **✅ נסגרו** — FU-1..FU-8b מוזגו ל-main
> (PRs #11#23: FU-1/2a, FU-2b #15, FU-2c #17, FU-3, FU-4, FU-5 #18, FU-6, FU-7 #13, FU-8a #16, FU-8b #23).
> 122 בדיקות עוברות. הטבלה נשמרת כתיעוד-מקור; פירוט-ה-FU והסטטוס בסעיף "יחידות-תיקון".
| ID | כותרת | invariant מופר | severity | קבצים מושפעים (file:line) | תיקון מוצע | | ID | כותרת | invariant מופר | severity | קבצים מושפעים (file:line) | תיקון מוצע |
|----|-------|----------------|----------|---------------------------|------------| |----|-------|----------------|----------|---------------------------|------------|
| GAP-01 | שני מסלולי ingest מקבילים שמתפצלים | INV-ING1, G2 | High | `precedent_library.py:88`, `internal_decisions.py:73` | מסלול-קליטה קנוני יחיד; ישויות-אחיות חולקות פייפליין | | GAP-01 | שני מסלולי ingest מקבילים שמתפצלים | INV-ING1, G2 | High | `precedent_library.py:88`, `internal_decisions.py:73` | מסלול-קליטה קנוני יחיד; ישויות-אחיות חולקות פייפליין |
@@ -45,12 +49,66 @@
--- ---
## ממצאי מחזור-2 (8 משטחי-האפליקציה מחוץ לצינור-הליבה) — GAP-24..62
> הופקו בסקירת-קוד word-for-word (3031.5.2026) של 8 המשטחים: גבול-Paperclip, web-ui,
> מילוי-שדות, אחסון-ניתוחים, כלי-MCP (71), סוכנים+skills, deploy/env. ממצאי-ה-UI ברמת-הדף
> מפורטים ב-[ui-audit.md](ui-audit.md). ה-invariants ב-[X6](X6-ui-api-contract.md)[X10](X10-deploy-env-secrets.md).
> **כל מחזור-2 פתוח** (אומת 31.5.2026: creds plaintext קיימים, 2 לקוחות קיימים, אין get_appraiser_facts, analyst חסר 3 כלים).
| ID | כותרת | invariant מופר | severity | קבצים מושפעים (file:line) | תיקון מוצע |
|----|-------|----------------|----------|---------------------------|------------|
| GAP-24 | שני לקוחות Paperclip מקבילים (api מול client legacy) | INV-INT4, G2 | High | `web/paperclip_api.py`, `web/paperclip_client.py` | לקוח-API קנוני יחיד |
| GAP-25 | גישת-DB ישירה ל-Paperclip (INSERT projects/issues/plugin_state) עוקפת API+audit | INV-INT4, G2, G9 | High | `web/paperclip_client.py` | להעביר הכל ל-API; להסיר מסלול-DB |
| GAP-26 | company/agent IDs קשיחים — **סותר X3 §1א** | INV-INT5, G2 | High | `web/paperclip_client.py:36-62`, `web/app.py:3976`, plugin `worker.ts` | מיפוי מ-config/env |
| GAP-27 | `company_id` נגזר בשתי דרכים (prefix מול fallback-map) | INV-INT6, G2 | Medium | `web/app.py` (prefix), `web/paperclip_client.py` (`_FALLBACK_APPEAL_TYPE_TO_COMPANY`) | פונקציית-גזירה יחידה |
| GAP-28 | webhooks fire-and-forget בולעים שגיאות, ללא idempotency | INV-INT7, G9, §6 | Medium | `web/paperclip_api.py:87-205` | event-id+dedup+רישום-כישלון |
| GAP-29 | חוזה-אירוע webhook לא-מתוקען (eventType חופשי, default שקט) | INV-INT8, G2 | Medium | `web/paperclip_api.py:87+`, plugin `onWebhook` | enum-אירוע מגורס |
| GAP-30 | ~60% endpoints ללא Pydantic → `unknown` → טיפוסים ידניים סוטים | INV-UI1/UI2, G2/G4 | High | `web/app.py` (רוב), `web-ui/src/lib/api/cases.ts:1-9` | response models + `api:types` |
| GAP-31 | `PracticeArea`/enum-סטטוס משוכפלים פרונט (3 מקומות, ערכים שונים) | INV-UI1, G2 | High | `web-ui/src/lib/practice-area.ts:12`, `lib/api/precedent-library.ts:26`, `components/precedents/practice-area.ts` | SSoT יחיד (ui-audit UI-A1/B1) |
| GAP-32 | אין envelope עקבי; שגיאות נבלעות ב-UI | INV-UI3/UI4, §6 | Medium | `web/app.py` (search ועוד), דפי-UI | envelope אחיד + error-card |
| GAP-33 | fallback SSE מסתיר כישלון; cache-TTL לא-תואם (5ש'↔300ש') | INV-UI5 | Low | `web-ui/src/lib/api/documents.ts:226-232` | terminal-state מפורש |
| GAP-34 | URLs קשיחים ב-UI/בק | INV-UI3/ENV3 | Low | `web-ui/.../app-shell.tsx:70`, `web/app.py:110` | env |
| GAP-35 | מקור-מילוי-שדות לא-מוצהר — מפוזר על 4 שירותים | INV-FP1, G9 | High | `precedent_metadata_extractor.py`, `halacha_extractor.py`, `ingest.py`, `db.py` (recompute_searchable) | טבלת-provenance SSoT (X8 §2) |
| GAP-36 | אין שקיפות-UI למה מולא ע"י Opus מול ידני | INV-UI6/FP1, G9 | Medium | `web-ui/src/app/precedents/[id]/page.tsx:160-185` | חיווי מקור-מילוי |
| GAP-37 | placeholder `"(טרם חולץ)"` כמחרוזת-קסם לא-מתועדת | INV-FP2 | Low | `internal_decisions.py`, `precedent_metadata_extractor.py` | constant מתועד |
| GAP-38 | שתי עמודות-סטטוס-חילוץ ב-case_law | INV-DM1, G2 | Medium | `db.py:603-606` | סטטוס יחיד / extraction-jobs |
| GAP-39 | `legal_arguments` ללא שער-אישור (בניגוד ל-halachot) | INV-DM5, G10 | High | `db.py:845-872` | `review_status` ל-legal_arguments |
| GAP-40 | `legal_arguments.cited_precedents TEXT[]` ללא FK → הזיות-LLM נבלעות | INV-DM6, G9, §6 | Medium | `db.py:858`, `argument_aggregator.py` | FK + דיווח-כישלון-קישור |
| GAP-41 | `appraiser_facts``claims` התנגשות; `appraiser_side` default '' מעורפל | INV-DM6 | Medium | `db.py:549-576` | CHECK + הבחנה document↔case |
| GAP-42 | 20+ enums כ-TEXT חופשי; אין embedding-provenance | INV-DM6/DM4, G4 | Medium | `db.py` (source_type, rule_type, status…) | CHECK-enums + עמודת-model |
| GAP-43 | `case_precedents``case_law` טבלאות-פסיקה מקבילות legacy | INV-G2 | Low | `db.py` | איחוד/סימון-deprecated |
| GAP-44 | אסימטריית extract/get — אין `get_appraiser_facts` (חילוץ-חוזר יקר) | INV-TOOL4, G2 | High | `mcp-server/.../drafting.py`, `server.py:563` | להוסיף `get_appraiser_facts` |
| GAP-45 | תור-חילוץ סמוי (pending-initial מול pending-review); אין extraction-job table | INV-TOOL4/FP5, G10 | Medium | `precedent_library.py`, `ingest.py` | `*_extraction_status` tool + טבלת-jobs |
| GAP-46 | הרשאות-סוכן לא-מתועדות (analyst/researcher חסרי כלים) | INV-AG3/TOOL6 | High | `.claude/agents/legal-analyst.md`, `legal-researcher.md` | יישור tools↔instructions |
| GAP-47 | `draft_section` ללא provenance (chunk→document/page); הנחיות-יו"ר ב-md ולא DB | INV-TOOL4, G9 | Medium | `mcp-server/.../drafting.py` | provenance בפלט + DB ל-directions |
| GAP-48 | envelope-תשובה לא-עקבי (71 כלים: string/JSON/{error}) | INV-TOOL1, G2 | Medium | `mcp-server/.../server.py`, tools/ | wrapper `{status,data,message}` |
| GAP-49 | 6 כלי-חיפוש חופפים + `precedent_search_library` שם-מטעה | INV-TOOL2, G2 | Medium | `server.py` (search_*), `precedents.py:81` | ✅ **שם-מטעה תוקן** (`precedent_search_library``search_case_precedents`, alias deprecated); 5 הנותרים = קורפוסים מובחנים בשמות סבירים |
| GAP-50 | 6 כלי-כתיבת-בלוק חופפים (draft_section/get_block_context/write_*/save_*) | INV-TOOL2, G2 | Medium | `server.py:500-616` | ✅ **draft_section deprecated→get_block_context** (הכרעת-יו"ר); write_*/save_* מובחנים בכוונה (זרימות שונות), לא מוזגו |
| GAP-51 | `set_outcome` enum-mismatch (3≠4); אוצרות-מילים סותרות | INV-TOOL1/UI1 | Medium | `block_writer.py:442` מול `lessons.py:11`, `workflow.py:145` | SSoT יחיד ל-outcome |
| GAP-52 | רוב הכלים לא-idempotent (case_create/document_upload/precedent_attach) | INV-TOOL3, G3 | Medium | `server.py`, tools/ | upsert/ON CONFLICT |
| GAP-53 | אין limit-caps (precedent_library_list/search_*/list_chair_feedback) | INV-TOOL5 | Low | tools/ | clamp ל-max |
| GAP-54 | 3 מסלולי-קליטת-פסיקה ולידציה א-סימטרית; citation-guard לא-מתועד | INV-ING1, G2 | Medium | `precedent_library.py`, `internal_decisions.py` | ✅ **נפתר ע"י FU-1** — שני מסלולי-הפסיקה (library+internal) עוברים דרך `ingest.ingest_document` הקנוני (ולידציית-enums + citation-guard סימטריים, מתועד ב-01-ingest §4); המסלול ה-3 (training→`style_corpus`) הוא קורפוס נפרד במכוון (סגנון, לא פסיקה). מאומת ב-`test_unified_ingest.py` |
| GAP-55 | Infisical dead-code; מקור-config לא-מתועד (Coolify-only) | INV-ENV2, G2 | Medium | `mcp-server/.../config.py` | לתעד Coolify SSoT / לבודד Infisical |
| GAP-56 | UUIDs קשיחים (company/agent) — תואם GAP-26 | INV-ENV3/INT5 | High | `web/paperclip_client.py:36-62`, `web/app.py:3976` | config-driven |
| GAP-57 | creds plaintext בברירת-מחדל (`paperclip:paperclip`) | INV-ENV4, G9, §6 | High | `web/paperclip_client.py:21`, `web/app.py:3789,3964` | default ריק + fail-loud |
| GAP-58 | `GITEA_ACCESS_TOKEN``GITEA_TOKEN` שני שמות; קטלוג חלקי | INV-ENV1 | Low | `web/gitea_client.py:22`, `git_sync.py:30`, `tools/cases.py:28` | שם קנוני יחיד + קטלוג |
| GAP-59 | chat-URL docs↔reality (`10.0.1.1` מול `host.docker.internal`) | INV-ENV3 | Medium | `web/chat_proxy.py:49`, `chat_service/server.py` | יישור env + תיעוד |
| GAP-60 | 13/40+ env vars ב-drift-catalog; 8+ סודות בלתי-מנוטרים | INV-ENV5/ENV1 | Medium | `web/mcp_env_catalog.py` | קטלוג מקיף |
| GAP-61 | URLs + `/home/chaim` קשיחים | INV-ENV3 | Low | `web/paperclip_client.py:31`, app.py | env/config |
| GAP-62 | start.sh לא-נכשל-על-uvicorn; deploy-curl fire-and-forget | INV-ENV2/§6 | Low | `start.sh`, `.gitea/workflows/deploy.yaml` | health-gate + אימות-deploy |
---
## יחידות-תיקון מוצעות (Proposed Fix-Units) ## יחידות-תיקון מוצעות (Proposed Fix-Units)
23 הממצאים מקובצים ל-8 יחידות-עבודה קוהרנטיות. הקיבוץ נגזר מהעיקרון שרבים מהממצאים 23 הממצאים מקובצים ל-8 יחידות-עבודה קוהרנטיות. הקיבוץ נגזר מהעיקרון שרבים מהממצאים
נפתרים יחד (כל פערי ה-ingest-asymmetry → יחידה אחת). זהו זרע למשימות TaskMaster נפתרים יחד (כל פערי ה-ingest-asymmetry → יחידה אחת). זהו זרע למשימות TaskMaster
ולתת-פרויקט 3 (שכבת-שלמות). ולתת-פרויקט 3 (שכבת-שלמות).
> **✅ מחזור-1 הושלם (31.5.2026):** FU-1..FU-8b כולם מוזגו ל-main. מחזור-2 (FU-9..15, להלן)
> נגזר מ-GAP-24..62 ו**פתוח**.
### FU-1 — איחוד מסלול-הקליטה (Unify ingest path) ### FU-1 — איחוד מסלול-הקליטה (Unify ingest path)
- **מכסה:** GAP-01, GAP-02, GAP-04, GAP-05 - **מכסה:** GAP-01, GAP-02, GAP-04, GAP-05
- **מספק invariants:** INV-ING1, INV-ING3, INV-G2, INV-G4; (תורם ל-DM1/RET2 דרך GAP-02) - **מספק invariants:** INV-ING1, INV-ING3, INV-G2, INV-G4; (תורם ל-DM1/RET2 דרך GAP-02)
@@ -110,6 +168,51 @@
- **תלויות:** ה-spec גמור (GAP-23 דורש קבצי-ספ יציבים לחבר לסוכנים) - **תלויות:** ה-spec גמור (GAP-23 דורש קבצי-ספ יציבים לחבר לסוכנים)
- **סוג:** pure-code + **chair-decision** — GAP-23 (חיבור ספ לסוכני-Paperclip) הוא - **סוג:** pure-code + **chair-decision** — GAP-23 (חיבור ספ לסוכני-Paperclip) הוא
prerequisite לתת-פרויקט 5 ומשנה התנהגות-סוכן בייצור prerequisite לתת-פרויקט 5 ומשנה התנהגות-סוכן בייצור
- **סטטוס:** ✅ FU-8a (GAP-21/22, PR #16) + FU-8b (GAP-23, PR #23) מוזגו.
> **— מחזור-2 (FU-9..15): 8 משטחי-האפליקציה מחוץ לצינור-הליבה. כולם פתוחים. —**
### FU-9 — לקוח-Paperclip קנוני
- **מכסה:** GAP-24..29 · **invariants:** INV-INT4INT8 · **effort:** L · **תלויות:** [X7](X7-paperclip-client-params.md) יציב
- **סוג:** code — איחוד 2 הלקוחות, הסרת מסלול-DB, IDs מ-config, company_id יחיד, webhook idempotency+enum
### FU-10 — חוזה UI↔API + design-system SSoT
- **מכסה:** GAP-30..34 + [ui-audit](ui-audit.md) (UI-A1..D6) · **invariants:** INV-UI1UI6 · **effort:** L · **תלויות:**
- **סוג:** code — Pydantic models+`api:types`, SSoT ל-enums/תוויות/tones, helpers משותפים, ניקוי redundancy
### FU-11 — מילוי-שדות מוצהר + שקיפות-UI
- **מכסה:** GAP-35..37 · **invariants:** INV-FP1FP5, UI6 · **effort:** M · **תלויות:**
- **סוג:** code — טבלת-provenance SSoT, formalize placeholder, חיווי "מולא-ע"י-Opus" + searchable + pending ב-UI
### FU-12 — חיזוק אחסון-הניתוחים
- **מכסה:** GAP-38..43 · **invariants:** INV-DM4DM6 · **effort:** M · **תלויות:** FU-1
- **סוג:** code + data-migration קל — provenance, שער-אישור ל-legal_arguments, CHECK-enums, FK, איחוד case_precedents
### FU-13 — סוכנים + skills — ✅ נסגר (2026-06-06)
- **מכסה:** GAP-46 (מרחיב GAP-23) · **invariants:** INV-AG3, INV-TOOL6 · **effort:** S · **תלויות:** ה-spec יציב
- **סוג:** code/docs — שלמות-הרשאות (tools↔instructions), DRY-boilerplate, dedup-skills
- **סטטוס:** הכרעת-יו"ר "היבריד". התברר שהפער ב-31.5 היה רחב מדי (יוחס לפי תיאור-תפקיד, לא הוראות בפועל).
researcher כבר היה תקין (מיושן ב-spec). analyst קיבל `aggregate_claims_to_arguments` + שלב 7 ("שלב 1");
`extract_references`/`extract_internal_citations` נשארו אצל researcher (מטלת-מחקר, לא analyst). עודכן [X4 §2א](X4-agents.md).
### FU-14 — חוזה כלי-ה-MCP
- **מכסה:** GAP-44,45,47..54 · **invariants:** INV-TOOL1TOOL5 · **effort:** L · **תלויות:** FU-1
- **סוג:** code — envelope אחיד, מיזוג חיפוש/בלוקים, idempotency, limit-caps, get-symmetry, set_outcome SSoT
- **סטטוס חלקי (פרוסה 1, 2026-06-06):** ✅ **GAP-44** — נוסף `get_appraiser_facts` (ה-get המקביל ל-extract, INV-TOOL4); ✅ **GAP-53** — נוסף `_clamp_limit` (תקרה 200, INV-TOOL5) על ~13 כלי list/search + הוספת limit ל-`list_chair_feedback` (שהיה ללא תקרה).
- **סטטוס חלקי (פרוסה 2, 2026-06-06):** ✅ **GAP-52** (INV-TOOL3 idempotency) — `case_create`/`precedent_attach`/`document_upload` מחזירים קיים במקום כפילות (בדיקת-מפתח ברמת-אפליקציה; document_upload לפי SHA-256 → מדלג OCR/embed כפול); ✅ **GAP-45** (INV-TOOL4 visibility) — נוסף `extraction_status` שחושף עומק תור-החילוץ (metadata/halacha) + גיל הבקשה הוותיקה.
- **סטטוס חלקי (פרוסה 3, 2026-06-06):** ✅ **GAP-51** (set_outcome SSoT, הכרעת-יו"ר "3 תוצאות + הוצאת betterment_levy") — קנוני `rejection/partial_acceptance/full_acceptance` ב-`lessons.VALID_OUTCOMES`; `OUTCOME_LABELS_HE` (עברית-ב-UI SSoT); `canonical_outcome()` ל-legacy; `betterment_levy``PRACTICE_AREA_OVERRIDES` (override לפי practice_area); block_writer/set_outcome/drafting/web-ui יושרו; נתונים נורמלו (9 שורות + גיבוי).
- **סטטוס חלקי (פרוסה 4, 2026-06-06):** ✅ **GAP-47 (חלק provenance, INV-TOOL4/G9)**`draft_section` חושף בפלט `document_id`+`page`+`score` לכל קטע ב-`case_documents`/`precedents` (ה-provenance כבר נשלף ב-`search_similar` ונזרק; כעת מוחזר), כך שהכותב יכול לעקוב אל המקור ולצטטו. תוספתי, לא-שובר. **נותר ב-GAP-47:** העברת הנחיות-יו"ר מ-`analysis-and-research.md` ל-DB (`get_chair_directions`) — שינוי-מסלול גדול יותר הנוגע ל-UI של דפנה ולזרימת-האנליסט; לפרוסה נפרדת.
- **סטטוס חלקי (פרוסה 5, 2026-06-06):** 🔄 **GAP-48 (envelope אחיד, INV-TOOL1) — תחילת מיגרציה הדרגתית.** נוצר `tools/envelope.py` כ-SSoT יחיד (`ok`/`empty`/`err``{status,data,message}`, status מבחין הצלחה/ריק/שגיאה) המחליף 3 מוסכמות סותרות (raw payload / `{error}` / `{status,message}` אד-הוק) ו-5 עותקי `_ok`/`_err` משוכפלים. **משפחת-החיפוש הראשונה הומרה** (`search_decisions`/`search_case_documents`/`find_similar_cases`/`search_internal_decisions`); `web/app.py` מפרק דרך `envelope_unwrap` לשמירת חוזה-ה-API (X6) ללא-שינוי; טסט `test_search_domain_scope` עודכן לחוזה החדש (5/5 ✅). **החלטה הנדסית:** הדרגתי לפי-משפחה ולא big-bang — מפת-צרכנים (Explore) הראתה ש-server.py הוא pass-through, web-ui מבודד (`/api/*`), ורק 17 כלים נצרכים ישירות מ-app.py — כך הסיכון לסוכנים החיים ממוזער. נותרו ~73 כלים בפרוסות הבאות.
- **סטטוס חלקי (פרוסה 6, 2026-06-06):** 🔄 **GAP-48 — מיגרציה רוחבית.** הומרו 10 משפחות נוספות ל-envelope: `precedent_library` (14), `citations` (3), `internal_decisions` (1), `missing_precedents` (4), `training_enrichment` (2), `precedents` (4), `legal_arguments` (2), `cases` (7), `documents` (8), `workflow` (9). בוטלו 5 עותקי `_ok`/`_err` משוכפלים (alias ל-SSoT, G2). עיקרון: envelope-`status` = הצלחת-הקריאה; תוצאה-עסקית (idempotent_existing/noop/...) ב-`data`. צרכני-app.py של cases/workflow/precedents חוּוטו דרך `envelope_unwrap` + בדיקת `status=="error"`→4xx, לשמירת חוזה-ה-API. כל הטסטים עוברים (182/182; `test_corpus_constraints` עודכן לחוזה). **נותר:** משפחת `drafting` (18 כלים — מסלול הפקת-ההחלטה) בפרוסה נפרדת.
- **פרוסה 7, 2026-06-06 — ✅ GAP-48 הושלם.** משפחת `drafting` (18 כלים) הומרה ל-envelope. export_docx/revise_draft/apply_user_edit משתמשים ב-`err`-לכשל (כך שהסוכן והמשתמש רואים את הכשל ברמת-המעטפת), כש-`failed_gates` רוכב ב-`data`; 6 צרכני-app.py (get_decision_template/apply_user_edit×2/revise_draft/list_bookmarks/export_docx) חוּוטו עם בדיקת envelope-status; `test_export_qa_gate` עודכן לחוזה (182/182 עוברים). **GAP-48 סגור — כל ~12 המשפחות אחידות.**
- **פרוסה 8, 2026-06-06 — ✅ GAP-49 (החלק הקריטי).** השם המטעה `precedent_search_library` (ציטוטים מצורפים-לתיק) שונה ל-`search_case_precedents` ובכך בוטל ההיפוך המסוכן מול `search_precedent_library` (ספרייה סמכותית — מקור CREAC). הישן נשמר כ-alias deprecated (ב-server.py) → אפס שבירה לסוכנים חיים. docstrings הובהרו; עודכנו app.py (typeahead) + legal-researcher/legal-writer docs + precedent_library docstring. 5 כלי-החיפוש הנותרים מחפשים קורפוסים מובחנים בשמות סבירים — לא בוצע rename-המוני (churn גבוה, ערך נמוך). 182/182 עוברים. **⚠ אחרי merge+deploy:** סנכרון cross-company של doc-הסוכן (frontmatter `search_case_precedents`). נותר ב-FU-14: GAP-50 (מיזוג כלי-בלוק — נוגע בתהליך-הכתיבה, דורש הכרעת-יו"ר), GAP-54, GAP-47-חלק-ב.
- **פרוסה 9, 2026-06-06 — ✅ GAP-50 (הכרעת-יו"ר).** מיפוי הראה שכלי-הבלוק אינם "כפילות מיותרת": `write_block`/`write_all_blocks`/`save_block_content`/`write_interim_draft` משרתים זרימות שונות (CLI/initial-draft מול תהליך-ה-writer "התיקון בקובץ, לא ב-DB"). הכפילות האמיתית היחידה — `draft_section` (הקשר לפי-סעיף, כמעט-נטוש) חופף ל-`get_block_context` (לפי-בלוק, קנוני). הוחלט (יו"ר): **draft_section deprecated** (docstring ב-server.py+drafting.py מפנה ל-get_block_context; draft-decision.md עודכן) — בלי הסרה, בלי מיזוג כלי-הכתיבה (שמירת תהליך-הכתיבה המכוון). 182/182 עוברים. **GAP-49+50 סגורים.** נותר ב-FU-14: GAP-54 (איחוד קליטת-פסיקה), GAP-47-חלק-ב (הנחיות-יו"ר→DB).
- **פרוסה 10, 2026-06-06 — ✅ GAP-54 (נסגר כ-resolved-by-FU-1).** אימות (G2: לא לפתור מחדש): `ingest.ingest_document` הוא המסלול הקנוני; `precedent_library` ו-`internal_decisions` שניהם עוברים דרכו עם ולידציית-enums + citation-guard סימטריים (מתועד ב-01-ingest §4); training→`style_corpus` הוא קורפוס נפרד במכוון. 9/9 `test_unified_ingest` עוברים — אין קוד לכתוב. **FU-14 כמעט-מלא: נותר רק GAP-47-חלק-ב** (העברת הנחיות-יו"ר מ-`analysis-and-research.md` ל-DB) — פיצ'ר UI+זרימת-אנליסט נפרד, לא דחוף.
### FU-15 — deploy/env/secrets
- **מכסה:** GAP-55..62 · **invariants:** INV-ENV1ENV5 · **effort:** M · **תלויות:**
- **סוג:** code/config + **chair-decision** (rotation סודות) — env-catalog SSoT, מקור-config יחיד, de-hardcode, drift מלא, start.sh עמיד
- **סטטוס חלקי:** GAP-57 (creds plaintext, אבטחה CWE-798) **נסגר ב-web/ 2026-06-06** — 3 מופעים ב-`web/paperclip_api.py`/`paperclip_client.py`/`app.py` הומרו ל-`require_paperclip_db_url()` fail-loud. נותרו 2 מופעים בסקריפטים מקומיים (`sync_agents_across_companies.py`, `sync_missing_agent_skills.py`) + GAP-55,56,5862 — לטיפול ב-FU-15 המלא.
--- ---
@@ -122,4 +225,10 @@
GAP-07 כבר chair-confirmed (canonical = הצורה הרשמית שהוקצתה). GAP-07 כבר chair-confirmed (canonical = הצורה הרשמית שהוקצתה).
**רצף מומלץ (תלויות):** FU-1 → FU-2 → FU-3; FU-4 ו-FU-6 במקביל (עצמאיים, Critical); **רצף מומלץ (תלויות):** FU-1 → FU-2 → FU-3; FU-4 ו-FU-6 במקביל (עצמאיים, Critical);
FU-7 אחרי FU-1; FU-5 אחרי FU-2; FU-8 אחרי ייצוב-הספ. FU-7 אחרי FU-1; FU-5 אחרי FU-2; FU-8 אחרי ייצוב-הספ. **(מחזור-1 ✅ הושלם.)**
**מחזור-2 (FU-9..15) — 8 משטחי-האפליקציה:** FU-10 (UI+design-system) ו-FU-15 (deploy/env) עצמאיים —
ניתן במקביל. FU-9 (לקוח-Paperclip) אחרי [X7](X7-paperclip-client-params.md). FU-12 (אחסון) ו-FU-14 (כלי-MCP)
אחרי FU-1. FU-11 (מילוי-שדות) עצמאי. FU-13 (סוכנים+skills) אחרי ייצוב-הספ.
**סיווג:** pure-code — FU-9/10/11/13/14; +data-migration קל — FU-12; +chair-decision — FU-15 (rotation סודות).
priority בפועל — של היו"ר.

72
docs/spec/ui-audit.md Normal file
View File

@@ -0,0 +1,72 @@
# UI-Audit — ביקורת דף-אחר-דף של ה-web-ui
מסמך זה הוא **מפת-הממצאים של ה-frontend** (web-ui), מקביל ל-[gap-audit.md](gap-audit.md) אך ברמת-הדף/הרכיב.
הוא תוצר סריקה word-for-word של 13 הדפים (5 cases-flow + 5 knowledge + 3 admin) + השכבה המשותפת.
כל ממצא נושא: `invariant מופר` (מ-[X6](X6-ui-api-contract.md)/[X8](X8-field-provenance.md)) · `severity` ·
`file:line` · `תיקון`. severity = הערכה הנדסית; priority = היו"ר.
**איך הופק:** סקירת 13 הדפים + `src/lib/api/*` + `src/components/*`, מאומת מול הקוד. התיקון מקובץ ל-**FU-10**.
> **דפים שנסרקו:** dashboard, cases/new, cases/[caseNumber], cases/[caseNumber]/compose, archive ·
> precedents, precedents/[id], training, methodology, missing-precedents · settings, skills, diagnostics.
> **נווט מלא:** כל 13 הדפים נגישים מ-[app-shell.tsx](../../web-ui/src/components/app-shell.tsx) — אין דף-יתום.
---
## 1. מוגדר-לא-נכון (Wrong Definitions)
| ID | כותרת | invariant | severity | file:line | תיקון |
|----|-------|-----------|----------|-----------|-------|
| UI-A1 | `PracticeArea` מוגדר ב-**3 מקומות עם ערכים שונים** — [lib/practice-area.ts:12](../../web-ui/src/lib/practice-area.ts) (`appeals_committee/national_insurance/labor_law` — שאריות מפרויקט אחר!), [lib/api/precedent-library.ts:26](../../web-ui/src/lib/api/precedent-library.ts) (`rishuy_uvniya/...`), ו-[components/precedents/practice-area.ts](../../web-ui/src/components/precedents/practice-area.ts) | UI1, G2 | **CRITICAL** | 3 קבצים | SSoT יחיד; הסרת שאריות national_insurance/labor_law |
| UI-A2 | `key_quote` חסר מטיפוס `Precedent`; גישה דרך `as {key_quote?:string}` | UI1/UI2 | **CRITICAL** | [precedents/[id]/page.tsx:178](../../web-ui/src/app/precedents/%5Bid%5D/page.tsx), [precedent-edit-sheet.tsx:94](../../web-ui/src/components/precedents/precedent-edit-sheet.tsx) | הוספת `key_quote` לטיפוס/OpenAPI |
| UI-A3 | תווית לא-עקבית לאותו ערך: "פיצויים (197)" מול "פיצויים לפי ס' 197" | UI1 | High | [precedents/[id]/page.tsx:26-35](../../web-ui/src/app/precedents/%5Bid%5D/page.tsx) מול practice-area.ts | תווית מ-SSoT יחיד |
| UI-A4 | enum נצרך לא-נגזר-מטיפוס (zod ידחה subtype חדש) | UI1 | High | [schemas/case.ts:78-86](../../web-ui/src/lib/schemas/case.ts) | zod נגזר מ-PracticeArea/AppealSubtype |
| UI-A5 | `expectedOutcomes`/`set_outcome` — אוצר-מילים לא-תואם בק (`rejected/accepted/partial` מול `rejection/.../betterment_levy`) | UI1, G2 | High | [schemas/case.ts:35-41](../../web-ui/src/lib/schemas/case.ts); בק `block_writer.py:442`/`lessons.py:11` | SSoT יחיד ל-enum-תוצאה |
## 2. כפילות (Duplication)
| ID | כותרת | invariant | severity | file:line | תיקון |
|----|-------|-----------|----------|-----------|-------|
| UI-B1 | `CaseStatus` + `STATUS_LABELS` + `STATUS_TONE` ב-3 מקומות | UI1, G2 | **CRITICAL** | [cases.ts:16-33](../../web-ui/src/lib/api/cases.ts), [status-badge.tsx:11-29,77-95](../../web-ui/src/components/cases/status-badge.tsx), [status-changer.tsx:18-24](../../web-ui/src/components/cases/status-changer.tsx) | enum+labels+tones מ-SSoT, ייבוא |
| UI-B2 | `STATUS_LABELS` של מסמכים משוכפל (ולא-שלם) | UI1 | High | [upload-sheet.tsx:39-46](../../web-ui/src/components/documents/upload-sheet.tsx), [documents-panel.tsx:39-46](../../web-ui/src/components/cases/documents-panel.tsx) | ל-`lib/doc-types.ts` |
| UI-B3 | פירמוט-תאריך משוכפל ×5 | UI/§6 | Medium | archive.tsx, case-header.tsx, documents-panel.tsx (+2) | `lib/format.ts` משותף |
| UI-B4 | תוויות practice-area/source-type משוכפלות | UI1 | High | [precedents/[id]/page.tsx:26-35](../../web-ui/src/app/precedents/%5Bid%5D/page.tsx) | ייבוא מ-practice-area.ts |
| UI-B5 | boilerplate העלאת-קבצים (FormData+fetch) ×4 | §6/G2 | Medium | documents.ts, training.ts, exports.ts, missing-precedents.ts | `uploadMultipart<T>()` ב-client.ts |
| UI-B6 | כרטיס-שגיאה משוכפל ×3 | UI4 | Medium | detail/library/missing pages | `<ErrorCard>` משותף |
## 3. מיותר / מת (Redundancy / Dead)
| ID | כותרת | invariant | severity | file:line | תיקון |
|----|-------|-----------|----------|-----------|-------|
| UI-C1 | 3 דפי-פסיקה חופפים (/precedents, /training, /missing-precedents) — גבולות מטושטשים | G2 | Medium | 3 דפים | הגדרת אחריות; שקילת איחוד |
| UI-C2 | כפתור "חלץ מטא-דאטה" שלא מרענן, מפנה ל-CLI ידני | UI5/FP5 | Medium | [precedent-edit-sheet.tsx:130](../../web-ui/src/components/precedents/precedent-edit-sheet.tsx) | auto-refresh/poll על תור-החילוץ |
| UI-C3 | `useCase` refetch כל 5ש' גם במנוחה/בעריכה | UI5 | Low | [cases.ts:150-152](../../web-ui/src/lib/api/cases.ts) | interval מותנה-סטטוס |
| UI-C4 | magic-numbers (intervals) מפוזרים ב-18 מודולים | UI5/§6 | Low | כל `lib/api/*` | `lib/api/query-config.ts` |
## 4. אי-עקביות + הפרת-כללים (Inconsistency / Rule-Violations)
| ID | כותרת | invariant | severity | file:line | תיקון |
|----|-------|-----------|----------|-----------|-------|
| UI-D1 | ~60% endpoints `unknown` → טיפוסים ידניים-סוטים | UI1/UI2 | **CRITICAL** | [cases.ts:1-9](../../web-ui/src/lib/api/cases.ts) (מתועד מפורשות) + בק | Pydantic models + `api:types` |
| UI-D2 | שדות-Opus מוצגים ללא חיווי "חולץ-אוטומטית"; היו"ר לא יודע מה לאמת | UI6/FP1 | High | [precedents/[id]/page.tsx:160-185](../../web-ui/src/app/precedents/%5Bid%5D/page.tsx) | badge "מולא-ע"י-Opus" |
| UI-D3 | אין חיווי `searchable`; הלכות `pending_review` לא מובלטות בדף-הפרט | UI6/FP3 | High | precedents/[id]/page.tsx | חיווי searchable + אזהרת-pending |
| UI-D4 | fallback SSE מסתיר כישלון כ-"completed"; TTL 5ש'↔300ש' | UI4/UI5 | Medium | [documents.ts:226-232](../../web-ui/src/lib/api/documents.ts) | terminal-state מפורש |
| UI-D5 | query-keys לא-עקביים (חלק `.all`, חלק לא; חלק exported, חלק לא) | §6 | Low | agents.ts, feedback.ts (+) | convention אחיד |
| UI-D6 | URLs קשיחים (`PAPERCLIP_BASE`, coolify, frontend) | UI3/ENV3 | Low | [app-shell.tsx:70](../../web-ui/src/components/app-shell.tsx) | env (ראה [X10](X10-deploy-env-secrets.md)) |
---
## 5. סיכום ל-FU-10
- **SSoT ל-enums/תוויות/tones** (UI-A1..A5, UI-B1/B2/B4) — תיקון-השורש של רוב הממצאים.
- **Pydantic models + OpenAPI=SSoT** (UI-D1) — מבטל את הטיפוסים-הידניים.
- **helpers משותפים** (UI-B3/B5/B6, UI-C4) — תאריך, upload, error-card, query-config.
- **שקיפות-מקור-מילוי** (UI-D2/D3) — נגזר מ-[X8](X8-field-provenance.md)/[X6 INV-UI6](X6-ui-api-contract.md).
- **ניקוי redundancy** (UI-C1..C3).
---
## 6. הפניות-אחיות
- [X6-ui-api-contract.md](X6-ui-api-contract.md) — ה-invariants (UI1UI6) שממצאים אלו מפרים.
- [X8-field-provenance.md](X8-field-provenance.md) — מקור-מילוי (בסיס ל-UI-D2/D3).
- [gap-audit.md](gap-audit.md) — GAP-30..34 (התקבילים ברמת-הארכיטקטורה) + FU-10.
- [00-constitution.md](00-constitution.md) — [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים), [G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש), [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai).

View File

@@ -0,0 +1,833 @@
# FU-1 Unified Ingest Path — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Collapse the two parallel ingest functions (`ingest_precedent`, `ingest_internal_decision`) into one canonical pipeline parameterized by an `IntakeSpec`, closing GAP-01/02/04/05.
**Architecture:** New module `services/ingest.py` holds a Template-Method skeleton `ingest_document(spec, ...)`; per-type variation rides on a frozen `IntakeSpec` config object (staging resolver, validate callable, enum_fields data, derive callable, display-name fallback, injected `create_record`). The two existing public functions stay as named entry points that build a spec and delegate. The DB-create functions are NOT merged (FU-2 boundary) — only routed via `spec.create_record`.
**Tech Stack:** Python 3.12, asyncpg, pytest (offline, monkeypatched I/O), local `.venv` at `mcp-server/.venv`.
**Spec:** [docs/superpowers/specs/2026-05-30-fu1-unified-ingest-design.md](../specs/2026-05-30-fu1-unified-ingest-design.md)
**Run tests with:** `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_unified_ingest.py -v`
---
## File Structure
- **Create** `mcp-server/src/legal_mcp/services/ingest.py` — canonical pipeline + `IntakeSpec` + shared helpers (`_stage_file`, `_coerce_date`, `_safe_filename`, `_embed_pages`).
- **Create** `mcp-server/tests/test_unified_ingest.py` — offline behavioral tests.
- **Modify** `mcp-server/src/legal_mcp/services/precedent_library.py``ingest_precedent` becomes a thin wrapper building `_EXTERNAL_SPEC`; delete inline pipeline + moved helpers; keep everything else (search, reextract, process_pending, list, delete, get).
- **Modify** `mcp-server/src/legal_mcp/services/internal_decisions.py``ingest_internal_decision` becomes a thin wrapper building `_INTERNAL_SPEC`; delete inline pipeline + moved helpers; keep migrate_*, enrich_*, search_internal.
**Unchanged callers (verify, don't edit):** `tools/precedent_library.py`, `tools/internal_decisions.py`, `web/` HTTP handlers — they call the two public functions whose signatures are preserved.
---
## Task 1: Failing tests for the unified pipeline
**Files:**
- Test: `mcp-server/tests/test_unified_ingest.py`
- [ ] **Step 1: Write the failing tests**
```python
"""FU-1: unified ingest pipeline tests (offline, all I/O monkeypatched).
Proves both intake types flow through services.ingest.ingest_document and that
the canonical pipeline is symmetric: BOTH metadata and halacha extraction are
queued for BOTH types (GAP-02 regression), enum validation applies to both
(GAP-04), multimodal is gated by flag+PDF not by intake type (GAP-05), and the
external citation guard is preserved.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from uuid import uuid4
import pytest
from legal_mcp import config
from legal_mcp.services import db, embeddings, chunker, extractor
from legal_mcp.services import ingest, precedent_library, internal_decisions
def _run(coro):
return asyncio.run(coro)
class _Chunk:
def __init__(self, i):
self.chunk_index = i
self.content = f"chunk-{i}"
self.section_type = "body"
self.page_number = 1
self.role = "child"
self.local_id = f"c{i}"
self.parent_local_id = None
@pytest.fixture()
def patched(monkeypatch, tmp_path):
"""Patch every I/O boundary. Record queue + create calls."""
calls = {"metadata": [], "halacha": [], "create": [], "chunks": [], "pages": []}
async def _extract_text(path):
return ("full decision text", 2, [0, 100])
def _strip(text):
return text
def _chunk(text, page_offsets=None):
return [_Chunk(0), _Chunk(1)]
async def _embed(texts, input_type="document"):
return [[0.0] * 8 for _ in texts]
async def _store_chunks(cid, dicts):
calls["chunks"].append((cid, len(dicts)))
return len(dicts)
async def _create_external(**kw):
calls["create"].append(("external", kw))
return {"id": uuid4()}
async def _create_internal(**kw):
calls["create"].append(("internal", kw))
return {"id": uuid4()}
async def _req_meta(cid):
calls["metadata"].append(cid)
async def _req_hal(cid):
calls["halacha"].append(cid)
async def _set_status(cid, status):
return None
monkeypatch.setattr(extractor, "extract_text", _extract_text)
monkeypatch.setattr(extractor, "strip_nevo_preamble", _strip)
monkeypatch.setattr(chunker, "chunk_document", _chunk)
monkeypatch.setattr(embeddings, "embed_texts", _embed)
monkeypatch.setattr(db, "store_precedent_chunks", _store_chunks)
monkeypatch.setattr(db, "create_external_case_law", _create_external)
monkeypatch.setattr(db, "create_internal_committee_decision", _create_internal)
monkeypatch.setattr(db, "request_metadata_extraction", _req_meta)
monkeypatch.setattr(db, "request_halacha_extraction", _req_hal)
monkeypatch.setattr(db, "set_case_law_extraction_status", _set_status)
monkeypatch.setattr(db, "set_case_law_halacha_status", _set_status)
# Force flat chunking + multimodal OFF unless a test flips it.
monkeypatch.setattr(config, "PARENT_DOC_RETRIEVAL_ENABLED", False)
monkeypatch.setattr(config, "MULTIMODAL_ENABLED", False)
return calls
def _make_pdf(tmp_path) -> str:
p = tmp_path / "decision.pdf"
p.write_bytes(b"%PDF-1.4 fake")
return str(p)
def test_internal_queues_BOTH_metadata_and_halacha(patched, tmp_path):
"""GAP-02 regression: the internal path must queue metadata too."""
_run(internal_decisions.ingest_internal_decision(
case_number="8046/24", text="decision text", chair_name="דפנה תמיר",
district="ירושלים", practice_area="betterment_levy",
))
assert len(patched["metadata"]) == 1, "internal path must queue metadata (GAP-02)"
assert len(patched["halacha"]) == 1
def test_external_queues_both(patched, tmp_path):
_run(precedent_library.ingest_precedent(
file_path=_make_pdf(tmp_path), citation="עע\"מ 1234/20",
practice_area="rishuy_uvniya", source_type="court_ruling",
))
assert len(patched["metadata"]) == 1
assert len(patched["halacha"]) == 1
def test_both_types_go_through_ingest_document(patched, tmp_path, monkeypatch):
seen = []
real = ingest.ingest_document
async def _spy(spec, **kw):
seen.append(spec.source_kind)
return await real(spec, **kw)
monkeypatch.setattr(ingest, "ingest_document", _spy)
_run(internal_decisions.ingest_internal_decision(
case_number="8046/24", text="t", chair_name="דפנה תמיר", practice_area="betterment_levy"))
_run(precedent_library.ingest_precedent(
file_path=_make_pdf(tmp_path), citation="עע\"מ 1/20", practice_area="rishuy_uvniya"))
assert seen == ["internal_committee", "external_upload"]
def test_enum_validation_rejects_bad_practice_area_internal(patched, tmp_path):
"""GAP-04: internal path must validate enums like the external one."""
with pytest.raises(ValueError, match="practice_area"):
_run(internal_decisions.ingest_internal_decision(
case_number="8046/24", text="t", chair_name="x", practice_area="bogus"))
def test_enum_validation_rejects_bad_practice_area_external(patched, tmp_path):
with pytest.raises(ValueError, match="practice_area"):
_run(precedent_library.ingest_precedent(
file_path=_make_pdf(tmp_path), citation="עע\"מ 1/20", practice_area="bogus"))
def test_external_citation_guard_still_blocks_arar(patched, tmp_path):
with pytest.raises(ValueError, match="ערר"):
_run(precedent_library.ingest_precedent(
file_path=_make_pdf(tmp_path), citation="ערר 1234/24"))
def test_internal_text_path_works_without_file(patched):
out = _run(internal_decisions.ingest_internal_decision(
case_number="8046/24", text="t", chair_name="x", practice_area="betterment_levy"))
assert out["status"] == "completed"
assert out["case_law_id"]
def test_internal_requires_file_or_text(patched):
with pytest.raises(ValueError, match="file_path or text"):
_run(internal_decisions.ingest_internal_decision(
case_number="8046/24", chair_name="x", practice_area="betterment_levy"))
def test_display_name_fallback_uses_canonical_id(patched, tmp_path):
_run(internal_decisions.ingest_internal_decision(
case_number="8046/24", text="t", chair_name="x", practice_area="betterment_levy"))
kind, kw = patched["create"][0]
assert kw["case_name"] == "8046/24", "missing case_name falls back to canonical id"
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_unified_ingest.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'legal_mcp.services.ingest'` (or ImportError).
- [ ] **Step 3: Commit the red tests**
```bash
cd ~/legal-ai
git add mcp-server/tests/test_unified_ingest.py
git commit -m "test(ingest): failing tests for unified pipeline (FU-1)"
```
---
## Task 2: Canonical module `ingest.py` — IntakeSpec + shared helpers
**Files:**
- Create: `mcp-server/src/legal_mcp/services/ingest.py`
- [ ] **Step 1: Write the module header, IntakeSpec, and shared helpers**
```python
"""Canonical ingest pipeline (FU-1).
One pipeline for all sibling-entity intake types (external precedent,
internal committee decision). Per-type variation rides on an ``IntakeSpec``
config object — never a parallel function. See
docs/spec/01-ingest.md and docs/superpowers/specs/2026-05-30-fu1-unified-ingest-design.md.
claude_session rule preserved: this module only QUEUES extraction
(``request_*_extraction`` = pure DB writes). It never imports
halacha_extractor / precedent_metadata_extractor, so it is safe to call
from the FastAPI container where the ``claude`` CLI is unavailable.
"""
from __future__ import annotations
import asyncio
import logging
import re
import shutil
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from typing import Awaitable, Callable
from uuid import UUID, uuid4
from legal_mcp import config
from legal_mcp.services import chunker, db, embeddings, extractor
logger = logging.getLogger(__name__)
ProgressCb = Callable[[str, int, str], Awaitable[None]]
async def _noop_progress(_status: str, _percent: int, _msg: str) -> None:
return None
@dataclass(frozen=True)
class IntakeSpec:
"""Describes everything that varies between intake types."""
source_kind: str
id_field: str
staging_root: Path
staging_subdir: Callable[[dict], str]
validate: Callable[[dict], None]
enum_fields: dict[str, frozenset[str]]
derive: Callable[[dict], dict]
display_name_fallback: str
create_record: Callable[..., Awaitable[dict]]
def _coerce_date(value) -> date | None:
if value is None or value == "":
return None
if isinstance(value, date):
return value
if isinstance(value, str):
try:
return date.fromisoformat(value[:10])
except ValueError:
return None
return None
def _safe_filename(name: str) -> str:
base = Path(name).name
return re.sub(r"[^\w.\-+א-ת ]", "_", base) or f"upload-{uuid4().hex[:8]}"
def _stage_file(src_path: Path, root: Path, subdir: str) -> Path:
dest_dir = root / (subdir or "other")
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / f"{uuid4().hex[:8]}_{_safe_filename(src_path.name)}"
shutil.copy2(src_path, dest)
return dest
def _validate_enums(spec: IntakeSpec, inputs: dict) -> None:
for field_name, allowed in spec.enum_fields.items():
value = inputs.get(field_name, "") or ""
if value not in allowed:
raise ValueError(f"invalid {field_name}: {value!r}")
```
- [ ] **Step 2: Add the multimodal page-embed helper (moved verbatim from precedent_library.py)**
```python
async def _embed_pages(case_law_id: UUID, pdf_path: Path, page_count: int) -> dict:
"""Render PDF pages → embed via voyage-multimodal → store. Non-fatal caller."""
thumb_dir = spec_thumb_dir(case_law_id)
rendered = await asyncio.to_thread(
extractor.render_pages_for_multimodal,
pdf_path, config.MULTIMODAL_DPI, config.MULTIMODAL_THUMB_DPI, thumb_dir,
)
images = [pil for pil, _ in rendered]
thumbs = [t for _, t in rendered]
img_embs = await embeddings.embed_images(images)
page_records = []
for i, (emb, thumb) in enumerate(zip(img_embs, thumbs)):
rel_thumb = None
if thumb is not None:
try:
rel_thumb = str(thumb.relative_to(config.DATA_DIR))
except ValueError:
rel_thumb = str(thumb)
page_records.append({
"page_number": i + 1, "embedding": emb, "image_thumbnail_path": rel_thumb,
})
stored = await db.store_precedent_image_embeddings(
case_law_id, page_records, model_name=config.MULTIMODAL_MODEL,
)
logger.info("Multimodal: stored %d page-image embeddings for case_law %s", stored, case_law_id)
return {"pages_embedded": stored}
def spec_thumb_dir(case_law_id: UUID) -> Path:
"""Thumbnails live under the precedent-library tree regardless of intake type."""
return Path(config.DATA_DIR) / "precedent-library" / "thumbnails" / str(case_law_id)
```
- [ ] **Step 3: Verify the module imports cleanly**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.services import ingest; print(ingest.IntakeSpec.__name__)"`
Expected: prints `IntakeSpec`, no error.
- [ ] **Step 4: Commit**
```bash
cd ~/legal-ai
git add mcp-server/src/legal_mcp/services/ingest.py
git commit -m "feat(ingest): IntakeSpec + shared helpers for canonical pipeline (FU-1)"
```
---
## Task 3: Canonical `ingest_document`
**Files:**
- Modify: `mcp-server/src/legal_mcp/services/ingest.py` (append `ingest_document`)
- [ ] **Step 1: Append the canonical pipeline function**
```python
async def ingest_document(
spec: IntakeSpec,
*,
inputs: dict,
file_path: str | Path | None = None,
text: str | None = None,
document_id: UUID | None = None,
progress: ProgressCb | None = None,
) -> dict:
"""Run the canonical 12-step pipeline for one intake item.
``inputs`` carries the type-specific record fields (citation/case_number,
case_name, court, practice_area, etc.). ``spec`` decides how they are
validated, staged, derived, and which DB-create runs. Returns a dict with
at least: status, case_law_id, chunks.
"""
progress = progress or _noop_progress
# Step 1: input validation (type-specific) + enums (uniform mechanism).
if not file_path and text is None:
raise ValueError("either file_path or text is required")
spec.validate(inputs)
_validate_enums(spec, inputs)
# Step 2: field derivation (identity for external).
inputs = {**inputs, **spec.derive(inputs)}
# Steps 3-5: stage (if file) + extract + strip.
page_count = 0
page_offsets = None
staged: Path | None = None
if file_path:
src = Path(file_path)
if not src.is_file():
raise FileNotFoundError(f"file not found: {src}")
await progress("staging", 5, "מעתיק את הקובץ לאחסון")
staged = _stage_file(src, spec.staging_root, spec.staging_subdir(inputs))
await progress("extracting", 15, "מחלץ טקסט מהקובץ")
try:
raw_text, page_count, page_offsets = await extractor.extract_text(str(staged))
except Exception as e:
await progress("failed", 100, f"כשל בחילוץ טקסט: {e}")
raise
raw_text = extractor.strip_nevo_preamble((raw_text or "")).strip()
else:
raw_text = (text or "").strip()
if not raw_text:
await progress("failed", 100, "לא נמצא טקסט בקובץ")
raise ValueError("no extractable text in file")
# Step 6: DB create (type-specific, routed — get case_law_id).
await progress("storing_metadata", 25, "שומר את הרשומה במסד הנתונים")
display_name = (inputs.get("case_name") or "").strip() or (
inputs.get(spec.display_name_fallback) or ""
).strip()
record = await spec.create_record(
full_text=raw_text,
case_name=display_name,
decision_date=_coerce_date(inputs.get("decision_date")),
document_id=document_id,
**{k: v for k, v in inputs.items()
if k not in {"case_name", "decision_date", "file_path", "text"}},
)
case_law_id = UUID(str(record["id"]))
try:
stored_chunks = await _chunk_embed_store(case_law_id, raw_text, page_offsets, page_count, progress)
# Step 9: multimodal — uniform: flag + PDF + page_count, NOT intake type.
if (config.MULTIMODAL_ENABLED and page_count > 0
and staged is not None and staged.suffix.lower() == ".pdf"):
try:
await progress("embedding_images", 70, f"מטמיע {page_count} עמודי תמונה (multimodal)")
await _embed_pages(case_law_id, staged, page_count)
except Exception as e:
logger.warning("Multimodal embedding failed (non-fatal): %s", e)
# Steps 10-12: queue BOTH extractions (GAP-02 fix) + statuses.
await db.set_case_law_extraction_status(case_law_id, "completed")
await db.set_case_law_halacha_status(case_law_id, "pending")
await db.request_metadata_extraction(case_law_id)
await db.request_halacha_extraction(case_law_id)
await progress("completed", 100,
f"נקלט: {stored_chunks} chunks. חילוץ הלכות ומטא-דאטה ממתינים בתור.")
return {
"status": "completed",
"case_law_id": str(case_law_id),
"chunks": stored_chunks,
"halachot": 0,
"halachot_pending": True,
"metadata_filled": [],
"pages": page_count,
}
except Exception as e:
logger.exception("ingest_document failed (%s): %s", spec.source_kind, e)
await db.set_case_law_extraction_status(case_law_id, "failed")
await progress("failed", 100, f"כשל בעיבוד: {e}")
raise
async def _chunk_embed_store(case_law_id, text, page_offsets, page_count, progress) -> int:
"""Steps 7-8: chunk (hierarchical/flat by flag) → embed children → store."""
if config.PARENT_DOC_RETRIEVAL_ENABLED:
await progress("chunking", 40, f"מחלק את הטקסט ל-chunks היררכיים ({page_count} עמ')")
h_chunks = chunker.chunk_document_hierarchical(text, page_offsets=page_offsets)
if not h_chunks:
return 0
children = [c for c in h_chunks if c.role == "child"]
parents = [c for c in h_chunks if c.role == "parent"]
await progress("embedding", 55, f"מייצר embeddings ל-{len(children)} children ({len(parents)} parents)")
child_vectors = await embeddings.embed_texts([c.content for c in children], input_type="document")
chunk_dicts: list[dict] = []
for p in parents:
chunk_dicts.append({
"role": "parent", "local_id": p.local_id, "parent_local_id": None,
"chunk_index": p.chunk_index, "content": p.content,
"section_type": p.section_type, "page_number": p.page_number, "embedding": None,
})
for c, v in zip(children, child_vectors):
chunk_dicts.append({
"role": "child", "local_id": c.local_id, "parent_local_id": c.parent_local_id,
"chunk_index": c.chunk_index, "content": c.content,
"section_type": c.section_type, "page_number": c.page_number, "embedding": v,
})
counts = await db.store_precedent_chunks_hierarchical(case_law_id, chunk_dicts)
return counts["children"]
else:
await progress("chunking", 40, f"מחלק את הטקסט ל-chunks ({page_count} עמ')")
chunks = chunker.chunk_document(text, page_offsets=page_offsets)
if not chunks:
return 0
await progress("embedding", 55, f"מייצר embeddings ל-{len(chunks)} chunks")
chunk_vectors = await embeddings.embed_texts([c.content for c in chunks], input_type="document")
chunk_dicts = [
{"chunk_index": c.chunk_index, "content": c.content,
"section_type": c.section_type, "page_number": c.page_number, "embedding": v}
for c, v in zip(chunks, chunk_vectors)
]
return await db.store_precedent_chunks(case_law_id, chunk_dicts)
```
- [ ] **Step 2: Verify import**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.services import ingest; print(ingest.ingest_document.__name__)"`
Expected: prints `ingest_document`.
- [ ] **Step 3: Commit**
```bash
cd ~/legal-ai
git add mcp-server/src/legal_mcp/services/ingest.py
git commit -m "feat(ingest): canonical ingest_document pipeline (FU-1)"
```
> **Note on `create_record` kwargs:** the wrappers (Tasks 4-5) build `inputs` so the
> leftover keys after popping `case_name`/`decision_date`/`file_path`/`text` exactly match
> each DB-create's remaining parameters. Verify against the signatures:
> `create_external_case_law(case_number, full_text, court, practice_area, appeal_subtype, subject_tags, summary, headnote, source_type, precedent_level, is_binding, ...)`
> and `create_internal_committee_decision(case_number, full_text, court, chair_name, district, practice_area, appeal_subtype, subject_tags, summary, is_binding, proceeding_type, ...)`.
---
## Task 4: External spec + rewrite `ingest_precedent` as wrapper
**Files:**
- Modify: `mcp-server/src/legal_mcp/services/precedent_library.py`
- [ ] **Step 1: Replace the top-of-file ingest section with a spec + wrapper**
Replace the body of `ingest_precedent` (lines ~88-317) and remove `_stage_file`, `_coerce_date`,
`_safe_filename`, `_embed_precedent_pages`, and the `_VALID_*` constants used only by ingest.
Keep `_VALID_PRACTICE_AREAS`/`_VALID_SOURCE_TYPES` values but move them into the spec. Add:
```python
from legal_mcp.services import ingest
PRECEDENT_LIBRARY_DIR = Path(config.DATA_DIR) / "precedent-library"
_VALID_PRACTICE_AREAS = frozenset({"", "rishuy_uvniya", "betterment_levy", "compensation_197"})
_VALID_SOURCE_TYPES = frozenset({"", "court_ruling", "appeals_committee"})
def _external_validate(inputs: dict) -> None:
citation = (inputs.get("citation") or "").strip()
if not citation:
raise ValueError("citation is required")
if citation.startswith(("ערר ", "ערר(", 'בל"מ ', 'בל"מ(', "ARAR ")):
raise ValueError(
"ציטוט שמתחיל ב-'ערר' או 'בל\"מ' הוא החלטת ועדת ערר. "
"השתמש ב-internal_decision_upload (דורש chair_name + district), "
"לא ב-precedent_library_upload."
)
def _external_staging_subdir(inputs: dict) -> str:
st = inputs.get("source_type") or ""
return st if st in {"court_ruling", "appeals_committee"} else "other"
_EXTERNAL_SPEC = ingest.IntakeSpec(
source_kind="external_upload",
id_field="citation",
staging_root=PRECEDENT_LIBRARY_DIR,
staging_subdir=_external_staging_subdir,
validate=_external_validate,
enum_fields={"practice_area": _VALID_PRACTICE_AREAS, "source_type": _VALID_SOURCE_TYPES},
derive=lambda inputs: {},
display_name_fallback="citation",
create_record=_create_external_record,
)
async def _create_external_record(**kw) -> dict:
"""Adapter: maps canonical inputs (citation) to create_external_case_law(case_number)."""
return await db.create_external_case_law(
case_number=kw["citation"].strip(),
case_name=kw["case_name"],
full_text=kw["full_text"],
court=(kw.get("court") or "").strip(),
decision_date=kw.get("decision_date"),
practice_area=kw.get("practice_area", ""),
appeal_subtype=(kw.get("appeal_subtype") or "").strip(),
subject_tags=list(kw.get("subject_tags") or []),
summary=(kw.get("summary") or "").strip(),
headnote=(kw.get("headnote") or "").strip(),
source_type=kw.get("source_type", ""),
precedent_level=kw.get("precedent_level", ""),
is_binding=kw.get("is_binding", True),
document_id=kw.get("document_id"),
)
async def ingest_precedent(
*,
file_path: str | Path,
citation: str,
case_name: str = "",
court: str = "",
decision_date=None,
source_type: str = "",
precedent_level: str = "",
practice_area: str = "",
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
is_binding: bool = True,
headnote: str = "",
summary: str = "",
document_id: UUID | None = None,
progress: ingest.ProgressCb | None = None,
) -> dict:
"""Ingest one external precedent. Thin wrapper over the canonical pipeline."""
inputs = {
"citation": citation, "case_name": case_name, "court": court,
"decision_date": decision_date, "source_type": source_type,
"precedent_level": precedent_level, "practice_area": practice_area,
"appeal_subtype": appeal_subtype, "subject_tags": subject_tags,
"is_binding": is_binding, "headnote": headnote, "summary": summary,
}
return await ingest.ingest_document(
_EXTERNAL_SPEC, inputs=inputs, file_path=file_path,
document_id=document_id, progress=progress,
)
```
> Define `_create_external_record` ABOVE `_EXTERNAL_SPEC` (Python resolves the name at
> dataclass-construction time). Reorder if needed.
- [ ] **Step 2: Run external-path tests**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_unified_ingest.py -k "external" -v`
Expected: `test_external_queues_both`, `test_enum_validation_rejects_bad_practice_area_external`,
`test_external_citation_guard_still_blocks_arar` PASS.
- [ ] **Step 3: Commit**
```bash
cd ~/legal-ai
git add mcp-server/src/legal_mcp/services/precedent_library.py
git commit -m "refactor(ingest): ingest_precedent delegates to canonical pipeline (FU-1)"
```
---
## Task 5: Internal spec + rewrite `ingest_internal_decision` as wrapper
**Files:**
- Modify: `mcp-server/src/legal_mcp/services/internal_decisions.py`
- [ ] **Step 1: Replace the ingest section with a spec + wrapper**
Remove `_coerce_date`, `_safe_filename`, and the inline pipeline body of
`ingest_internal_decision` (lines ~73-220). Keep `_VALID_DISTRICTS`, `_COURT_TO_DISTRICT`,
`_district_from_court`, and all migrate_*/enrich_*/search_internal functions. Add:
```python
from legal_mcp.services import ingest
INTERNAL_DECISIONS_DIR = Path(config.DATA_DIR) / "internal-decisions"
_VALID_PRACTICE_AREAS = frozenset({"", "rishuy_uvniya", "betterment_levy", "compensation_197"})
_VALID_DISTRICTS = frozenset({"", "ירושלים", "מרכז", "תל אביב", "צפון", "דרום", "ארצי"})
def _internal_validate(inputs: dict) -> None:
if not (inputs.get("case_number") or "").strip():
raise ValueError("case_number is required")
def _internal_derive(inputs: dict) -> dict:
district = (inputs.get("district") or "").strip() or _district_from_court(inputs.get("court") or "")
proc = (inputs.get("proceeding_type") or "").strip() or derive_proceeding_type(
appeal_subtype=inputs.get("appeal_subtype") or "", subject=inputs.get("case_name") or "",
)
return {"district": district, "proceeding_type": proc}
async def _create_internal_record(**kw) -> dict:
return await db.create_internal_committee_decision(
case_number=kw["case_number"].strip(),
case_name=kw["case_name"],
full_text=kw["full_text"],
court=(kw.get("court") or "").strip(),
decision_date=kw.get("decision_date"),
chair_name=(kw.get("chair_name") or "").strip(),
district=kw.get("district", ""),
practice_area=kw.get("practice_area", ""),
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),
document_id=kw.get("document_id"),
proceeding_type=kw.get("proceeding_type") or "ערר",
)
_INTERNAL_SPEC = ingest.IntakeSpec(
source_kind="internal_committee",
id_field="case_number",
staging_root=INTERNAL_DECISIONS_DIR,
staging_subdir=lambda inputs: (inputs.get("district") or "other"),
validate=_internal_validate,
enum_fields={"practice_area": _VALID_PRACTICE_AREAS, "district": _VALID_DISTRICTS},
derive=_internal_derive,
display_name_fallback="case_number",
create_record=_create_internal_record,
)
async def ingest_internal_decision(
*,
case_number: str,
case_name: str = "",
court: str = "",
decision_date=None,
chair_name: str = "",
district: str = "",
practice_area: str = "",
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
summary: str = "",
is_binding: bool = True,
file_path: str | Path | None = None,
text: str | None = None,
document_id: UUID | None = None,
queue_halachot: bool = True, # retained for signature compat; pipeline always queues
proceeding_type: str = "",
) -> dict:
"""Ingest one appeals-committee decision. Thin wrapper over the canonical pipeline."""
inputs = {
"case_number": case_number, "case_name": case_name, "court": court,
"decision_date": decision_date, "chair_name": chair_name, "district": district,
"practice_area": practice_area, "appeal_subtype": appeal_subtype,
"subject_tags": subject_tags, "summary": summary, "is_binding": is_binding,
"proceeding_type": proceeding_type,
}
out = await ingest.ingest_document(
_INTERNAL_SPEC, inputs=inputs, file_path=file_path, text=text,
document_id=document_id,
)
return {"status": out["status"], "case_law_id": out["case_law_id"],
"chunks": out["chunks"], "halachot_pending": True}
```
> `queue_halachot=False` was only used by `migrate_from_style_corpus`. The canonical pipeline
> always queues both (per INV-ING3). Confirm with the user during execution that bulk
> re-migration queueing is acceptable; the migrate path is out of FU-1 scope but calls this
> wrapper. If suppression is still required, that is a follow-up — note it, do not silently drop.
- [ ] **Step 2: Run the full test file**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_unified_ingest.py -v`
Expected: ALL 9 tests PASS — including `test_internal_queues_BOTH_metadata_and_halacha` (GAP-02).
- [ ] **Step 3: Commit**
```bash
cd ~/legal-ai
git add mcp-server/src/legal_mcp/services/internal_decisions.py
git commit -m "refactor(ingest): ingest_internal_decision delegates to canonical pipeline; queue metadata too (GAP-02, FU-1)"
```
---
## Task 6: Dead-code sweep, smoke import, full suite
**Files:**
- Verify: `mcp-server/src/legal_mcp/services/precedent_library.py`, `internal_decisions.py`
- [ ] **Step 1: Confirm no orphaned references to removed helpers**
Run: `cd ~/legal-ai/mcp-server && grep -rn "_embed_precedent_pages\|_stage_file\|_safe_filename\|_coerce_date" src/legal_mcp/services/precedent_library.py src/legal_mcp/services/internal_decisions.py`
Expected: NO matches (all moved to `ingest.py`). If any remain in code paths other than ingest, leave them; if orphaned, delete.
- [ ] **Step 2: Smoke-import every affected module + its callers**
Run:
```bash
cd ~/legal-ai/mcp-server && .venv/bin/python -c "
from legal_mcp.services import ingest, precedent_library, internal_decisions
from legal_mcp.tools import precedent_library as t1, internal_decisions as t2
import inspect
sig_p = inspect.signature(precedent_library.ingest_precedent)
sig_i = inspect.signature(internal_decisions.ingest_internal_decision)
assert 'citation' in sig_p.parameters and 'file_path' in sig_p.parameters
assert 'case_number' in sig_i.parameters and 'text' in sig_i.parameters
print('signatures preserved; imports clean')
"
```
Expected: prints `signatures preserved; imports clean`.
- [ ] **Step 3: Run the entire test suite (no regressions elsewhere)**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/ -q`
Expected: all pre-existing tests still pass + the 9 new ones.
- [ ] **Step 4: Lint the changed files (match repo style)**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m ruff check src/legal_mcp/services/ingest.py src/legal_mcp/services/precedent_library.py src/legal_mcp/services/internal_decisions.py 2>/dev/null || echo "ruff not configured — skip"`
Expected: clean, or "skip".
- [ ] **Step 5: Update TaskMaster #59 → done**
Mark subtasks 59.1-59.4 and task 59 as done via task-master (verify via MCP get_task).
- [ ] **Step 6: Final commit**
```bash
cd ~/legal-ai
git add -A mcp-server/
git commit -m "chore(ingest): dead-code sweep + smoke checks for unified pipeline (FU-1)"
```
---
## Self-Review Notes
- **GAP-01** (single path) → Tasks 2-5. **GAP-02** (metadata queue) → Task 3 step 1 + test `test_internal_queues_BOTH_metadata_and_halacha`. **GAP-04** (enum validation) → `_validate_enums` + tests. **GAP-05** (staging/derive/multimodal/fallback/guard unified) → Task 3 + specs in Tasks 4-5.
- **Boundary preserved:** DB-create functions untouched (routed via `create_record`); no migration.
- **Open execution check:** `queue_halachot=False` suppression in `migrate_from_style_corpus` (Task 5 note) — surface to user, do not silently change bulk-migration behavior.
- **claude_session rule:** `ingest.py` imports only db/chunker/embeddings/extractor — no LLM extractors. Safe for container.

View File

@@ -0,0 +1,613 @@
# FU-2a: Idempotent Ingest + Write-Time Normalization + `searchable` Flag — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make ingest idempotent (`ON CONFLICT` upsert), normalize identifiers at the write boundary (type-aware), and add a materialized `searchable` flag — all forward-only, no identifier migration.
**Architecture:** Pure-code + one schema-additive migration (V21) in `db.py`. The two `create_*_case_law` functions move from app-level SELECT-then-INSERT/UPDATE to atomic `INSERT … ON CONFLICT … DO UPDATE` against the existing V15 partial unique indexes (predicate repeated). A new `_canonical_case_number` normalizes at write for identifier-keyed corpora (internal/cases), not for external (citation is its id). A new `searchable` boolean is recomputed from the completeness contract on ingest/metadata completion; the search-layer filter is gated behind a dry-run.
**Tech Stack:** Python 3.12, asyncpg, PostgreSQL (pgvector) at localhost:5433, pytest offline, local `.venv` at `mcp-server/.venv`.
**Spec:** [docs/superpowers/specs/2026-05-30-fu2a-idempotent-ingest-design.md](../specs/2026-05-30-fu2a-idempotent-ingest-design.md)
**Run tests:** `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_idempotent_ingest.py -v`
**DB smoke (real Postgres):** source `~/.env`, connect to `localhost:5433` db `legal_ai` (see Task 6).
---
## File Structure
- **Modify** `mcp-server/src/legal_mcp/services/db.py`:
- add `_canonical_case_number(s)` (pure) near `_normalize_case_number` (~line 1196).
- add pure `_compute_searchable(row, has_embedded_chunk)` + async `recompute_searchable(...)`.
- add `SCHEMA_V21_SQL` (after V20, ~line 1094) + wire into `_run_schema_migrations` (~line 1119).
- normalize at write in `create_case`, `create_internal_committee_decision` (NOT `create_external_case_law`).
- convert `create_external_case_law` + `create_internal_committee_decision` to `ON CONFLICT … DO UPDATE`.
- **Modify** `mcp-server/src/legal_mcp/services/ingest.py`: call `db.recompute_searchable(case_law_id)` after statuses are set (uniform, both types).
- **Modify** the search layer (`services/hybrid_search.py` and/or `db.py` search functions) — gated `searchable = true` filter (Task 6, only if dry-run is clean).
- **Create** `mcp-server/tests/test_idempotent_ingest.py` — offline tests for the pure pieces + ingest wiring.
**Unchanged:** public signatures of `ingest_precedent`/`ingest_internal_decision` (FU-1) and the DB-create parameter lists. Normalization/upsert live inside the write boundary.
---
## Task 1: Failing tests (pure logic + ingest wiring)
**Files:** Create `mcp-server/tests/test_idempotent_ingest.py`
- [ ] **Step 1: Write the failing tests**
```python
"""FU-2a: idempotent ingest + write-time normalization + searchable flag.
Offline tests for the *pure* pieces (canonical normalization, completeness
predicate) and ingest wiring. The real ON CONFLICT upsert is verified by a
DB smoke test against localhost:5433 (see plan Task 6), since it requires a
live Postgres partial unique index.
"""
from __future__ import annotations
import asyncio
from uuid import uuid4
import pytest
from legal_mcp.services import db, ingest
def _run(coro):
return asyncio.run(coro)
# ── GAP-06: canonical normalization (pure, deterministic) ──────────────
@pytest.mark.parametrize("raw,expected", [
("ערר 8137/24", "8137-24"),
(" עע\"מ 1/20 ", "1-20"),
("8126-03-25", "8126-03-25"), # month segment preserved
("בל\"מ 1010-01-25", "1010-01-25"),
("8047/23", "8047-23"),
])
def test_canonical_case_number(raw, expected):
assert db._canonical_case_number(raw) == expected
def test_canonical_does_not_invent_month():
# No month in input → none added (X1 §1).
assert db._canonical_case_number("8126/24") == "8126-24"
# ── GAP-13: completeness predicate (pure) ──────────────────────────────
def _complete_row():
return {
"case_number": "8047-23", "case_name": "פלוני נ' הוועדה",
"practice_area": "rishuy_uvniya", "source_kind": "internal_committee",
"extraction_status": "completed", "headnote": "תקציר",
"summary": "", "subject_tags": [],
}
def test_compute_searchable_true_when_complete():
assert db._compute_searchable(_complete_row(), has_embedded_chunk=True) is True
def test_compute_searchable_false_without_embedded_chunk():
assert db._compute_searchable(_complete_row(), has_embedded_chunk=False) is False
def test_compute_searchable_false_without_metadata():
row = _complete_row()
row["headnote"] = ""; row["summary"] = ""; row["subject_tags"] = []
assert db._compute_searchable(row, has_embedded_chunk=True) is False
def test_compute_searchable_false_when_extraction_incomplete():
row = _complete_row(); row["extraction_status"] = "pending"
assert db._compute_searchable(row, has_embedded_chunk=True) is False
def test_compute_searchable_false_without_core_fields():
row = _complete_row(); row["practice_area"] = ""
assert db._compute_searchable(row, has_embedded_chunk=True) is False
# ── ingest wires in recompute_searchable (both types) ──────────────────
def test_ingest_calls_recompute_searchable(monkeypatch, tmp_path):
calls = {"recompute": [], "meta": [], "hal": []}
async def _extract_text(path): return ("text", 1, [0])
monkeypatch.setattr(ingest.extractor, "extract_text", _extract_text)
monkeypatch.setattr(ingest.extractor, "strip_nevo_preamble", lambda t: t)
monkeypatch.setattr(ingest.chunker, "chunk_document",
lambda t, page_offsets=None: [type("C", (), {
"chunk_index": 0, "content": "c", "section_type": "b",
"page_number": 1})()])
async def _embed(texts, input_type="document"): return [[0.0] * 8 for _ in texts]
monkeypatch.setattr(ingest.embeddings, "embed_texts", _embed)
async def _store(cid, dicts): return len(dicts)
monkeypatch.setattr(ingest.db, "store_precedent_chunks", _store)
async def _create_internal(**kw): return {"id": uuid4()}
monkeypatch.setattr(ingest.db, "create_internal_committee_decision", _create_internal)
async def _noop(*a, **k): return None
monkeypatch.setattr(ingest.db, "set_case_law_extraction_status", _noop)
monkeypatch.setattr(ingest.db, "set_case_law_halacha_status", _noop)
monkeypatch.setattr(ingest.db, "request_metadata_extraction",
lambda cid: calls["meta"].append(cid) or _noop())
monkeypatch.setattr(ingest.db, "request_halacha_extraction",
lambda cid: calls["hal"].append(cid) or _noop())
async def _recompute(cid): calls["recompute"].append(cid)
monkeypatch.setattr(ingest.db, "recompute_searchable", _recompute)
monkeypatch.setattr(ingest.config, "PARENT_DOC_RETRIEVAL_ENABLED", False)
monkeypatch.setattr(ingest.config, "MULTIMODAL_ENABLED", False)
from legal_mcp.services import internal_decisions
_run(internal_decisions.ingest_internal_decision(
case_number="8047/23", text="t", chair_name="x", practice_area="rishuy_uvniya"))
assert len(calls["recompute"]) == 1, "ingest must recompute searchable after success"
```
- [ ] **Step 2: Run to verify failure**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_idempotent_ingest.py -v`
Expected: FAIL — `AttributeError: module 'legal_mcp.services.db' has no attribute '_canonical_case_number'` (and `_compute_searchable`, `recompute_searchable`).
- [ ] **Step 3: Commit**
```bash
cd ~/legal-ai
git add mcp-server/tests/test_idempotent_ingest.py
git commit -m "test(ingest): failing tests for idempotent ingest + searchable (FU-2a)"
```
---
## Task 2: `_canonical_case_number` + write-time normalization
**Files:** Modify `mcp-server/src/legal_mcp/services/db.py`
- [ ] **Step 1: Add `_canonical_case_number` next to `_normalize_case_number` (~line 1212)**
```python
def _canonical_case_number(s: str) -> str:
"""Canonical write-time form per X1 §1: trim · prefix-strip · '/''-'.
Deterministic and format-only — does NOT add or remove a month segment.
Used at the write boundary for identifier-keyed corpora (internal
committee decisions, active cases). NOT for external precedents, whose
canonical identifier is the full citation.
"""
s = (s or "").strip()
m = re.search(r"\d", s)
if m:
s = s[m.start():]
return s.strip().replace("/", "-")
```
- [ ] **Step 2: Normalize at write in `create_case` (~line 1158)**
Change the INSERT's `case_number` binding to normalized form. Replace `case_id, case_number, title,` with:
```python
case_id, _canonical_case_number(case_number), title,
```
- [ ] **Step 3: Normalize at write in `create_internal_committee_decision` (top of function body, ~line 2649)**
Immediately after `pool = await get_pool()`, add:
```python
case_number = _canonical_case_number(case_number)
```
(Do NOT add this to `create_external_case_law` — external keeps its citation verbatim; that function only `.strip()`s, which the caller adapter already does.)
- [ ] **Step 4: Run normalization tests**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_idempotent_ingest.py -k "canonical" -v`
Expected: `test_canonical_case_number` (5 cases) + `test_canonical_does_not_invent_month` PASS.
- [ ] **Step 5: Commit**
```bash
cd ~/legal-ai
git add mcp-server/src/legal_mcp/services/db.py
git commit -m "feat(ingest): write-time canonical case_number normalization (GAP-06, FU-2a)"
```
---
## Task 3: Convert both create functions to `ON CONFLICT DO UPDATE`
**Files:** Modify `mcp-server/src/legal_mcp/services/db.py`
- [ ] **Step 1: Replace `create_external_case_law` body (lines 2566-2624, from `pool = await get_pool()` to `return _row_to_case_law(row)`)**
```python
pool = await get_pool()
tags_json = json.dumps(subject_tags or [], 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'.
# The predicate is repeated in ON CONFLICT (required for partial indexes).
# This also subsumes the old cited_only→external_upload promotion: a
# cited_only row with the same case_number conflicts and is promoted by
# DO UPDATE. Scoped to the external partial index, so an internal row with
# the same number is NOT touched (the old SELECT-without-source_kind could
# wrongly promote it).
row = await conn.fetchrow(
"""
INSERT INTO case_law (
case_number, case_name, court, date, subject_tags,
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
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
'external_upload', $10, 'processing', 'pending',
$11, $12, $13, $14, $15, $16
)
ON CONFLICT (case_number) WHERE source_kind <> 'internal_committee'
DO UPDATE SET
case_name = EXCLUDED.case_name,
court = COALESCE(NULLIF(EXCLUDED.court, ''), case_law.court),
date = COALESCE(EXCLUDED.date, case_law.date),
practice_area = EXCLUDED.practice_area,
appeal_subtype = EXCLUDED.appeal_subtype,
subject_tags = EXCLUDED.subject_tags,
summary = COALESCE(NULLIF(EXCLUDED.summary, ''), case_law.summary),
headnote = EXCLUDED.headnote,
key_quote = COALESCE(NULLIF(EXCLUDED.key_quote, ''), case_law.key_quote),
full_text = EXCLUDED.full_text,
source_url = COALESCE(NULLIF(EXCLUDED.source_url, ''), case_law.source_url),
source_type = EXCLUDED.source_type,
precedent_level = EXCLUDED.precedent_level,
is_binding = EXCLUDED.is_binding,
document_id = COALESCE(EXCLUDED.document_id, case_law.document_id),
source_kind = 'external_upload',
extraction_status = 'processing',
halacha_extraction_status = 'pending'
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,
)
return _row_to_case_law(row)
```
- [ ] **Step 2: Replace `create_internal_committee_decision` body (lines 2649-2708)**
```python
pool = await get_pool()
case_number = _canonical_case_number(case_number)
tags_json = json.dumps(subject_tags or [], 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)
# WHERE source_kind = 'internal_committee'. Predicate repeated for the
# partial index. Replaces the old SELECT-then-INSERT/UPDATE (race-prone).
row = await conn.fetchrow(
"""
INSERT INTO case_law (
case_number, case_name, court, date, chair_name, district,
subject_tags, summary, full_text,
source_kind, source_type, document_id,
extraction_status, halacha_extraction_status,
practice_area, appeal_subtype, is_binding, proceeding_type
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9,
'internal_committee', 'appeals_committee', $10,
'processing', 'pending',
$11, $12, $13, $14
)
ON CONFLICT (case_number, proceeding_type)
WHERE source_kind = 'internal_committee'
DO UPDATE SET
case_name = EXCLUDED.case_name,
court = COALESCE(NULLIF(EXCLUDED.court, ''), case_law.court),
date = COALESCE(EXCLUDED.date, case_law.date),
chair_name = COALESCE(NULLIF(EXCLUDED.chair_name, ''), case_law.chair_name),
district = COALESCE(NULLIF(EXCLUDED.district, ''), case_law.district),
practice_area = EXCLUDED.practice_area,
appeal_subtype = EXCLUDED.appeal_subtype,
subject_tags = EXCLUDED.subject_tags,
summary = COALESCE(NULLIF(EXCLUDED.summary, ''), case_law.summary),
full_text = EXCLUDED.full_text,
source_type = 'appeals_committee',
source_kind = 'internal_committee',
is_binding = EXCLUDED.is_binding,
document_id = COALESCE(EXCLUDED.document_id, case_law.document_id),
extraction_status = 'processing',
halacha_extraction_status = 'pending'
RETURNING *
""",
case_number, case_name, court, decision_date, chair_name, district,
tags_json, summary, full_text,
document_id, practice_area, appeal_subtype, is_binding,
proceeding_type,
)
return _row_to_case_law(row)
```
- [ ] **Step 3: Verify import + no syntax error**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.services import db; print('db imports')"`
Expected: prints `db imports`.
- [ ] **Step 4: Commit**
```bash
cd ~/legal-ai
git add mcp-server/src/legal_mcp/services/db.py
git commit -m "feat(ingest): atomic ON CONFLICT upsert in create_*_case_law (GAP-03, FU-2a)"
```
---
## Task 4: V21 migration — `searchable` column + recompute
**Files:** Modify `mcp-server/src/legal_mcp/services/db.py`
- [ ] **Step 1: Add `SCHEMA_V21_SQL` after `SCHEMA_V20_SQL` (~line 1094)**
```python
# ── V21: explicit `searchable` flag (GAP-13 / INV-DM1) ─────────────
# Materialized completeness flag — a case_law row is exposed to search only
# when it satisfies the completeness contract (02-data-model §2a). Recomputed
# on ingest/metadata completion via recompute_searchable(); not inferred at
# query time. Default false so a freshly-inserted row is excluded until proven
# complete. Health-check surfaces count(*) FILTER (WHERE NOT searchable).
SCHEMA_V21_SQL = """
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS searchable boolean NOT NULL DEFAULT false;
CREATE INDEX IF NOT EXISTS idx_case_law_searchable ON case_law (searchable);
"""
```
- [ ] **Step 2: Wire V21 into `_run_schema_migrations` (~line 1119) and bump the log line**
After `await conn.execute(SCHEMA_V20_SQL)` add:
```python
await conn.execute(SCHEMA_V21_SQL)
```
Change the log line `"Database schema initialized (v1-v20)"``"Database schema initialized (v1-v21)"`.
- [ ] **Step 3: Add `_compute_searchable` (pure) + `recompute_searchable` (async) near the case_law helpers (after `create_internal_committee_decision`, ~line 2709)**
```python
def _compute_searchable(row: dict, has_embedded_chunk: bool) -> bool:
"""Completeness contract (INV-DM1 / 02-data-model §2a).
A row is searchable IFF: canonical id present · case_name/practice_area/
source_kind present · ≥1 chunk with a non-null embedding · extraction
completed · metadata non-empty (≥1 of headnote/summary/subject_tags).
Pure — `has_embedded_chunk` is supplied by the caller (cross-table check).
"""
if not has_embedded_chunk:
return False
if (row.get("extraction_status") or "") != "completed":
return False
if not (row.get("case_number") or "").strip():
return False
if not (row.get("case_name") or "").strip():
return False
if not (row.get("practice_area") or "").strip():
return False
if not (row.get("source_kind") or "").strip():
return False
tags = row.get("subject_tags") or []
has_meta = bool((row.get("headnote") or "").strip()) \
or bool((row.get("summary") or "").strip()) \
or (len(tags) > 0)
return has_meta
async def recompute_searchable(case_law_id: "UUID | str | None" = None) -> int:
"""Recompute and persist the `searchable` flag. Idempotent / reversible.
If case_law_id is None, recompute ALL rows (used by the V21 backfill and
the dry-run). Returns the number of rows now marked searchable=true.
"""
pool = await get_pool()
async with pool.acquire() as conn:
if case_law_id is not None:
cid = case_law_id if isinstance(case_law_id, UUID) else UUID(str(case_law_id))
rows = await conn.fetch(
"SELECT * FROM case_law WHERE id = $1", cid)
else:
rows = await conn.fetch("SELECT * FROM case_law")
n_true = 0
for r in rows:
row = dict(r)
# subject_tags is stored jsonb; _row_to_case_law parses it, but here
# we read raw — normalize to a list length check.
tags = row.get("subject_tags")
if isinstance(tags, str):
try:
tags = json.loads(tags)
except (ValueError, TypeError):
tags = []
row["subject_tags"] = tags or []
has_chunk = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM precedent_chunks "
"WHERE case_law_id = $1 AND embedding IS NOT NULL)", row["id"])
val = _compute_searchable(row, bool(has_chunk))
await conn.execute(
"UPDATE case_law SET searchable = $2 WHERE id = $1", row["id"], val)
if val:
n_true += 1
return n_true
```
- [ ] **Step 4: Run the completeness-predicate tests**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_idempotent_ingest.py -k "searchable and not ingest" -v`
Expected: all `test_compute_searchable_*` PASS.
- [ ] **Step 5: Commit**
```bash
cd ~/legal-ai
git add mcp-server/src/legal_mcp/services/db.py
git commit -m "feat(data-model): V21 searchable flag + recompute_searchable (GAP-13, FU-2a)"
```
---
## Task 5: Wire `recompute_searchable` into ingest
**Files:** Modify `mcp-server/src/legal_mcp/services/ingest.py`
- [ ] **Step 1: Call recompute after statuses are set in `ingest_document`**
In `ingest.py`, find the block (added by FU-1) that sets statuses + queues extraction:
```python
await db.set_case_law_extraction_status(case_law_id, "completed")
await db.set_case_law_halacha_status(case_law_id, "pending")
await db.request_metadata_extraction(case_law_id)
await db.request_halacha_extraction(case_law_id)
```
Immediately AFTER `request_halacha_extraction`, add:
```python
await db.recompute_searchable(case_law_id)
```
> Rationale: at this point chunks+embeddings are stored and extraction_status is
> completed, so the completeness predicate is meaningful. Metadata may still be
> pending (queued), so the row may compute searchable=false until metadata fills —
> the metadata extractor also calls recompute (Task 5 Step 2).
- [ ] **Step 2: Call recompute after metadata extraction fills fields**
In `mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py`, find `extract_and_apply`'s success path (where it persists the filled metadata fields). After the DB update that writes the extracted metadata, add a call:
```python
await db.recompute_searchable(case_law_id)
```
(Import `db` is already present in that module; if not, add `from legal_mcp.services import db`. Confirm by reading the file's imports first.)
- [ ] **Step 3: Run the ingest-wiring test**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_idempotent_ingest.py -k "ingest_calls_recompute" -v`
Expected: `test_ingest_calls_recompute_searchable` PASS.
- [ ] **Step 4: Commit**
```bash
cd ~/legal-ai
git add mcp-server/src/legal_mcp/services/ingest.py mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py
git commit -m "feat(ingest): recompute searchable on ingest + metadata completion (GAP-13, FU-2a)"
```
---
## Task 6: DB smoke + dry-run + GATED search filter
**Files:** Modify search layer ONLY if dry-run is clean (see Step 4).
- [ ] **Step 1: Apply the V21 migration to the local DB and smoke-test upsert idempotency**
Run (sources env, exercises real Postgres):
```bash
cd ~/legal-ai && set -a && source ~/.env && set +a
cd mcp-server && .venv/bin/python -c "
import asyncio, uuid
from legal_mcp.services import db
async def main():
await db.get_pool() # runs migrations incl V21
# idempotent internal upsert: same (case_number, proceeding_type) twice
cn = 'ZZ9999/24'
r1 = await db.create_internal_committee_decision(case_number=cn, case_name='t', full_text='x', practice_area='rishuy_uvniya')
r2 = await db.create_internal_committee_decision(case_number=cn, case_name='t2', full_text='x2', practice_area='rishuy_uvniya')
assert r1['id'] == r2['id'], 'upsert must update, not duplicate'
# cleanup
pool = await db.get_pool()
async with pool.acquire() as c:
await c.execute(\"DELETE FROM case_law WHERE case_number = 'ZZ9999-24'\")
print('UPSERT IDEMPOTENT OK; normalized stored as ZZ9999-24')
asyncio.run(main())
"
```
Expected: `UPSERT IDEMPOTENT OK` and no duplicate. (Note: `ZZ9999/24` normalizes to `ZZ9999-24` — confirms write-time normalization too.)
- [ ] **Step 2: Backfill the `searchable` flag (recompute, reversible)**
```bash
cd ~/legal-ai && set -a && source ~/.env && set +a
cd mcp-server && .venv/bin/python -c "
import asyncio
from legal_mcp.services import db
async def main():
n = await db.recompute_searchable()
print('recompute_searchable: rows now searchable =', n)
asyncio.run(main())
"
```
- [ ] **Step 3: Dry-run report — which rows would drop from search if the filter is enabled**
```bash
cd ~/legal-ai && set -a && source ~/.env && set +a
PGPASSWORD="$POSTGRES_PASSWORD" psql "host=$POSTGRES_HOST port=$POSTGRES_PORT dbname=$POSTGRES_DB user=$POSTGRES_USER" -c "
SELECT source_kind,
count(*) AS total,
count(*) FILTER (WHERE NOT searchable) AS would_drop
FROM case_law GROUP BY source_kind ORDER BY source_kind;"
```
Report the table to the controller. **Decision gate:** if `would_drop` includes legitimate, currently-findable precedents (e.g. external_upload / internal_committee rows that users rely on), DO NOT enable the search filter in Step 4 — stop and report; the filter waits for FU-2b. If `would_drop` is only genuinely-incomplete rows, proceed.
- [ ] **Step 4: (GATED) Enable `searchable = true` filter in the search layer**
ONLY if Step 3 is clean. Read `mcp-server/src/legal_mcp/services/hybrid_search.py` to find the `case_law` WHERE clauses in `search_precedent_library_hybrid` / `search_documents_hybrid`. Add `AND cl.searchable = true` (alias as used in that query) to the case_law-joined precedent search paths. Add a focused test asserting a non-searchable row is excluded (monkeypatch or DB smoke). If deferred, write a one-line note in the spec §7 that the filter is pending FU-2b and skip.
- [ ] **Step 5: Add health-check visibility**
Find the health-check endpoint/function (search `def health` / `processing_status` in `web/app.py` or `tools/`). Add a field `non_searchable_case_law = SELECT count(*) FROM case_law WHERE NOT searchable`. Keep it a single cheap COUNT.
- [ ] **Step 6: Commit**
```bash
cd ~/legal-ai
git add -A mcp-server/ web/
git commit -m "feat(retrieval): gated searchable filter + health-check visibility (GAP-13, FU-2a)"
```
---
## Task 7: Full suite + smoke + lint + TaskMaster
- [ ] **Step 1: Full test suite**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/ -q`
Expected: all pass (the FU-1 77 + new FU-2a tests). Report the summary line.
- [ ] **Step 2: Smoke-import**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.services import db, ingest, precedent_library, internal_decisions; print('clean')"`
Expected: `clean`.
- [ ] **Step 3: Lint changed files (if ruff available)**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m ruff check src/legal_mcp/services/db.py src/legal_mcp/services/ingest.py 2>/dev/null; echo "exit=$?"`
Expected: clean or "ruff not available".
- [ ] **Step 4: Mark TaskMaster #60 + subtasks done**
Controller handles this (edit `.taskmaster/tasks/tasks.json`, verify via MCP get_task). Subtasks 60.1 (GAP-03), 60.2 (GAP-06), 60.5 (GAP-13).
---
## Self-Review Notes
- **GAP-03** → Task 3 (ON CONFLICT both functions). **GAP-06** → Task 2 (`_canonical_case_number` + write-time, type-aware). **GAP-13** → Tasks 4-5 (column + recompute + wiring) and gated Task 6 (filter).
- **No identifier migration** — FU-2b (#67) owns GAP-07/08. The V21 backfill only sets a derived, reversible flag.
- **Gated search filter** (Task 6 Step 3-4): the behavior-visible change is contingent on a clean dry-run; otherwise deferred. Surface the dry-run table to the user.
- **Offline-test limitation:** ON CONFLICT needs real Postgres → verified by Task 6 Step 1 smoke; offline tests cover the pure logic (normalize, completeness) and ingest wiring.
- **Type-consistency:** `_canonical_case_number`, `_compute_searchable(row, has_embedded_chunk)`, `recompute_searchable(case_law_id=None)` — names used identically in tests (Task 1) and impl (Tasks 2,4).

View File

@@ -0,0 +1,421 @@
# FU-3: Re-Index on Content Change — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Detect content changes via a SHA-256 `content_hash`, expose a standalone `reindex_case_law` that re-embeds from stored `full_text` (no re-OCR, no file needed), and surface embedding-drift in the health-check — enforcing INV-G6 where embeddings can't be DB-GENERATED.
**Architecture:** Two additive `case_law` columns (V23): `content_hash` (hash of current full_text, written at the create boundary) and `indexed_hash` (hash the current chunks/embeddings were built from, set by `mark_indexed` after a successful store). Stale ⇔ `content_hash IS DISTINCT FROM indexed_hash`. `reindex_case_law` reuses the canonical `_chunk_embed_store` over stored text. Backfill only computes hashes (no re-embed — existing rows keep their vectors).
**Tech Stack:** Python 3.12, asyncpg, PostgreSQL@localhost:5433, voyage embeddings API, pytest offline, `.venv` at `mcp-server/.venv`.
**Spec:** [docs/superpowers/specs/2026-05-30-fu3-reindex-on-change-design.md](../specs/2026-05-30-fu3-reindex-on-change-design.md)
**Run tests:** `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_reindex_on_change.py -v`
---
## File Structure
- **Modify** `mcp-server/src/legal_mcp/services/db.py``_content_hash`; V23 migration; `content_hash` in `create_external_case_law`/`create_internal_committee_decision`/`create_case`; `mark_indexed`; `list_stale_case_law`; `recompute_content_hashes`.
- **Modify** `mcp-server/src/legal_mcp/services/ingest.py``reindex_case_law`; call `mark_indexed` after `_chunk_embed_store` in `ingest_document`.
- **Modify** `mcp-server/src/legal_mcp/services/metrics.py``stale_embedding_case_law` count.
- **Modify** `mcp-server/src/legal_mcp/tools/precedent_library.py` + `server.py` — MCP tool `precedent_reindex`.
- **Create** `mcp-server/tests/test_reindex_on_change.py`.
---
## Task 1: Failing tests
**Files:** Create `mcp-server/tests/test_reindex_on_change.py`
- [ ] **Step 1: Write the failing tests**
```python
"""FU-3: re-index on content change (offline, monkeypatched I/O)."""
from __future__ import annotations
import asyncio
from uuid import uuid4
import pytest
from legal_mcp.services import db, ingest
def _run(coro):
return asyncio.run(coro)
# ── content_hash is deterministic ──────────────────────────────────────
def test_content_hash_deterministic():
h1 = db._content_hash("פסק דין כלשהו")
h2 = db._content_hash("פסק דין כלשהו")
assert h1 == h2 and len(h1) == 64 # sha256 hex
def test_content_hash_empty_is_blank():
assert db._content_hash("") == ""
assert db._content_hash(None) == ""
def test_content_hash_changes_with_text():
assert db._content_hash("alpha") != db._content_hash("beta")
# ── mark_indexed copies content_hash → indexed_hash ─────────────────────
def test_mark_indexed_executes_update(monkeypatch):
seen = {}
class _Conn:
async def execute(self, q, *a):
seen["q"] = q; seen["args"] = a
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
class _Pool:
def acquire(self): return _Conn()
async def _pool(): return _Pool()
monkeypatch.setattr(db, "get_pool", _pool)
cid = uuid4()
_run(db.mark_indexed(cid))
assert "indexed_hash" in seen["q"] and "content_hash" in seen["q"]
assert seen["args"][0] == cid
# ── reindex_case_law re-embeds from stored text, no extractor/LLM ───────
def test_reindex_case_law_uses_stored_text(monkeypatch):
cid = uuid4()
calls = {"chunk_embed_store": [], "mark_indexed": []}
async def _get_case_law(x):
return {"id": cid, "full_text": "טקסט שמור של ההחלטה"}
monkeypatch.setattr(ingest.db, "get_case_law", _get_case_law)
async def _ces(case_law_id, text, page_offsets, page_count, progress):
calls["chunk_embed_store"].append((case_law_id, text))
return 5
monkeypatch.setattr(ingest, "_chunk_embed_store", _ces)
async def _mark(x):
calls["mark_indexed"].append(x)
monkeypatch.setattr(ingest.db, "mark_indexed", _mark)
out = _run(ingest.reindex_case_law(cid))
assert out["chunks"] == 5 and out["reindexed"] is True
assert calls["chunk_embed_store"][0][1] == "טקסט שמור של ההחלטה"
assert calls["mark_indexed"] == [cid]
def test_reindex_case_law_missing_row_raises(monkeypatch):
async def _none(x): return None
monkeypatch.setattr(ingest.db, "get_case_law", _none)
with pytest.raises(ValueError, match="not found"):
_run(ingest.reindex_case_law(uuid4()))
```
- [ ] **Step 2: Run to verify failure**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_reindex_on_change.py -v`
Expected: FAIL — `AttributeError: ... no attribute '_content_hash'` / `mark_indexed` / `reindex_case_law`.
- [ ] **Step 3: Commit**
```bash
cd ~/legal-ai
git add mcp-server/tests/test_reindex_on_change.py
git commit -m "test(reindex): failing tests for content-hash re-index (FU-3)"
```
---
## Task 2: V23 + hash helpers + content_hash at write
**Files:** Modify `mcp-server/src/legal_mcp/services/db.py`
- [ ] **Step 1: Ensure `hashlib` import + add `_content_hash`**
READ the top imports of db.py. If `import hashlib` is absent, add it. Add this helper near `_canonical_case_number` (~line 1227):
```python
def _content_hash(text: str) -> str:
"""SHA-256 hex of the text — deterministic content fingerprint (FU-3/GAP-09).
Empty/None → "" (a row with no text has no content fingerprint).
"""
if not text:
return ""
return hashlib.sha256(text.encode("utf-8")).hexdigest()
```
- [ ] **Step 2: Add `SCHEMA_V23_SQL` after `SCHEMA_V22_SQL` + wire it**
READ near `SCHEMA_V22_SQL` and `_run_schema_migrations`. Add after the V22 block:
```python
# ── V23: case_law content/indexed hashes — re-index on content change (GAP-09) ──
# content_hash = SHA-256 of current full_text (written at the create boundary).
# indexed_hash = the content_hash the CURRENT chunks/embeddings were built from
# (set by mark_indexed after a successful store). Stale ⇔ content_hash IS
# DISTINCT FROM indexed_hash. embedding can't be a GENERATED column (needs an
# API call), so freshness is enforced by detection + reindex_case_law + health-check.
SCHEMA_V23_SQL = """
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS content_hash text NOT NULL DEFAULT '';
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS indexed_hash text;
"""
```
After `await conn.execute(SCHEMA_V22_SQL)` add `await conn.execute(SCHEMA_V23_SQL)`; bump the log line to `v1-v23`.
- [ ] **Step 3: Write `content_hash` in the two case_law create functions**
In `create_external_case_law` and `create_internal_committee_decision` (db.py ~2610-2760), the `INSERT ... ON CONFLICT ... DO UPDATE` was built in FU-2a. For EACH:
1. Add `content_hash` to the INSERT column list (append after the last data column, before the closing `)`).
2. Add a matching `$N` placeholder in VALUES (next number after the current max).
3. Add `content_hash = EXCLUDED.content_hash` to the `DO UPDATE SET` clause.
4. Append `_content_hash(full_text)` as the LAST positional arg in the `conn.fetchrow(..., <args>)` call (matching the new `$N`).
CRITICAL: the new placeholder number must equal `(current highest $N) + 1`, and the new arg must be appended LAST in the args tuple in the SAME order. Read the current SQL + args carefully and count. After editing, verify param count = placeholder count (Step 5 import check will catch a gross mismatch; the DB smoke in Task 6 confirms at runtime).
- [ ] **Step 4: Write `content_hash` in `create_case`**
In `create_case` (db.py ~1130-1165), the INSERT into `cases` — add `content_hash`? NO: `cases` is a different table (active appeal cases), and FU-3's scope is `case_law` (the corpus). Do NOT alter `create_case` or the `cases` table here. (The spec §3 mentioned create_case for normalization in FU-2a; for FU-3 hashing, scope is `case_law` only. Skip create_case.)
- [ ] **Step 5: Add `mark_indexed`, `list_stale_case_law`, `recompute_content_hashes` (after `get_case_law`, ~line 2547)**
```python
async def mark_indexed(case_law_id: UUID) -> None:
"""Mark a case_law row's embeddings as built from its current content (FU-3).
Sets indexed_hash := content_hash. Call AFTER a successful chunk+embed+store.
"""
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE case_law SET indexed_hash = content_hash WHERE id = $1",
case_law_id,
)
async def list_stale_case_law(limit: int = 500) -> list[dict]:
"""case_law rows whose embeddings are stale vs current content (GAP-09/INV-G6)."""
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""SELECT id, case_number, source_kind
FROM case_law
WHERE coalesce(full_text, '') <> ''
AND content_hash IS DISTINCT FROM indexed_hash
ORDER BY created_at LIMIT $1""",
limit,
)
return [dict(r) for r in rows]
async def recompute_content_hashes() -> dict:
"""Backfill (FU-3): set content_hash for all rows; set indexed_hash=content_hash
only where chunks already exist (those are already embedded). Rows with text but
no chunks get indexed_hash=NULL → surface as stale. Hash-only; no re-embed."""
pool = await get_pool()
updated = 0
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT id, full_text FROM case_law")
for r in rows:
ch = _content_hash(r["full_text"] or "")
has_chunks = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM precedent_chunks WHERE case_law_id = $1)",
r["id"])
await conn.execute(
"UPDATE case_law SET content_hash = $2, "
"indexed_hash = CASE WHEN $3 THEN $2 ELSE indexed_hash END WHERE id = $1",
r["id"], ch, bool(has_chunks))
updated += 1
return {"updated": updated}
```
- [ ] **Step 6: Run the helper tests**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_reindex_on_change.py -k "content_hash or mark_indexed" -v`
Expected: `test_content_hash_*` (3) + `test_mark_indexed_executes_update` PASS.
- [ ] **Step 7: Commit**
```bash
cd ~/legal-ai
git add mcp-server/src/legal_mcp/services/db.py
git commit -m "feat(reindex): V23 content/indexed hashes + helpers + write content_hash (GAP-09, FU-3)"
```
---
## Task 3: `reindex_case_law` + mark_indexed on ingest
**Files:** Modify `mcp-server/src/legal_mcp/services/ingest.py`
- [ ] **Step 1: Call `mark_indexed` after successful chunk+embed+store in `ingest_document`**
READ `ingest_document` — find the line `stored_chunks = await _chunk_embed_store(case_law_id, raw_text, page_offsets, page_count, progress)` (~line 184). Immediately AFTER it, add:
```python
await db.mark_indexed(case_law_id)
```
(After a fresh ingest, chunks were just built from the current text → indexed_hash = content_hash.)
- [ ] **Step 2: Add `reindex_case_law` (append to ingest.py)**
```python
async def reindex_case_law(
case_law_id: "UUID | str",
progress: ProgressCb | None = None,
) -> dict:
"""Re-chunk + re-embed an existing case_law row from its STORED full_text (GAP-09).
No re-extract / no re-OCR (uses the stored text — see feedback_no_reocr_retrofit)
and no LLM/CLI (only chunker + voyage embeddings), so it is safe to run anywhere.
Idempotent: store_precedent_chunks(_hierarchical) is DELETE-then-INSERT.
"""
progress = progress or _noop_progress
cid = case_law_id if isinstance(case_law_id, UUID) else UUID(str(case_law_id))
row = await db.get_case_law(cid)
if not row:
raise ValueError(f"case_law not found: {cid}")
text = (row.get("full_text") or "").strip()
if not text:
raise ValueError("case_law has no stored full_text to re-index")
stored = await _chunk_embed_store(cid, text, None, 0, progress)
await db.mark_indexed(cid)
await progress("completed", 100, f"הוטמע מחדש: {stored} chunks")
return {"status": "completed", "case_law_id": str(cid), "chunks": stored, "reindexed": True}
```
(`UUID`, `db`, `_chunk_embed_store`, `_noop_progress`, `ProgressCb` are already in ingest.py.)
- [ ] **Step 3: Run reindex tests**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_reindex_on_change.py -v`
Expected: ALL pass (incl `test_reindex_case_law_uses_stored_text`, `test_reindex_case_law_missing_row_raises`).
- [ ] **Step 4: Commit**
```bash
cd ~/legal-ai
git add mcp-server/src/legal_mcp/services/ingest.py
git commit -m "feat(reindex): reindex_case_law from stored text + mark_indexed on ingest (GAP-09, FU-3)"
```
---
## Task 4: Health-check drift count
**Files:** Modify `mcp-server/src/legal_mcp/services/metrics.py`
- [ ] **Step 1: Add `stale_embedding_case_law` count**
READ metrics.py — the aggregation that holds `non_searchable_case_law` / `cases_with_stale_blocks` (added in FU-2a/FU-7). Add a sibling, mirroring the exact pattern:
```python
stale_embedding_case_law = await conn.fetchval(
"SELECT COUNT(*) FROM case_law "
"WHERE coalesce(full_text,'') <> '' AND content_hash IS DISTINCT FROM indexed_hash")
```
and expose it in the returned summary dict: `"stale_embedding_case_law": stale_embedding_case_law`.
- [ ] **Step 2: Smoke-import + commit**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.services import metrics; print('clean')"`
```bash
cd ~/legal-ai
git add mcp-server/src/legal_mcp/services/metrics.py
git commit -m "feat(reindex): health-check stale_embedding_case_law count (GAP-09, FU-3)"
```
---
## Task 5: MCP tool `precedent_reindex`
**Files:** Modify `mcp-server/src/legal_mcp/tools/precedent_library.py`, `mcp-server/src/legal_mcp/server.py`
- [ ] **Step 1: Add the tool function in precedent_library.py (mirror `precedent_extract_metadata`)**
READ `precedent_extract_metadata` (tools/precedent_library.py ~205-216) for the `_ok`/`_err`/UUID pattern. Add:
```python
async def precedent_reindex(case_law_id: str) -> str:
"""re-chunk + re-embed פסיקה קיימת מה-full_text השמור (FU-3/GAP-09).
לתיקון drift של embeddings או אחרי שינוי-תוכן. אינו מריץ OCR/LLM — רק
chunking + voyage embeddings. idempotent (מוחק ובונה chunks מחדש).
"""
try:
cid = UUID(case_law_id)
except ValueError:
return _err("case_law_id לא תקין")
try:
from legal_mcp.services import ingest
result = await ingest.reindex_case_law(cid)
except Exception as e:
return _err(str(e))
return _ok(result)
```
- [ ] **Step 2: Register in server.py (mirror the precedent tools' `@mcp.tool()` registration)**
READ server.py — find where `precedent_extract_metadata` (or another `precedent_*` tool) is registered with `@mcp.tool()` and delegated to `tools.precedent_library`. Add an equivalent registration for `precedent_reindex` following the identical pattern (decorator + delegation + the same import style). Report the exact registration block you added.
- [ ] **Step 3: Smoke-import + commit**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.tools import precedent_library; import legal_mcp.server; print('clean')"`
```bash
cd ~/legal-ai
git add mcp-server/src/legal_mcp/tools/precedent_library.py mcp-server/src/legal_mcp/server.py
git commit -m "feat(reindex): precedent_reindex MCP tool (GAP-09, FU-3)"
```
---
## Task 6: Backfill + full suite + DB smoke + lint + TaskMaster
- [ ] **Step 1: Full offline suite**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/ -q`
Expected: all pass (FU-1/2a/7 + new FU-3). If a pre-existing test that calls `ingest_document` breaks because `mark_indexed` isn't stubbed, fix that fixture to stub `db.mark_indexed` (same pattern as the FU-2a `recompute_searchable` fixture fix). Report.
- [ ] **Step 2: DB smoke + backfill (real Postgres — applies V23, runs backfill)**
```bash
cd ~/legal-ai && set -a && source ~/.env 2>/dev/null && set +a
cd mcp-server && .venv/bin/python -c "
import asyncio
from legal_mcp.services import db
async def main():
await db.get_pool() # applies V23
pool = await db.get_pool()
async with pool.acquire() as c:
cols = await c.fetchval(\"SELECT count(*) FROM information_schema.columns WHERE table_name='case_law' AND column_name IN ('content_hash','indexed_hash')\")
print('V23 columns present:', cols, '(expect 2)')
res = await db.recompute_content_hashes()
print('backfill:', res)
stale = await db.list_stale_case_law()
print('stale after backfill:', len(stale))
asyncio.run(main())
" 2>&1 | grep -vE 'INFO|WARNING|httpx|deprecat|command not found|\^\^\^' | tail -5
```
Expected: `V23 columns present: 2`, backfill updated ~129, `stale after backfill:` a small number (rows with text but no chunks, e.g. cited_only). Report the stale count.
- [ ] **Step 3: Lint**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m ruff check src/legal_mcp/services/db.py src/legal_mcp/services/ingest.py 2>/dev/null; echo "exit=$?"`
Expected: clean or "ruff not available".
- [ ] **Step 4: TaskMaster** — controller marks #61 + subtask 61.1 done (61.2 already cancelled), verifies via MCP.
---
## Self-Review Notes
- **GAP-09** → content_hash detection (Task 2) + reindex_case_law (Task 3) + drift health-check (Task 4) + MCP tool (Task 5).
- **No re-OCR:** reindex uses stored `full_text` only (Task 3) — honors feedback_no_reocr_retrofit.
- **Backfill is hash-only** (Task 6 Step 2) — no re-embed, no API cost; existing vectors untouched.
- **#61.2 closed** (not-applicable, in the spec commit) — no multimodal backfill task here.
- **Scope:** `case_law` only — `create_case`/`cases` table NOT touched (Task 2 Step 4).
- **Type consistency:** `_content_hash(text)->str`, `mark_indexed(case_law_id)`, `reindex_case_law(id)->{chunks,reindexed}`, `list_stale_case_law()`, `recompute_content_hashes()->{updated}` — names identical across tasks + tests.
- **Param-count risk** (Task 2 Step 3): the FU-2a upsert SQL must get exactly one new placeholder + one new arg per function; verified at runtime by the Task 6 DB smoke (a mismatch raises immediately).

View File

@@ -0,0 +1,521 @@
# FU-7: Audit-Trail + Provenance — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Turn `audit_log` into an end-to-end audit trail, attach source-provenance to generated blocks, enforce citation→corpus resolution, and flag DOCX↔blocks drift — all forward-only, no data migration.
**Architecture:** Reuse `audit_log.log_action` with a `details` JSONB payload (X5 §4 — no new table) via a non-fatal `log_action_safe` wrapper. Provenance is an append-only `write_block` audit event carrying the source ids that fed the generation. GAP-17 drift is a deterministic `cases.blocks_stale` flag (V22) set at the known divergence points + a health-check count — not a fragile DOCX→blocks reparse. GAP-20 is a structural `case_law_id` resolver surfaced as a QA warning.
**Tech Stack:** Python 3.12, asyncpg, PostgreSQL@localhost:5433, pytest offline, `.venv` at `mcp-server/.venv`.
**Spec:** [docs/superpowers/specs/2026-05-30-fu7-audit-provenance-design.md](../specs/2026-05-30-fu7-audit-provenance-design.md)
**Run tests:** `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_audit_provenance.py -v`
---
## File Structure
- **Modify** `mcp-server/src/legal_mcp/services/audit.py` — add `log_action_safe(...)`.
- **Modify** `mcp-server/src/legal_mcp/services/db.py` — V22 migration (`cases.blocks_stale`), `mark_blocks_stale`, `resolve_citation_case_law_ids`.
- **Modify** `mcp-server/src/legal_mcp/tools/documents.py` — audit in `document_upload`, `extract_claims`.
- **Modify** `mcp-server/src/legal_mcp/services/block_writer.py` — collect source ids; audit `write_block`; clear `blocks_stale` on save.
- **Modify** `mcp-server/src/legal_mcp/tools/drafting.py` — audit `export_docx`; set/clear `blocks_stale` in `export_docx`/`revise_draft`/`apply_user_edit`.
- **Modify** QA path (`services/qa_validator.py`) — citation→corpus warning.
- **Modify** `mcp-server/src/legal_mcp/services/metrics.py``cases_with_stale_blocks` count.
- **Create** `mcp-server/tests/test_audit_provenance.py`.
---
## Task 1: Failing tests
**Files:** Create `mcp-server/tests/test_audit_provenance.py`
- [ ] **Step 1: Write the failing tests**
```python
"""FU-7: audit-trail + provenance (offline, monkeypatched I/O)."""
from __future__ import annotations
import asyncio
from uuid import uuid4
import pytest
from legal_mcp.services import audit, db
def _run(coro):
return asyncio.run(coro)
# ── GAP-18: log_action_safe is non-fatal ───────────────────────────────
def test_log_action_safe_swallows_db_error(monkeypatch):
async def _boom(*a, **k):
raise RuntimeError("db down")
monkeypatch.setattr(audit, "log_action", _boom)
# must NOT raise
_run(audit.log_action_safe("write_block", details={"x": 1}))
def test_log_action_safe_forwards_args(monkeypatch):
seen = {}
async def _capture(action, case_id=None, document_id=None, details=None, user="system"):
seen.update(action=action, details=details)
monkeypatch.setattr(audit, "log_action", _capture)
_run(audit.log_action_safe("export_docx", details={"path": "/x"}))
assert seen["action"] == "export_docx" and seen["details"] == {"path": "/x"}
# ── GAP-20: structural citation resolver ────────────────────────────────
def test_resolve_citation_case_law_ids_splits(monkeypatch):
good = uuid4()
bad = uuid4()
class _Conn:
async def fetchval(self, q, cid):
return cid == good
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
class _Pool:
def acquire(self): return _Conn()
async def _pool():
return _Pool()
monkeypatch.setattr(db, "get_pool", _pool)
out = _run(db.resolve_citation_case_law_ids([good, bad]))
assert good in out["resolved"] and bad in out["unresolved"]
# ── GAP-17: blocks_stale helper ────────────────────────────────────────
def test_mark_blocks_stale_executes_update(monkeypatch):
seen = {}
class _Conn:
async def execute(self, q, *a):
seen["q"] = q; seen["args"] = a
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
class _Pool:
def acquire(self): return _Conn()
async def _pool(): return _Pool()
monkeypatch.setattr(db, "get_pool", _pool)
cid = uuid4()
_run(db.mark_blocks_stale(cid, True))
assert "blocks_stale" in seen["q"] and seen["args"][0] is True and seen["args"][1] == cid
```
- [ ] **Step 2: Run to verify failure**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_audit_provenance.py -v`
Expected: FAIL — `AttributeError: ... has no attribute 'log_action_safe'` / `resolve_citation_case_law_ids` / `mark_blocks_stale`.
- [ ] **Step 3: Commit**
```bash
cd ~/legal-ai
git add mcp-server/tests/test_audit_provenance.py
git commit -m "test(audit): failing tests for audit-trail + provenance (FU-7)"
```
---
## Task 2: V22 migration + core helpers
**Files:** Modify `mcp-server/src/legal_mcp/services/audit.py`, `mcp-server/src/legal_mcp/services/db.py`
- [ ] **Step 1: Add `log_action_safe` to audit.py (after `log_action`)**
```python
async def log_action_safe(
action: str,
case_id: "UUID | None" = None,
document_id: "UUID | None" = None,
details: dict | None = None,
user: str = "system",
) -> None:
"""Non-fatal audit: never let an audit-log failure break the caller's action.
The authoritative integrity trail is git (X5 §2.1); audit_log is the
'who/what/when' observability layer, so a write failure is logged as a
warning and swallowed.
"""
try:
await log_action(action, case_id=case_id, document_id=document_id,
details=details, user=user)
except Exception as e: # noqa: BLE001 — observability must not break the op
logger.warning("audit log_action failed (non-fatal) for %s: %s", action, e)
```
- [ ] **Step 2: Add `SCHEMA_V22_SQL` after `SCHEMA_V21_SQL` in db.py + wire it**
READ db.py near `SCHEMA_V21_SQL` (~line 1097-1133). Add after the V21 block:
```python
# ── V22: cases.blocks_stale — DOCX↔blocks drift flag (GAP-17 / INV-EX1) ──
# Set true when revise_draft/apply_user_edit make active_draft_path the live
# source-of-truth without re-syncing decision_blocks; cleared when blocks are
# re-exported or re-saved. Surfaced by health-check. Source-of-truth remains
# decision_blocks — this only flags known drift (no fragile DOCX→blocks reparse).
SCHEMA_V22_SQL = """
ALTER TABLE cases ADD COLUMN IF NOT EXISTS blocks_stale boolean NOT NULL DEFAULT false;
"""
```
After `await conn.execute(SCHEMA_V21_SQL)` add `await conn.execute(SCHEMA_V22_SQL)` and bump the log line to `v1-v22`.
- [ ] **Step 3: Add `mark_blocks_stale` + `resolve_citation_case_law_ids` to db.py (near the case helpers, after `get_active_draft_path`)**
```python
async def mark_blocks_stale(case_id: UUID, stale: bool) -> None:
"""Flag/clear DOCX↔blocks drift for a case (GAP-17)."""
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE cases SET blocks_stale = $1, updated_at = now() WHERE id = $2",
stale, case_id,
)
async def resolve_citation_case_law_ids(ids) -> dict:
"""Structural citation→corpus resolution (GAP-20 / INV-AUD3).
Given case_law_id values referenced by a decision's citations/provenance,
split into resolvable (exist in case_law) vs unresolvable.
"""
resolved, unresolved = [], []
pool = await get_pool()
async with pool.acquire() as conn:
for cid in ids:
try:
exists = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM case_law WHERE id = $1)", cid)
except Exception:
exists = False
(resolved if exists else unresolved).append(cid)
return {"resolved": resolved, "unresolved": unresolved}
```
- [ ] **Step 4: Run Task-1 tests for these helpers**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_audit_provenance.py -v`
Expected: all 4 tests PASS.
- [ ] **Step 5: Commit**
```bash
cd ~/legal-ai
git add mcp-server/src/legal_mcp/services/audit.py mcp-server/src/legal_mcp/services/db.py
git commit -m "feat(audit): log_action_safe + V22 blocks_stale + citation resolver (FU-7)"
```
---
## Task 3: GAP-18 — audit calls on upload / extract_claims / export
**Files:** Modify `mcp-server/src/legal_mcp/tools/documents.py`, `mcp-server/src/legal_mcp/tools/drafting.py`
- [ ] **Step 1: `document_upload` — audit after processing (documents.py)**
READ `document_upload` (lines ~14-94). It computes `case_id`, `doc` (with `doc["id"]`), `actual_doc_type`, and `result` (with `result["classification"]`). Ensure `from legal_mcp.services import audit` is imported (add if missing). Immediately BEFORE the final `return json.dumps({...})`, add:
```python
await audit.log_action_safe(
"document_upload", case_id=case_id, document_id=UUID(doc["id"]),
details={"title": title, "doc_type": actual_doc_type},
)
```
- [ ] **Step 2: `extract_claims` — audit before return (documents.py)**
In `extract_claims` (lines ~300-348), before the final `return json.dumps(results, ...)`, add:
```python
await audit.log_action_safe(
"extract_claims", case_id=case_id,
details={"docs_processed": len(docs), "results": len(results)},
)
```
- [ ] **Step 3: `export_docx` — audit after export (drafting.py)**
READ `export_docx` in `drafting.py` (around lines 384-439). It resolves `case_id`, builds `path`, and calls `db.set_active_draft_path(case_id, path)`. Ensure `audit` is imported. After the `set_active_draft_path` call, add:
```python
await audit.log_action_safe(
"export_docx", case_id=case_id,
details={"path": str(path)},
)
```
- [ ] **Step 4: Verify imports**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.tools import documents, drafting; print('clean')"`
Expected: `clean`.
- [ ] **Step 5: Commit**
```bash
cd ~/legal-ai
git add mcp-server/src/legal_mcp/tools/documents.py mcp-server/src/legal_mcp/tools/drafting.py
git commit -m "feat(audit): log document_upload/extract_claims/export_docx (GAP-18, FU-7)"
```
---
## Task 4: GAP-19 — block→source provenance
**Files:** Modify `mcp-server/src/legal_mcp/services/block_writer.py`
- [ ] **Step 1: Make `_build_precedents_context` also return the case_law ids it used**
READ `_build_precedents_context` (lines ~671-716). Change the `caselaw_rows` SELECT to also fetch `cl.id`:
replace `"""SELECT cl.case_number, cl.case_name, cl.court, cl.summary, cl.key_quote,` with
`"""SELECT cl.id, cl.case_number, cl.case_name, cl.court, cl.summary, cl.key_quote,`.
Collect ids and change the function to return a tuple. At the function's two `return` points:
- replace `return "\n\n".join(parts) if parts else "(אין תקדימים)"` with
`return ("\n\n".join(parts) if parts else "(אין תקדימים)"), case_law_ids`
- ensure `case_law_ids = []` is initialized at the top, and inside the caselaw loop append `r["id"]` (str(r["id"])).
If there is an early/exception return path that returns a bare string, make it return `("(אין תקדימים)", [])` too.
- [ ] **Step 2: Update the caller in `write_block` + collect document/claim ids**
READ `write_block` (lines ~280-394). Line ~321 currently:
`precedents_context = await _build_precedents_context(case_id, block_id)`
Change to:
`precedents_context, _precedent_case_law_ids = await _build_precedents_context(case_id, block_id)`
Add a helper `_collect_block_sources` (after `_build_result`, ~line 408):
```python
async def _collect_block_sources(case_id: UUID, block_id: str) -> dict:
"""Deterministic source ids available to a block's generation (GAP-19).
document_ids: case documents matching the block's allowed doc-types.
claim_ids: extracted claims for the case. (case_law_ids are captured
separately from the precedent search inside write_block.)
"""
allowed = _BLOCK_DOC_TYPES.get(block_id, [])
docs = await db.list_documents(case_id)
if allowed:
docs = [d for d in docs if d.get("doc_type") in allowed]
claims = await db.get_claims(case_id)
return {
"document_ids": [str(d["id"]) for d in docs],
"claim_ids": [str(c["id"]) for c in claims],
}
```
In `write_block`, just before the final `return _build_result(block_id, content, block_cfg)` (the non-template path, ~line 394), build the sources and attach to the result:
```python
sources = await _collect_block_sources(case_id, block_id)
sources["case_law_ids"] = _precedent_case_law_ids
result = _build_result(block_id, content, block_cfg)
result["sources"] = sources
return result
```
(For the template path return at ~line 308, attach an empty sources dict: `r = _build_result(...); r["sources"] = {"document_ids": [], "claim_ids": [], "case_law_ids": []}; return r`.)
- [ ] **Step 3: Write the provenance audit in `write_and_store_block` and `save_block_content`**
In `write_and_store_block` (~line 1039), after `await store_block(UUID(decision["id"]), result)`, add:
```python
await audit.log_action_safe(
"write_block", case_id=case_id,
details={
"decision_id": str(decision["id"]),
"block_id": block_id,
"model_used": result.get("model_used"),
"generation_type": result.get("generation_type"),
"sources": result.get("sources", {}),
},
)
await db.mark_blocks_stale(case_id, False)
```
In `save_block_content` (~line 905), after `await store_block(...)` add the same `mark_blocks_stale(case_id, False)` (a saved block means DB blocks are current). Ensure `from legal_mcp.services import audit` is imported in block_writer.py (add if missing).
- [ ] **Step 4: Smoke-import + targeted check**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.services import block_writer; print('clean')"`
Expected: `clean`.
- [ ] **Step 5: Commit**
```bash
cd ~/legal-ai
git add mcp-server/src/legal_mcp/services/block_writer.py
git commit -m "feat(audit): block→source provenance via write_block audit event (GAP-19, FU-7)"
```
---
## Task 5: GAP-20 — citation→corpus validation as QA warning
**Files:** Modify `mcp-server/src/legal_mcp/services/qa_validator.py`
- [ ] **Step 1: Read the QA validator structure**
READ `mcp-server/src/legal_mcp/services/qa_validator.py` — find the function that runs the QA checks and returns findings (look for the list of checks / findings dicts with severity like `warning`/`critical`). Identify the findings structure (keys, how a check is appended).
- [ ] **Step 2: Add a citation-resolution check**
Add a check that gathers `case_law_id`s referenced by the decision's provenance/citations and resolves them. Concretely, add a function in qa_validator.py:
```python
async def _check_citation_resolution(case_id, decision_id) -> list[dict]:
"""GAP-20/INV-AUD3: every cited case_law_id must resolve to the corpus.
Reads case_law_ids from the decision's write_block audit provenance
(audit_log details.sources.case_law_ids) and verifies each resolves.
Unresolvable ids → non-blocking warning + audit('citation_unresolved').
"""
from legal_mcp.services import db, audit
from uuid import UUID
rows = await audit.get_audit_log(case_id=case_id, action="write_block", limit=200)
ids = set()
for r in rows:
details = r.get("details") or {}
if isinstance(details, str):
import json as _json
try: details = _json.loads(details)
except (ValueError, TypeError): details = {}
for raw in (details.get("sources") or {}).get("case_law_ids", []):
try: ids.add(UUID(str(raw)))
except (ValueError, TypeError): pass
if not ids:
return []
res = await db.resolve_citation_case_law_ids(list(ids))
findings = []
if res["unresolved"]:
await audit.log_action_safe(
"citation_unresolved", case_id=case_id,
details={"unresolved": [str(x) for x in res["unresolved"]]},
)
findings.append({
"check": "citation_resolution",
"severity": "warning",
"passed": False,
"message": f"{len(res['unresolved'])} ציטוטים אינם פתירים לקורפוס — דורש אימות יו\"ר",
})
return findings
```
Then wire `_check_citation_resolution` into the validator's main run function so its findings are appended to the result list (match the existing findings shape — adjust the dict keys to the validator's actual schema discovered in Step 1). It must be a **warning**, never a critical gate (does not block export).
- [ ] **Step 3: Smoke-import**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.services import qa_validator; print('clean')"`
Expected: `clean`.
- [ ] **Step 4: Commit**
```bash
cd ~/legal-ai
git add mcp-server/src/legal_mcp/services/qa_validator.py
git commit -m "feat(qa): citation→corpus resolution as non-blocking warning (GAP-20, FU-7)"
```
---
## Task 6: GAP-17 — blocks_stale wiring + health-check
**Files:** Modify `mcp-server/src/legal_mcp/tools/drafting.py`, `mcp-server/src/legal_mcp/services/metrics.py`
- [ ] **Step 1: Set `blocks_stale=true` in `revise_draft` and `apply_user_edit`**
READ `revise_draft` (~647-733) and `apply_user_edit` (~569-613) in drafting.py. Each ends by calling `db.set_active_draft_path(case_id, ...)`. Immediately after that call in EACH function, add:
```python
await db.mark_blocks_stale(case_id, True)
```
- [ ] **Step 2: Clear `blocks_stale=false` in `export_docx`**
In `export_docx` (after the `set_active_draft_path` + the audit added in Task 3), add:
```python
await db.mark_blocks_stale(case_id, False)
```
(export_docx renders FROM the blocks, so the DOCX matches blocks → not stale.)
- [ ] **Step 3: Health-check count in metrics.py**
READ `mcp-server/src/legal_mcp/services/metrics.py` — find the aggregation that already runs counts (the one FU-2a added `non_searchable_case_law` to). Add a sibling count:
```python
cases_with_stale_blocks = await conn.fetchval(
"SELECT COUNT(*) FROM cases WHERE blocks_stale")
```
and expose it in the returned summary dict as `"cases_with_stale_blocks": cases_with_stale_blocks`.
- [ ] **Step 4: Smoke-import**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -c "from legal_mcp.tools import drafting; from legal_mcp.services import metrics; print('clean')"`
Expected: `clean`.
- [ ] **Step 5: Commit**
```bash
cd ~/legal-ai
git add mcp-server/src/legal_mcp/tools/drafting.py mcp-server/src/legal_mcp/services/metrics.py
git commit -m "feat(audit): blocks_stale drift flag + health-check visibility (GAP-17, FU-7)"
```
---
## Task 7: Full suite + DB smoke + lint + TaskMaster
- [ ] **Step 1: Full offline suite**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/ -q`
Expected: all pass (FU-1/2a + new FU-7 tests). Report the summary line. If a pre-existing test fails because a newly-audited function now calls `audit`/`mark_blocks_stale` without a stub, fix that test's fixture to stub the new boundary (same pattern as the FU-2a `recompute_searchable` fixture fix).
- [ ] **Step 2: DB smoke (real Postgres — applies V22, exercises helpers)**
```bash
cd ~/legal-ai && set -a && source ~/.env 2>/dev/null && set +a
cd mcp-server && .venv/bin/python -c "
import asyncio, uuid
from legal_mcp.services import db, audit
async def main():
await db.get_pool() # applies V22
pool = await db.get_pool()
async with pool.acquire() as c:
col = await c.fetchval(\"SELECT 1 FROM information_schema.columns WHERE table_name='cases' AND column_name='blocks_stale'\")
print('V22 blocks_stale present:', bool(col))
# citation resolver: random id is unresolved
out = await db.resolve_citation_case_law_ids([uuid.uuid4()])
print('resolver unresolved count:', len(out['unresolved']))
# log_action_safe never raises
await audit.log_action_safe('fu7_smoke', details={'ok': True})
print('log_action_safe ok')
asyncio.run(main())
" 2>&1 | grep -vE 'INFO|WARNING|httpx|deprecat|command not found|\^\^\^' | tail -5
```
Expected: `V22 blocks_stale present: True`, `resolver unresolved count: 1`, `log_action_safe ok`. (Optionally clean the smoke row: `DELETE FROM audit_log WHERE action='fu7_smoke'`.)
- [ ] **Step 3: Lint**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m ruff check src/legal_mcp/services/audit.py src/legal_mcp/services/db.py src/legal_mcp/services/block_writer.py 2>/dev/null; echo "exit=$?"`
Expected: clean or "ruff not available".
- [ ] **Step 4: Mark TaskMaster #65 done** — controller edits `.taskmaster/tasks/tasks.json` + verifies via MCP get_task.
---
## Self-Review Notes
- **GAP-18** → Task 3 (+ write_block audit in Task 4). **GAP-19** → Task 4 (provenance event). **GAP-20** → Task 5 (resolver + QA warning). **GAP-17** → Tasks 2+6 (V22 flag + wiring + health).
- **No new table** (audit_log reused, X5 §4). **No data migration** (V22 additive; provenance forward-only).
- **Non-fatal audit:** all calls via `log_action_safe`. **GAP-20 is warning-only** (never a critical gate — doesn't block export, consistent with FU-6 gates).
- **Type consistency:** `log_action_safe`, `mark_blocks_stale(case_id, stale)`, `resolve_citation_case_law_ids(ids)->{resolved,unresolved}`, `result["sources"]={document_ids,claim_ids,case_law_ids}` — names identical across tasks + tests.
- **Offline-test limit:** real audit_log INSERT / V22 verified by Task 7 Step 2 smoke; offline tests cover the pure wrappers/resolver logic.

View File

@@ -0,0 +1,401 @@
# FU-2b: Internal Identifier Reconciliation — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a reversible, chair-gated migration script that rewrites `internal_committee` `case_number` values currently holding a full citation into the canonical normalized bare number (X1: trim · prefix-strip · `/``-`, month preserved), leaving `citation_formatted` untouched.
**Architecture:** A standalone `scripts/` migration (not editable-service code), `--dry-run` by default. Dry-run emits a reconciliation table (CSV + Hebrew Markdown) for chair review; `--apply --approved <csv>` writes a backup then updates only chair-approved rows. Extraction is deterministic (single number-token regex) — no LLM. The production apply runs only AFTER Dafna approves the table.
**Tech Stack:** Python 3.12, asyncpg, PostgreSQL@localhost:5433, pytest offline, `.venv` at `mcp-server/.venv`.
**Spec:** [docs/superpowers/specs/2026-05-31-fu2b-identifier-reconciliation-design.md](../specs/2026-05-31-fu2b-identifier-reconciliation-design.md)
**Run script:** `PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python; $PY scripts/fu2b_reconcile_internal_case_numbers.py` (dry-run)
**Run tests:** `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_fu2b_reconcile.py -v`
---
## File Structure
- **Create** `scripts/fu2b_reconcile_internal_case_numbers.py` — the migration (pure `_extract_bare` + reconciliation builder + table/backup/revert writers + argparse `--dry-run`/`--apply`).
- **Create** `mcp-server/tests/test_fu2b_reconcile.py` — offline tests for `_extract_bare` + consistency flagging (imports the script module via sys.path).
- **Modify** `scripts/SCRIPTS.md` — register the new script (CLAUDE.md rule).
- **Artifact (produced, committed for review)** `data/audit/fu2b-reconciliation-<ts>.md` — the chair table from the dry-run.
No service code changes; no schema change. FK-safe (all `case_law` FKs use `id` UUID — verified).
---
## Task 1: Failing tests for `_extract_bare`
**Files:** Create `mcp-server/tests/test_fu2b_reconcile.py`
- [ ] **Step 1: Write the failing tests**
```python
"""FU-2b: deterministic bare-number extraction (offline)."""
from __future__ import annotations
import importlib.util
from pathlib import Path
import pytest
# Load the migration script as a module (it lives in scripts/, not a package).
_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "fu2b_reconcile_internal_case_numbers.py"
_spec = importlib.util.spec_from_file_location("fu2b_reconcile", _SCRIPT)
fu2b = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(fu2b)
@pytest.mark.parametrize("raw,expected_bare", [
("ערר (‏ועדות ערר - תכנון ובנייה ירושלים‏) 403/17 אהרון ברק נ'", "403-17"),
("ערר (...) 8136-10-24 שחר שות'", "8136-10-24"), # month preserved
("בל\"מ (...) 1028/20 חלוואני ריאד", "1028-20"),
("8047/23", "8047-23"), # already-bare-ish
("ערר 81002-01-21", "81002-01-21"),
])
def test_extract_bare_single_token(raw, expected_bare):
bare, flag = fu2b._extract_bare(raw)
assert bare == expected_bare
assert flag == "OK"
def test_extract_bare_no_number():
bare, flag = fu2b._extract_bare("ערר אדלר נ' הוועדה")
assert bare is None and flag == "NO_NUMBER"
def test_extract_bare_multiple_numbers_flagged():
# Two case-number-shaped tokens → ambiguous, must NOT auto-pick.
bare, flag = fu2b._extract_bare("ערר 403/17 ו-1024/24 מאוחדים")
assert bare is None and flag == "MULTI_NUMBER"
def test_extract_bare_preserves_month_not_padding():
# Month kept exactly; 2-part stays 2-part (no invented month).
assert fu2b._extract_bare("ערר 8126/24 פלוני")[0] == "8126-24"
assert fu2b._extract_bare("ערר 8126-03-25 פלוני")[0] == "8126-03-25"
def test_consistency_flag_when_bare_absent_from_citation():
# proposed bare must appear in citation_formatted, else MISMATCH.
assert fu2b._consistency_flag("403-17", "ערר (...) 403/17 אהרון ברק") == "OK"
assert fu2b._consistency_flag("403-17", "ערר (...) 1975/24 מישהו אחר") == "MISMATCH"
assert fu2b._consistency_flag("403-17", "") == "NO_CITATION"
```
- [ ] **Step 2: Run to verify failure**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_fu2b_reconcile.py -v`
Expected: FAIL — `FileNotFoundError`/`ModuleNotFoundError` (script doesn't exist) or `AttributeError: _extract_bare`.
- [ ] **Step 3: Commit**
```bash
cd ~/legal-ai
git add mcp-server/tests/test_fu2b_reconcile.py
git commit -m "test(fu2b): failing tests for bare-number extraction (FU-2b)"
```
---
## Task 2: The migration script (dry-run + apply + backup)
**Files:** Create `scripts/fu2b_reconcile_internal_case_numbers.py`
- [ ] **Step 1: Write the script**
```python
#!/usr/bin/env python3
"""FU-2b — reconcile internal_committee case_number → canonical bare number.
Rewrites case_number values that currently hold a full citation into the
canonical normalized bare number (X1: trim · prefix-strip · '/''-', month
preserved). citation_formatted is the display field and is left untouched.
DETERMINISTIC — no LLM. Extraction takes the single case-number-shaped token
from the value; 0 or >1 tokens are flagged for chair review, never guessed.
Usage (must use the mcp-server venv — asyncpg/pgvector vendored there):
PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python
# Dry-run (default): builds the reconciliation table for chair review.
$PY scripts/fu2b_reconcile_internal_case_numbers.py
# Apply ONLY the chair-approved rows (after Dafna's review), backup first:
$PY scripts/fu2b_reconcile_internal_case_numbers.py --apply \
--approved data/audit/fu2b-approved-<ts>.csv
Scope: source_kind='internal_committee' only (external → #68/FU-2c). FK-safe:
all case_law FKs reference case_law.id (UUID), not case_number.
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "mcp-server" / "src"))
if "POSTGRES_URL" not in os.environ:
os.environ["POSTGRES_URL"] = (
f"postgres://{os.environ.get('POSTGRES_USER','legal_ai')}:"
f"{os.environ.get('POSTGRES_PASSWORD','')}@"
f"{os.environ.get('POSTGRES_HOST','127.0.0.1')}:"
f"{os.environ.get('POSTGRES_PORT','5433')}/"
f"{os.environ.get('POSTGRES_DB','legal_ai')}"
)
AUDIT_DIR = REPO_ROOT / "data" / "audit"
_TOKEN_RE = re.compile(r"[0-9]{2,6}(?:[-/][0-9]{1,2}){1,2}")
def _extract_bare(case_number: str) -> tuple[str | None, str]:
"""Return (canonical_bare, flag). flag ∈ {OK, NO_NUMBER, MULTI_NUMBER}.
Deterministic: finds case-number-shaped tokens (NNNN/YY or NNNN-MM-YY).
Exactly one → normalize '/''-' (month preserved, none invented). 0 or >1
→ None + flag (chair decides; never guess).
"""
tokens = _TOKEN_RE.findall(case_number or "")
if len(tokens) == 1:
return tokens[0].replace("/", "-"), "OK"
if not tokens:
return None, "NO_NUMBER"
return None, "MULTI_NUMBER"
def _consistency_flag(bare: str | None, citation_formatted: str) -> str:
"""OK if bare appears in citation_formatted; MISMATCH if not; NO_CITATION if empty."""
if not citation_formatted:
return "NO_CITATION"
if not bare:
return "NO_NUMBER"
# compare against the citation with separators unified, to match 403/17 vs 403-17
cf = citation_formatted.replace("/", "-")
return "OK" if bare in cf else "MISMATCH"
async def _build_reconciliation() -> list[dict]:
from legal_mcp.services import db
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, case_number, proceeding_type, coalesce(citation_formatted,'') AS cf "
"FROM case_law WHERE source_kind='internal_committee' ORDER BY case_number")
# detect dup serials across proceeding_type for a DUP_CHECK flag
out: list[dict] = []
for r in rows:
bare, flag = _extract_bare(r["case_number"])
cons = _consistency_flag(bare, r["cf"])
changes = bare is not None and bare != r["case_number"]
out.append({
"id": str(r["id"]),
"current_case_number": r["case_number"],
"proposed_bare": bare or "",
"proceeding_type": r["proceeding_type"] or "",
"citation_formatted": r["cf"],
"extract_flag": flag,
"consistency": cons,
"will_change": "yes" if changes else "no",
})
# DUP_CHECK: same proposed_bare appearing on >1 row (any proceeding_type)
from collections import Counter
bare_counts = Counter(d["proposed_bare"] for d in out if d["proposed_bare"])
for d in out:
if d["proposed_bare"] and bare_counts[d["proposed_bare"]] > 1:
d["dup_check"] = "DUP_CHECK"
else:
d["dup_check"] = ""
return out
def _ts() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def _write_table(rows: list[dict], ts: str) -> tuple[Path, Path]:
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
csv_path = AUDIT_DIR / f"fu2b-reconciliation-{ts}.csv"
md_path = AUDIT_DIR / f"fu2b-reconciliation-{ts}.md"
cols = ["id", "current_case_number", "proposed_bare", "proceeding_type",
"citation_formatted", "extract_flag", "consistency", "dup_check", "will_change"]
with csv_path.open("w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=cols)
w.writeheader()
w.writerows(rows)
changing = [r for r in rows if r["will_change"] == "yes"]
flagged = [r for r in rows if r["extract_flag"] != "OK" or r["consistency"] == "MISMATCH" or r["dup_check"]]
with md_path.open("w", encoding="utf-8") as f:
f.write(f"# FU-2b — טבלת-תיאום מזהים (internal_committee) — {ts}\n\n")
f.write(f"- סה\"כ רשומות: {len(rows)}\n- ישתנו: {len(changing)}\n- מסומנות לסקירה: {len(flagged)}\n\n")
f.write("## דורש הכרעת-יו\"ר (flags)\n\n")
f.write("| current_case_number | proposed_bare | proc | flags |\n|---|---|---|---|\n")
for r in flagged:
fl = " ".join(x for x in [r["extract_flag"] if r["extract_flag"] != "OK" else "",
r["consistency"] if r["consistency"] == "MISMATCH" else "",
r["dup_check"]] if x)
f.write(f"| {r['current_case_number'][:50]} | {r['proposed_bare']} | {r['proceeding_type']} | {fl} |\n")
f.write("\n## כל השינויים המוצעים\n\n")
f.write("| current_case_number | → proposed_bare | proc |\n|---|---|---|\n")
for r in changing:
f.write(f"| {r['current_case_number'][:55]} | {r['proposed_bare']} | {r['proceeding_type']} |\n")
return csv_path, md_path
async def _apply(approved_csv: Path, ts: str) -> dict:
from legal_mcp.services import db
with approved_csv.open(encoding="utf-8") as f:
approved = [r for r in csv.DictReader(f)
if r.get("will_change") == "yes" and r.get("proposed_bare")]
if not approved:
return {"applied": 0, "note": "no approved changing rows"}
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
backup = AUDIT_DIR / f"fu2b-backup-{ts}.csv"
pool = await db.get_pool()
applied = 0
with backup.open("w", newline="", encoding="utf-8") as bf:
bw = csv.writer(bf)
bw.writerow(["id", "old_case_number"])
async with pool.acquire() as conn:
for r in approved:
old = await conn.fetchval("SELECT case_number FROM case_law WHERE id=$1", r["id"])
if old is None:
continue
bw.writerow([r["id"], old])
await conn.execute(
"UPDATE case_law SET case_number=$2 WHERE id=$1 "
"AND source_kind='internal_committee'",
r["id"], r["proposed_bare"])
applied += 1
return {"applied": applied, "backup": str(backup)}
async def main() -> int:
parser = argparse.ArgumentParser(description="FU-2b internal case_number reconciliation")
parser.add_argument("--apply", action="store_true", help="apply approved changes (default: dry-run)")
parser.add_argument("--approved", type=str, help="path to chair-approved CSV (required with --apply)")
args = parser.parse_args()
ts = _ts()
if not args.apply:
rows = await _build_reconciliation()
csv_path, md_path = _write_table(rows, ts)
changing = sum(1 for r in rows if r["will_change"] == "yes")
flagged = sum(1 for r in rows if r["extract_flag"] != "OK" or r["consistency"] == "MISMATCH" or r["dup_check"])
print(f"DRY-RUN: {len(rows)} rows | will_change={changing} | flagged={flagged}")
print(f" table: {md_path}")
print(f" csv: {csv_path}")
print("Review the table with the chair, then run --apply --approved <reviewed.csv>.")
return 0
if not args.approved:
print("ERROR: --apply requires --approved <csv> (the chair-reviewed table).", file=sys.stderr)
return 2
result = await _apply(Path(args.approved), ts)
print(f"APPLIED: {result}")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
```
- [ ] **Step 2: Run the unit tests**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_fu2b_reconcile.py -v`
Expected: ALL pass (extraction + flags + consistency).
- [ ] **Step 3: Commit**
```bash
cd ~/legal-ai
chmod +x scripts/fu2b_reconcile_internal_case_numbers.py
git add scripts/fu2b_reconcile_internal_case_numbers.py
git commit -m "feat(fu2b): chair-gated internal case_number reconciliation script (GAP-07/08)"
```
---
## Task 3: Dry-run against the DB → produce the chair table
**Files:** Produces `data/audit/fu2b-reconciliation-<ts>.{csv,md}`
- [ ] **Step 1: Run the dry-run**
```bash
cd ~/legal-ai && set -a && source ~/.env 2>/dev/null && set +a
PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python
$PY scripts/fu2b_reconcile_internal_case_numbers.py
```
Expected output: `DRY-RUN: 56 rows | will_change=~52 | flagged=~1` (the ~1 = the 8047/23 DUP_CHECK pair → 2 rows flagged). Note the exact numbers.
- [ ] **Step 2: Sanity-check the produced table**
Open `data/audit/fu2b-reconciliation-<ts>.md`. Verify:
- `will_change` rows: each `current_case_number` (full citation) → a clean `proposed_bare` matching the number inside it.
- `flagged` section: should contain the `8047-23` DUP_CHECK pair (ערר + בל"מ) and ideally nothing else (0 MULTI_NUMBER, 0 MISMATCH expected per the analysis).
- If MULTI_NUMBER / MISMATCH rows appear unexpectedly, STOP and report them (the analysis predicted 0; an unexpected flag means the data changed and needs investigation before chair review).
- [ ] **Step 3: Commit the produced table as a review artifact**
```bash
cd ~/legal-ai
git add data/audit/fu2b-reconciliation-*.md data/audit/fu2b-reconciliation-*.csv
git commit -m "chore(fu2b): dry-run reconciliation table for chair review (GAP-07/08)"
```
(If `data/audit/` is gitignored, skip the commit and report the path instead — the table still exists on disk for review.)
---
## Task 4: SCRIPTS.md + PR
- [ ] **Step 1: Register the script in `scripts/SCRIPTS.md`**
Add a row to the active-scripts table (match the file's existing table format) describing `fu2b_reconcile_internal_case_numbers.py`: purpose (FU-2b internal case_number reconciliation, GAP-07/08), status (active, chair-gated), usage (dry-run default / `--apply --approved`).
- [ ] **Step 2: Full suite + commit + push + PR**
```bash
cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/ -q # report summary (expect all pass)
cd ~/legal-ai
git add scripts/SCRIPTS.md
git commit -m "docs(scripts): register fu2b reconciliation script (FU-2b)"
git push -u origin fix/fu2b-identifier-reconciliation
```
Then create the PR via the Gitea REST API (token from `~/.git-credentials`) and merge per the standing PR+merge rule. The PR delivers the **tooling + dry-run table**; the production `--apply` is the separate gated step below.
---
## Task 5: [HUMAN GATE] Chair review + gated apply (NOT automated)
> This task is the chair-approval gate. It is NOT executed by an implementer subagent.
- [ ] **Step 1:** Present `data/audit/fu2b-reconciliation-<ts>.md` to the controller, who presents it to Dafna: the ~52 proposed changes + the `8047-23` ערר/בל"מ DUP_CHECK pair. Dafna confirms the mapping and adjudicates whether 8047/23 is two distinct proceedings (keep both) or a mis-tagged duplicate (manual delete, separate).
- [ ] **Step 2:** Save the reviewed table as `data/audit/fu2b-approved-<ts>.csv` (rows Dafna approved; `will_change=yes` only for those).
- [ ] **Step 3:** Run the gated apply against the DB:
```bash
cd ~/legal-ai && set -a && source ~/.env && set +a
PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python
$PY scripts/fu2b_reconcile_internal_case_numbers.py --apply --approved data/audit/fu2b-approved-<ts>.csv
```
- [ ] **Step 4:** Verify: re-run dry-run → `will_change=0` (idempotent); spot-check `get_case_by_number` still resolves a migrated case; confirm a backup CSV was written (revert path). Mark TaskMaster #67 done.
---
## Self-Review Notes
- **GAP-07/08 (internal)** → Task 2 script + Task 3 dry-run + Task 5 gated apply. Canonical form per X1 (month preserved) — `_extract_bare` replaces only `/`→`-` on the single extracted token, never strips/pads a month.
- **Reversible:** `_apply` writes `fu2b-backup-<ts>.csv` (id, old_case_number) before each UPDATE.
- **Chair gate:** `--apply` requires `--approved <csv>`; production apply is Task 5 (human), not part of the PR merge.
- **Determinism / safety:** 0/>1 token → flagged, never guessed; consistency + DUP_CHECK flags surface the 8047 edge.
- **Scope:** `source_kind='internal_committee'` only (the UPDATE has the `AND source_kind='internal_committee'` guard); external → #68.
- **FK-safe:** verified all 11 `case_law` FKs use `id` (UUID).
- **Type consistency:** `_extract_bare(case_number)->(bare|None,flag)`, `_consistency_flag(bare,citation)->str` — names match tests (Task 1) and script (Task 2).

View File

@@ -0,0 +1,326 @@
# FU-8a: Process→Code Guards — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make two process barriers enforceable in code: `sync_agents_across_companies.py --verify` exits non-zero on any drift (incl. adapter_type mismatch, loud not silent), and a fitness-function test fails the suite if the repo gains raw Paperclip HTTP calls or direct `agent_wakeup_requests` inserts.
**Architecture:** GAP-21 — extract the drift loop into a pure `build_drift_report(...)` and a pure `_verify_exit_code(...)`, then make `--verify` exit `1` on drift. GAP-22 — a self-contained pytest fitness function that scans `web/`, `mcp-server/src/`, `scripts/` for forbidden Paperclip-access patterns with an explicit allowlist. Both pure-code; repo pre-scanned clean (0 existing violations).
**Tech Stack:** Python 3.12, asyncpg (sync script), pytest offline, `.venv` at `mcp-server/.venv`.
**Spec:** [docs/superpowers/specs/2026-05-31-fu8a-process-to-code-guards-design.md](../specs/2026-05-31-fu8a-process-to-code-guards-design.md)
**Run tests:** `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_sync_verify_gate.py tests/test_paperclip_access_guard.py -v`
---
## File Structure
- **Modify** `scripts/sync_agents_across_companies.py` — extract `build_drift_report(...)` + `_verify_exit_code(...)` (pure); `--verify` exits non-zero on drift; adapter_type mismatch + missing-in-mirror counted as drift.
- **Create** `mcp-server/tests/test_sync_verify_gate.py` — offline tests for the two pure functions (imports the script via importlib, like the FU-2b test).
- **Create** `mcp-server/tests/test_paperclip_access_guard.py` — the fitness-function guard (scan + fixtures + real-repo assertion).
- **Modify** `scripts/SCRIPTS.md` — note the new `--verify` gate semantics.
---
## Task 1: GAP-21 — `--verify` becomes an enforceable drift gate
**Files:** Modify `scripts/sync_agents_across_companies.py`; Create `mcp-server/tests/test_sync_verify_gate.py`
- [ ] **Step 1: Write the failing tests**
```python
"""FU-8a / GAP-21: sync --verify drift-gate logic (offline)."""
from __future__ import annotations
import importlib.util
from pathlib import Path
_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "sync_agents_across_companies.py"
_spec = importlib.util.spec_from_file_location("sync_agents", _SCRIPT)
sync = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(sync)
def _agent(name, adapter="claude_code", cfg=None):
return {"id": f"id-{name}", "name": name, "adapter_type": adapter,
"adapter_config": cfg or {"model": "x"}, "runtime_config": {}, "metadata": {},
"budget_monthly_cents": 0, "icon": "", "title": "", "role": "", "agent_api_keys": []}
def test_verify_exit_code_clean_is_zero():
assert sync._verify_exit_code(plan=[], mismatches=[], missing=[]) == 0
def test_verify_exit_code_drift_is_nonzero():
assert sync._verify_exit_code(plan=[("m", "mi", {"x": 1})], mismatches=[], missing=[]) == 1
def test_verify_exit_code_adapter_mismatch_is_nonzero():
# adapter_type mismatch must count as drift (not silent skip)
assert sync._verify_exit_code(plan=[], mismatches=["עוזר משפטי"], missing=[]) == 1
def test_verify_exit_code_missing_is_nonzero():
assert sync._verify_exit_code(plan=[], mismatches=[], missing=["סוכן"]) == 1
def test_build_drift_report_flags_adapter_mismatch():
master = [_agent("A", adapter="claude_code")]
mirror_by_name = {"A": _agent("A", adapter="deepseek_local")}
rep = sync.build_drift_report(master, mirror_by_name, mirror_skills=set(), only=None)
assert "A" in rep["mismatches"]
assert rep["plan"] == [] # mismatch short-circuits the diff
def test_build_drift_report_flags_missing_and_plan():
master = [_agent("A"), _agent("B")]
# A missing in mirror; B present but differing config
mirror_by_name = {"B": _agent("B", cfg={"model": "different"})}
rep = sync.build_drift_report(master, mirror_by_name, mirror_skills=set(), only=None)
assert "A" in rep["missing"]
assert any(p[0]["name"] == "B" for p in rep["plan"])
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_sync_verify_gate.py -v`
Expected: FAIL — `AttributeError: module 'sync_agents' has no attribute '_verify_exit_code'` / `build_drift_report`.
(Note: the script imports `asyncpg` at module top — confirm it imports cleanly under importlib; it does not connect at import time.)
- [ ] **Step 3: Add the two pure functions**
In `scripts/sync_agents_across_companies.py`, add ABOVE `async def main()`:
```python
def build_drift_report(master_agents, mirror_by_name, mirror_skills, only=None) -> dict:
"""Pure drift computation (no DB, no printing). Returns:
{"plan": [(master, mirror, diff), ...], "mismatches": [name, ...], "missing": [name, ...]}.
adapter_type mismatch and missing-in-mirror are recorded as drift, not skipped silently.
"""
plan, mismatches, missing = [], [], []
for m in master_agents:
if only and m["name"] != only:
continue
mirror = mirror_by_name.get(m["name"])
if not mirror:
missing.append(m["name"])
continue
if m["adapter_type"] != mirror["adapter_type"]:
mismatches.append(m["name"])
continue
diff = compute_diff(m, mirror, mirror_skills)
if diff:
plan.append((m, mirror, diff))
return {"plan": plan, "mismatches": mismatches, "missing": missing}
def _verify_exit_code(plan, mismatches, missing) -> int:
"""0 iff fully in sync; 1 if any drift (needs-sync / adapter mismatch / missing-in-mirror)."""
return 1 if (plan or mismatches or missing) else 0
```
- [ ] **Step 4: Rewire `main()`'s drift loop + `--verify` to use them**
In `main()`, REPLACE the inline drift loop (the `plan = []` block through the `for m in master_agents:` loop that builds `plan`) with:
```python
print(f"=== Drift report ===")
report = build_drift_report(master_agents, mirror_by_name, mirror_skills, only=args.only)
plan = report["plan"]
for name in report["missing"]:
print(f"{name:14s} — NOT FOUND in mirror (we never auto-create) — DRIFT")
for name in report["mismatches"]:
m = next(a for a in master_agents if a["name"] == name)
mi = mirror_by_name[name]
print(f"{name:14s} — adapter_type mismatch ({m['adapter_type']} vs {mi['adapter_type']}) "
f"— DRIFT (apply skips it; fix manually in both companies)")
for master, mirror, diff in plan:
print_diff(master["name"], diff, master["id"], mirror["id"])
```
And REPLACE the `if args.verify:` block with:
```python
if args.verify:
code = _verify_exit_code(plan, report["mismatches"], report["missing"])
total_drift = len(plan) + len(report["mismatches"]) + len(report["missing"])
print(f"\nSummary: {len(plan)} need sync, {len(report['mismatches'])} adapter-mismatch, "
f"{len(report['missing'])} missing-in-mirror → {'DRIFT' if code else 'IN SYNC'}")
sys.exit(code)
```
(The `--apply` path still uses `plan` and still does NOT touch adapter_type-mismatch agents — only `--verify`'s exit code changes + the loud reporting.)
- [ ] **Step 5: Run tests + import check**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_sync_verify_gate.py -v` → all PASS.
Run: `.venv/bin/python -c "import importlib.util,pathlib; p=pathlib.Path('scripts/sync_agents_across_companies.py'); s=importlib.util.spec_from_file_location('s',p); m=importlib.util.module_from_spec(s); s.loader.exec_module(m); print('imports')"` (from repo root) → `imports`.
- [ ] **Step 6: Commit**
```bash
cd ~/legal-ai
git add scripts/sync_agents_across_companies.py mcp-server/tests/test_sync_verify_gate.py
git commit -m "feat(sync): --verify exits non-zero on drift; adapter mismatch = loud drift (GAP-21, FU-8a)"
```
---
## Task 2: GAP-22 — Paperclip-access fitness function
**Files:** Create `mcp-server/tests/test_paperclip_access_guard.py`
- [ ] **Step 1: Write the guard + its tests**
```python
"""FU-8a / GAP-22: fitness function — forbid un-sanctioned Paperclip access.
Fails if any scanned source (outside the allowlist) reaches the Paperclip API
with a raw HTTP client or inserts directly into agent_wakeup_requests. The
sanctioned paths are web/paperclip_api.py::pc_request (Python) and scripts/pc.sh
(bash); wakeup must go through POST /api/agents/{id}/wakeup.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[2]
SCAN_ROOTS = [REPO / "web", REPO / "mcp-server" / "src", REPO / "scripts"]
# Files exempt from the HTTP-to-Paperclip rule (the sanctioned helpers + legacy DB-read client).
ALLOWLIST = {
REPO / "web" / "paperclip_api.py", # the sanctioned pc_request helper
REPO / "scripts" / "pc.sh", # the sanctioned bash wrapper
REPO / "web" / "paperclip_client.py", # legacy: DB reads only (no raw http, no wakeup insert)
}
_PC_URL = re.compile(r"PAPERCLIP_API_URL|127\.0\.0\.1:3100|localhost:3100|pc\.nautilus\.marcusgroup\.org")
_HTTP_CLIENT = re.compile(r"\bhttpx\b|\brequests\.(get|post|put|patch|delete)\b|\baiohttp\b|\bcurl\b")
_WAKEUP_INSERT = re.compile(r"insert\s+into\s+agent_wakeup_requests", re.IGNORECASE)
def _scan_text(text: str) -> list[str]:
"""Return violation reasons for a single file's text."""
reasons = []
if _WAKEUP_INSERT.search(text):
reasons.append("direct INSERT INTO agent_wakeup_requests — use the wakeup API")
# raw HTTP to Paperclip: both a paperclip-URL token and an http-client token present
if _PC_URL.search(text) and _HTTP_CLIENT.search(text):
reasons.append("raw HTTP client + Paperclip URL — use web/paperclip_api.pc_request or scripts/pc.sh")
return reasons
def _iter_source_files():
for root in SCAN_ROOTS:
if not root.exists():
continue
for ext in ("*.py", "*.sh"):
for f in root.rglob(ext):
if f in ALLOWLIST or "/.venv/" in str(f) or "/tests/" in str(f):
continue
yield f
def find_violations() -> list[tuple[str, str]]:
out = []
for f in _iter_source_files():
try:
text = f.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
for reason in _scan_text(text):
out.append((str(f.relative_to(REPO)), reason))
return out
# ── the guard catches positives, ignores sanctioned negatives ──────────
def test_scan_flags_raw_http_to_paperclip():
bad = 'import httpx\nasync def f():\n await httpx.post(f"{PAPERCLIP_API_URL}/x")\n'
assert _scan_text(bad)
def test_scan_flags_wakeup_insert():
bad = "await conn.execute('INSERT INTO agent_wakeup_requests (id) VALUES ($1)', x)"
assert _scan_text(bad)
def test_scan_ignores_sanctioned_helper_shape():
ok = 'url = f"{PAPERCLIP_API_URL}{path}"\n# the only place httpx is allowed for paperclip\n'
# this shape WOULD flag if not allowlisted — proving the allowlist is what protects it
assert _scan_text(ok) # raw text matches; the file is protected by ALLOWLIST, not by content
def test_scan_ignores_plain_code():
assert _scan_text("def add(a, b):\n return a + b\n") == []
# ── the real repo must be clean (pre-scanned 2026-05-31: 0 violations) ──
def test_repo_has_no_paperclip_access_violations():
violations = find_violations()
assert violations == [], "Un-sanctioned Paperclip access found:\n" + "\n".join(
f" {f}: {r}" for f, r in violations)
```
- [ ] **Step 2: Run the guard tests**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/test_paperclip_access_guard.py -v`
Expected: ALL PASS — including `test_repo_has_no_paperclip_access_violations` (repo is clean).
If `test_repo_has_no_paperclip_access_violations` FAILS, it found a real violation: either fix the offending code to use the sanctioned helper, or (if it's a genuine sanctioned location) add it to `ALLOWLIST` with a comment justifying it. Report any such case.
- [ ] **Step 3: Commit**
```bash
cd ~/legal-ai
git add mcp-server/tests/test_paperclip_access_guard.py
git commit -m "feat(guard): fitness function blocking raw Paperclip access (GAP-22, FU-8a)"
```
---
## Task 3: SCRIPTS.md + full suite + smoke + PR
- [ ] **Step 1: Note the `--verify` gate semantics in SCRIPTS.md**
In `scripts/SCRIPTS.md`, in the `sync_agents_across_companies.py` row, append to its Purpose cell: "**`--verify` יוצא exit≠0 על drift** (כולל adapter_type-mismatch — מדווח רם, נספר כ-drift) — שמיש כ-gate ל-cron/CI (GAP-21/FU-8a)."
- [ ] **Step 2: Full offline suite**
Run: `cd ~/legal-ai/mcp-server && .venv/bin/python -m pytest tests/ -q`
Expected: all pass (prior suite + the new GAP-21/GAP-22 tests). Report the summary line.
- [ ] **Step 3: Smoke — run `--verify` against the live Paperclip DB (read-only)**
```bash
cd ~/legal-ai && set -a && source ~/.env 2>/dev/null && set +a
PAPERCLIP_BOARD_API_KEY="${PAPERCLIP_BOARD_API_KEY:-}" \
/home/chaim/legal-ai/mcp-server/.venv/bin/python scripts/sync_agents_across_companies.py --verify; echo "exit=$?"
```
Report the output + exit code. Expected: prints a drift report; `exit=0` if agents are in sync, `exit=1` if drift exists (either is a valid result — it proves the gate works). The script only READS in `--verify` (no mutation).
(If the script needs `PAPERCLIP_DB_URL`/board key and they're absent, report that the smoke needs the Paperclip env; the offline unit tests already validate the gate logic.)
- [ ] **Step 4: Commit + PR**
```bash
cd ~/legal-ai
git add scripts/SCRIPTS.md
git commit -m "docs(scripts): note sync --verify drift-gate semantics (FU-8a)"
git push -u origin fix/fu8a-process-to-code-guards
```
Create the PR via the Gitea REST API (token from `~/.git-credentials`) and merge per the standing PR+merge rule.
- [ ] **Step 5: TaskMaster #66 → done** (controller; verify via MCP). GAP-23 remains in #69.
---
## Self-Review Notes
- **GAP-21** → Task 1: `build_drift_report` + `_verify_exit_code` (pure, tested); `--verify` exits 1 on drift; adapter mismatch loud + counted. `--apply` behavior unchanged.
- **GAP-22** → Task 2: fitness function; tested on positive fixtures + sanctioned negatives + the real repo (clean). Allowlist explicit (`paperclip_api.py`, `pc.sh`, legacy `paperclip_client.py`).
- **Repo pre-scanned clean** — Task 2 Step 2's repo assertion passes today; if it ever fails, that's the guard doing its job.
- **No production-data risk** — pure-code; smoke `--verify` is read-only.
- **Type consistency:** `build_drift_report(...)->{plan,mismatches,missing}`, `_verify_exit_code(plan,mismatches,missing)->int`, `find_violations()->[(file,reason)]`, `_scan_text(text)->[reason]` — names match across tasks + tests.
- **GAP-23 out of scope** (#69 / FU-8b).

View File

@@ -0,0 +1,504 @@
# X11 Citation Corroboration — Phase 1 (Signal) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the citation-corroboration **signal** — classify each incoming citation's *treatment*, link it to the *specific halacha* it supports, and expose per-halacha corroboration — **without yet changing the approval gate** (that is Phase 2).
**Architecture:** A new schema version (V24) adds a `treatment` column to `precedent_internal_citations` and a `halacha_citation_corroboration` link table. A new service `corroboration.py` does three deterministic-or-LLM steps: (1) classify treatment of a citing passage via Opus 4.8 (INV-COR2), (2) match a citing passage to one halacha via pgvector cosine over `halachot.embedding` (INV-COR3), (3) aggregate distinct positive citations per halacha (INV-COR4). A read-only MCP tool reports the result with full provenance (INV-COR6). Auto-approval (INV-COR4/G10) and enrichment are **Phase 2** (separate plan).
**Tech Stack:** Python 3.12, asyncpg + pgvector, FastMCP (`@mcp.tool()`), `claude_session.query_json(model=, effort=)`, `embeddings.embed_texts`, pytest (offline deterministic tests mirroring `tests/test_fu2b_reconcile.py`).
**Spec:** [docs/spec/X11-citation-corroboration.md](../../spec/X11-citation-corroboration.md) (INV-COR1COR6). Prerequisite (clean identity graph) is **done** (Shafer merge + corpus scan).
---
## File Structure
- Create: `mcp-server/src/legal_mcp/services/corroboration.py` — the corroboration service (classify · match · aggregate · persist). One responsibility: turn the citation graph into per-halacha corroboration rows.
- Modify: `mcp-server/src/legal_mcp/services/db.py` — add `SCHEMA_V24_SQL` + helpers `store_corroboration`, `list_corroboration_for_halacha`.
- Modify: `mcp-server/src/legal_mcp/server.py` — register read-only MCP tool `halacha_corroboration`.
- Create: `mcp-server/tests/test_corroboration.py` — offline deterministic tests (treatment parse, aggregation, independence rule).
**Treatment vocabulary (INV-COR2):** positive = `{followed, explained}`; negative = `{distinguished, criticized, questioned, overruled}`; neutral = `{mentioned}`. Defined once in `corroboration.py`.
---
## Task 1: Schema V24 — treatment column + corroboration link table
**Files:**
- Modify: `mcp-server/src/legal_mcp/services/db.py` (add `SCHEMA_V24_SQL` constant near the other `SCHEMA_V23_SQL`; register it in `_run_schema_migrations`, db.py:54-ish)
- [ ] **Step 1: Add the schema constant**
Add after the `SCHEMA_V23_SQL = """..."""` block:
```python
SCHEMA_V24_SQL = """
-- X11: citation corroboration (treatment + halacha-level link)
ALTER TABLE precedent_internal_citations
ADD COLUMN IF NOT EXISTS treatment TEXT DEFAULT '';
CREATE TABLE IF NOT EXISTS halacha_citation_corroboration (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
halacha_id UUID NOT NULL REFERENCES halachot(id) ON DELETE CASCADE,
citing_case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE,
citing_decision_id UUID REFERENCES decisions(id) ON DELETE SET NULL,
source_citation_id UUID NOT NULL, -- the precedent_internal_citations / case_law_citations row
treatment TEXT NOT NULL, -- followed/explained/distinguished/criticized/questioned/overruled/mentioned
match_score NUMERIC(4,3) DEFAULT 0, -- cosine(context, rule_statement)
match_context TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (halacha_id, source_citation_id)
);
CREATE INDEX IF NOT EXISTS idx_hcc_halacha ON halacha_citation_corroboration(halacha_id);
"""
```
- [ ] **Step 2: Register it in `_run_schema_migrations`**
In `_run_schema_migrations` (db.py), after `await conn.execute(SCHEMA_V23_SQL)` add:
```python
await conn.execute(SCHEMA_V24_SQL)
```
And update the log line to `"Database schema initialized (v1-v24)"`.
- [ ] **Step 3: Apply + verify against the dev DB**
Run (from `mcp-server/`):
```bash
DOTENV_PATH=/home/chaim/.env DATA_DIR=/home/chaim/legal-ai/data .venv/bin/python -c "
import asyncio; from legal_mcp.services import db
async def m():
pool=await db.get_pool()
cols=await pool.fetch(\"SELECT column_name FROM information_schema.columns WHERE table_name='precedent_internal_citations' AND column_name='treatment'\")
t=await pool.fetchval(\"SELECT to_regclass('halacha_citation_corroboration')\")
print('treatment col:', bool(cols), '| table:', t)
asyncio.run(m())"
```
Expected: `treatment col: True | table: halacha_citation_corroboration`
- [ ] **Step 4: Commit**
```bash
git add mcp-server/src/legal_mcp/services/db.py
git commit -m "feat(db): V24 — citation treatment column + halacha corroboration link table (X11)"
```
---
## Task 2: Treatment classifier (deterministic parse, unit-tested)
The LLM call classifies a citing passage; the **parse/validate** of its output is the deterministic, unit-tested unit (mirrors `halacha_extractor._coerce_halacha`).
**Files:**
- Create: `mcp-server/src/legal_mcp/services/corroboration.py`
- Test: `mcp-server/tests/test_corroboration.py`
- [ ] **Step 1: Write the failing test**
```python
# tests/test_corroboration.py
from __future__ import annotations
import pytest
from legal_mcp.services import corroboration as cor
@pytest.mark.parametrize("raw,expected", [
({"treatment": "followed"}, "followed"),
({"treatment": "OVERRULED"}, "overruled"), # case-insensitive
({"treatment": "bananas"}, "mentioned"), # unknown -> neutral default
({}, "mentioned"), # missing -> neutral default
])
def test_coerce_treatment(raw, expected):
assert cor._coerce_treatment(raw) == expected
def test_treatment_polarity():
assert cor.is_positive("followed") and cor.is_positive("explained")
assert cor.is_negative("distinguished") and cor.is_negative("overruled")
assert not cor.is_positive("mentioned") and not cor.is_negative("mentioned")
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py -q`
Expected: FAIL — `ModuleNotFoundError: corroboration` / `_coerce_treatment` undefined.
- [ ] **Step 3: Write minimal implementation**
```python
# src/legal_mcp/services/corroboration.py
"""X11 citation corroboration — classify treatment, match to halacha, aggregate.
Phase 1: builds the SIGNAL only (no approval changes). See docs/spec/X11.
All LLM calls go through the local claude_session bridge (Opus 4.8 @ xhigh),
same architectural rule as the other extractors (local MCP only).
"""
from __future__ import annotations
import logging
from legal_mcp import config
from legal_mcp.config import parse_llm_json
from legal_mcp.services import claude_session
logger = logging.getLogger(__name__)
TREATMENT_POSITIVE = {"followed", "explained"}
TREATMENT_NEGATIVE = {"distinguished", "criticized", "questioned", "overruled"}
TREATMENT_NEUTRAL = {"mentioned"}
_VALID_TREATMENT = TREATMENT_POSITIVE | TREATMENT_NEGATIVE | TREATMENT_NEUTRAL
def is_positive(t: str) -> bool: return t in TREATMENT_POSITIVE
def is_negative(t: str) -> bool: return t in TREATMENT_NEGATIVE
def _coerce_treatment(raw: dict) -> str:
t = str((raw or {}).get("treatment", "")).strip().lower()
return t if t in _VALID_TREATMENT else "mentioned"
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py -q`
Expected: PASS (3 params + polarity).
- [ ] **Step 5: Add the LLM classifier call (integration, not unit-tested)**
Append to `corroboration.py`:
```python
_TREATMENT_PROMPT = """אתה משפטן בכיר. נתון ציטוט של פסק/החלטה קודמים בתוך החלטה מאוחרת.
סווג כיצד ההחלטה המאוחרת **מטפלת** בתקדים המצוטט, לפי אחת מהקטגוריות:
- followed — אימצה והחילה את ההלכה.
- explained — הסבירה/הזכירה בלי לחלוק.
- distinguished — אבחנה (קבעה שלא חל בנסיבות).
- criticized — מתחה ביקורת בלי לבטל.
- questioned — הטילה ספק.
- overruled — דחתה/ביטלה את ההלכה.
- mentioned — אזכור-אגב בלי טיפול.
החזר JSON בלבד: {"treatment": "<קטגוריה>"}.
"""
async def classify_treatment(cited_citation: str, context: str) -> str:
"""Return one treatment label for how `context` treats `cited_citation`."""
user = f"תקדים מצוטט: {cited_citation}\n\n--- ההקשר המצטט ---\n{context}\n--- סוף ---"
try:
result = await claude_session.query_json(
user, system=_TREATMENT_PROMPT,
model=config.HALACHA_EXTRACT_MODEL or None,
effort=config.HALACHA_EXTRACT_EFFORT or None,
)
except Exception as e:
logger.warning("classify_treatment failed: %s", e)
return "mentioned"
return _coerce_treatment(result if isinstance(result, dict) else {})
```
- [ ] **Step 6: Commit**
```bash
git add mcp-server/src/legal_mcp/services/corroboration.py mcp-server/tests/test_corroboration.py
git commit -m "feat(corroboration): treatment classifier + polarity (INV-COR2, X11)"
```
---
## Task 3: Halacha matcher — pgvector cosine, threshold (INV-COR3)
The matcher links a citing passage to the single best halacha of the cited precedent. The **threshold decision** is the deterministic unit; the pgvector lookup is integration.
**Files:**
- Modify: `mcp-server/src/legal_mcp/services/corroboration.py`
- Modify: `mcp-server/src/legal_mcp/services/db.py` (add `nearest_halacha_for_vector`)
- Test: `mcp-server/tests/test_corroboration.py`
- [ ] **Step 1: Write the failing test (threshold gate is the unit)**
Append to `tests/test_corroboration.py`:
```python
def test_match_accepts_above_threshold():
# (halacha_id, similarity) above floor -> accepted
assert cor.accept_match(("h1", 0.62), floor=0.50) == "h1"
def test_match_rejects_below_threshold():
# below floor -> None (INV-COR3: don't attach to a different legal point)
assert cor.accept_match(("h1", 0.41), floor=0.50) is None
def test_match_rejects_empty():
assert cor.accept_match(None, floor=0.50) is None
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py::test_match_accepts_above_threshold -q`
Expected: FAIL — `accept_match` undefined.
- [ ] **Step 3: Implement the threshold gate + env floor**
Add to `config.py` (near `HALACHA_EXTRACT_*`):
```python
HALACHA_CORROBORATION_MATCH_FLOOR = float(os.environ.get("HALACHA_CORROBORATION_MATCH_FLOOR", "0.50"))
HALACHA_CORROBORATION_MIN_CITES = int(os.environ.get("HALACHA_CORROBORATION_MIN_CITES", "2"))
```
Add to `corroboration.py`:
```python
def accept_match(best: tuple[str, float] | None, floor: float = config.HALACHA_CORROBORATION_MATCH_FLOOR) -> str | None:
"""Return the halacha_id iff similarity clears the floor (INV-COR3)."""
if not best:
return None
halacha_id, sim = best
return halacha_id if sim >= floor else None
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py -q`
Expected: PASS (all, incl. Task 2).
- [ ] **Step 5: Add the pgvector lookup (integration)**
Add to `db.py`:
```python
async def nearest_halacha_for_vector(case_law_id: UUID, vec: list[float]) -> tuple[str, float] | None:
"""Best-matching halacha of `case_law_id` for a context embedding (cosine)."""
pool = await get_pool()
row = await pool.fetchrow(
"SELECT id::text AS id, 1 - (embedding <=> $2) AS sim "
"FROM halachot WHERE case_law_id = $1 AND embedding IS NOT NULL "
"ORDER BY embedding <=> $2 LIMIT 1",
case_law_id, vec,
)
return (row["id"], float(row["sim"])) if row else None
```
- [ ] **Step 6: Commit**
```bash
git add mcp-server/src/legal_mcp/services/corroboration.py mcp-server/src/legal_mcp/services/db.py mcp-server/src/legal_mcp/config.py mcp-server/tests/test_corroboration.py
git commit -m "feat(corroboration): halacha matcher + cosine threshold (INV-COR3, X11)"
```
---
## Task 4: Aggregator — distinct positive citations, negative-flag (INV-COR4)
Pure function: given per-citation results for one halacha, count **distinct** positive citing sources and detect any negative treatment.
**Files:**
- Modify: `mcp-server/src/legal_mcp/services/corroboration.py`
- Test: `mcp-server/tests/test_corroboration.py`
- [ ] **Step 1: Write the failing test**
Append to `tests/test_corroboration.py`:
```python
def _link(src, treatment):
return {"source_id": src, "treatment": treatment}
def test_aggregate_counts_distinct_positive():
links = [_link("d1","followed"), _link("d1","explained"), _link("d2","followed")]
agg = cor.aggregate(links, min_cites=2)
assert agg["positive_sources"] == 2 # d1 counted once (INV-COR4 independence)
assert agg["has_negative"] is False
assert agg["corroborated"] is True
def test_aggregate_negative_blocks():
links = [_link("d1","followed"), _link("d2","followed"), _link("d3","distinguished")]
agg = cor.aggregate(links, min_cites=2)
assert agg["has_negative"] is True
assert agg["corroborated"] is False # any negative -> not corroborated (INV-COR2)
def test_aggregate_below_threshold():
agg = cor.aggregate([_link("d1","followed")], min_cites=2)
assert agg["corroborated"] is False # single source insufficient (INV-COR4)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py::test_aggregate_counts_distinct_positive -q`
Expected: FAIL — `aggregate` undefined.
- [ ] **Step 3: Implement**
Add to `corroboration.py`:
```python
def aggregate(links: list[dict], min_cites: int = config.HALACHA_CORROBORATION_MIN_CITES) -> dict:
"""Aggregate per-halacha corroboration (INV-COR4/COR2).
links: [{"source_id": str, "treatment": str}, ...] already matched to ONE halacha.
positive_sources = count of DISTINCT source_id whose treatment is positive.
has_negative = any negative treatment present.
corroborated = positive_sources >= min_cites AND not has_negative.
"""
positive = {l["source_id"] for l in links if is_positive(l["treatment"])}
has_negative = any(is_negative(l["treatment"]) for l in links)
return {
"positive_sources": len(positive),
"has_negative": has_negative,
"corroborated": len(positive) >= min_cites and not has_negative,
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd mcp-server && .venv/bin/python -m pytest tests/test_corroboration.py -q`
Expected: PASS (all).
- [ ] **Step 5: Commit**
```bash
git add mcp-server/src/legal_mcp/services/corroboration.py mcp-server/tests/test_corroboration.py
git commit -m "feat(corroboration): aggregator — distinct positive + negative-flag (INV-COR4, X11)"
```
---
## Task 5: Orchestration + persistence (build the signal for one precedent)
Wires Tasks 24 over a precedent's incoming citations and stores `halacha_citation_corroboration` rows (INV-COR6 provenance). Integration (no new offline unit).
**Files:**
- Modify: `mcp-server/src/legal_mcp/services/corroboration.py`
- Modify: `mcp-server/src/legal_mcp/services/db.py` (add `store_corroboration`, `incoming_citations_for_precedent`)
- [ ] **Step 1: Add DB helpers**
```python
# db.py
async def incoming_citations_for_precedent(case_law_id: UUID) -> list[dict]:
"""All incoming citations (both graphs) with their context + source id."""
pool = await get_pool()
rows = await pool.fetch(
"SELECT id::text AS source_id, source_case_law_id::text AS citing_case_law_id, "
" NULL::text AS citing_decision_id, match_context AS context "
"FROM precedent_internal_citations WHERE cited_case_law_id = $1 "
"UNION ALL "
"SELECT id::text, NULL, decision_id::text, context_text "
"FROM case_law_citations WHERE case_law_id = $1",
case_law_id,
)
return [dict(r) for r in rows]
async def store_corroboration(halacha_id: str, source_id: str, citing_case_law_id, citing_decision_id, treatment: str, score: float, context: str) -> None:
pool = await get_pool()
await pool.execute(
"INSERT INTO halacha_citation_corroboration "
"(halacha_id, citing_case_law_id, citing_decision_id, source_citation_id, treatment, match_score, match_context) "
"VALUES ($1,$2,$3,$4,$5,$6,$7) "
"ON CONFLICT (halacha_id, source_citation_id) DO UPDATE SET "
"treatment=EXCLUDED.treatment, match_score=EXCLUDED.match_score",
halacha_id, citing_case_law_id, citing_decision_id, source_id, treatment, score, context,
)
```
- [ ] **Step 2: Add the orchestrator**
```python
# corroboration.py
from uuid import UUID
from legal_mcp.services import db, embeddings
async def build_for_precedent(case_law_id: str | UUID) -> dict:
"""For one cited precedent: classify+match+store each incoming citation. Idempotent."""
if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id)
cits = await db.incoming_citations_for_precedent(case_law_id)
linked = 0
for c in cits:
ctx = (c.get("context") or "").strip()
if not ctx:
continue
vecs = await embeddings.embed_texts([ctx], input_type="query")
best = await db.nearest_halacha_for_vector(case_law_id, vecs[0])
halacha_id = accept_match(best)
if not halacha_id:
continue
treatment = await classify_treatment(c.get("citing_case_law_id") or c.get("citing_decision_id") or "", ctx)
await db.store_corroboration(
halacha_id, c["source_id"],
c.get("citing_case_law_id"), c.get("citing_decision_id"),
treatment, best[1], ctx,
)
linked += 1
return {"citations": len(cits), "linked": linked}
```
- [ ] **Step 3: Integration smoke-test on Shafer (the fixed precedent, 7 citations)**
Run (from `mcp-server/`):
```bash
DOTENV_PATH=/home/chaim/.env DATA_DIR=/home/chaim/legal-ai/data .venv/bin/python -c "
import asyncio; from legal_mcp.services import corroboration as cor
print(asyncio.run(cor.build_for_precedent('9024da7b-f408-4b6f-808f-c514a83728e4')))"
```
Expected: `{'citations': 7, 'linked': <0..7>}` and no exception. Inspect `halacha_citation_corroboration` rows for treatment/score sanity.
- [ ] **Step 4: Commit**
```bash
git add mcp-server/src/legal_mcp/services/corroboration.py mcp-server/src/legal_mcp/services/db.py
git commit -m "feat(corroboration): orchestrator + persistence over both citation graphs (X11)"
```
---
## Task 6: Read-only MCP tool `halacha_corroboration`
Exposes per-halacha corroboration + provenance to the chair/agents (INV-COR6). **No approval change** — reporting only.
**Files:**
- Modify: `mcp-server/src/legal_mcp/services/db.py` (add `list_corroboration_for_halacha`)
- Modify: `mcp-server/src/legal_mcp/server.py` (register tool)
- [ ] **Step 1: Add the DB read**
```python
# db.py
async def list_corroboration_for_halacha(halacha_id: UUID) -> list[dict]:
pool = await get_pool()
rows = await pool.fetch(
"SELECT treatment, match_score, match_context, citing_case_law_id::text, "
" citing_decision_id::text, created_at "
"FROM halacha_citation_corroboration WHERE halacha_id = $1 "
"ORDER BY match_score DESC", halacha_id,
)
return [dict(r) for r in rows]
```
- [ ] **Step 2: Register the MCP tool** (copy an existing `@mcp.tool()` idiom in `server.py`)
```python
@mcp.tool()
async def halacha_corroboration(halacha_id: str) -> dict:
"""החזר את ה-corroboration של הלכה: הציטוטים שמתקפים אותה, הטיפול, וסיכום (X11, read-only)."""
from uuid import UUID
from legal_mcp.services import corroboration as cor, db
links = await db.list_corroboration_for_halacha(UUID(halacha_id))
agg = cor.aggregate(
[{"source_id": (l["citing_case_law_id"] or l["citing_decision_id"] or ""), "treatment": l["treatment"]} for l in links]
)
return {"halacha_id": halacha_id, "summary": agg, "citations": links}
```
- [ ] **Step 3: Verify the tool loads** (restart MCP server is required to pick up new tools)
Run: `cd mcp-server && .venv/bin/python -c "from legal_mcp import server; print('halacha_corroboration' in [t.name for t in server.mcp._tool_manager.list_tools()])"`
Expected: `True` (adjust the introspection call to the FastMCP version if needed; the point is the import succeeds and the tool registers).
- [ ] **Step 4: Commit**
```bash
git add mcp-server/src/legal_mcp/services/db.py mcp-server/src/legal_mcp/server.py
git commit -m "feat(mcp): halacha_corroboration read-only tool (INV-COR6, X11)"
```
---
## Out of scope (Phase 2 — separate plan)
- **Auto-approval wiring (INV-COR4/COR5 + G10 behavior):** make `corroborated == True` set `halachot.review_status='approved'` with `reviewer='corroborated (≥N judicial citations)'`, leaving the uncorroborated/negative tail in the chair gate. **Sensitive** — gated on Dafna validating the signal from Phase 1 first.
- **Enrichment (INV-COR3 secondary):** sharpen `rule_statement` from citing framing.
- **Backfill:** run `build_for_precedent` across all precedents with halachot + incoming citations.
- **Treatment backfill of `case_law_citations.citation_type`** (currently default `'support'`).
---
## Self-Review
**Spec coverage:** INV-COR2 (Task 2 classifier + polarity), INV-COR3 (Task 3 match floor + pgvector), INV-COR4 (Task 4 distinct positive + min_cites), INV-COR6 (Task 5 store provenance + Task 6 report). INV-COR1 (principle, no code) and INV-COR5 (chair-gate preserved) are honored by **not** changing approval in Phase 1 — explicitly deferred to Phase 2. ✔
**Placeholders:** none — every code step has concrete content; the LLM-dependent steps carry the exact prompt + I/O contract; tuning values (`MATCH_FLOOR`, `MIN_CITES`) are real env-defaults, not TBDs.
**Type consistency:** `accept_match` returns `halacha_id|None`; `aggregate` consumes `[{"source_id","treatment"}]`; `_coerce_treatment`/`is_positive`/`is_negative` share the `_VALID_TREATMENT` sets. `build_for_precedent` uses `accept_match` + `classify_treatment` + `store_corroboration` with matching signatures. ✔

View File

@@ -0,0 +1,290 @@
# X11 Citation Corroboration — Phase 2 (Wire the approval gate) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Turn the Phase 1 **signal** into an **approval action**. A halacha that is *corroborated* by ≥N distinct positive judicial citations (0 negatives) is auto-approved with citation provenance; a halacha that a later citing court *overruled* is demoted back to the chair gate. Then **backfill** the signal+approval across the whole corpus.
**Gate cleared:** Phase 1's "Out of scope" deferred auto-approval as *"Sensitive — gated on Dafna validating the signal from Phase 1 first."* Dafna validated the signal and approved enabling it (2026-06-01). This plan builds the **active** wiring (default ON, env-tunable kill-switch).
**Architecture:** No schema change — Phase 1's `halacha_citation_corroboration` table already holds the provenance. We add:
1. a pure decision function `approval_action(agg, has_overruled)` (unit-tested, INV-COR2/COR4),
2. DB transitions that move *only* the legal states (`pending_review → approved` on corroboration; `approved → pending_review` on overruled) — never touching `published`/`rejected`,
3. `reconcile_approvals(case_law_id)` called at the tail of `build_for_precedent`,
4. a corpus `build_all()` backfill driver + a **write** MCP tool `corroboration_rebuild`.
**Tech Stack:** Python 3.12, asyncpg, FastMCP, `claude_session` (local Opus 4.8), pytest (offline deterministic).
**Spec:** [docs/spec/X11-citation-corroboration.md](../../spec/X11-citation-corroboration.md) §4 step 5, §5 (INV-COR2/COR4/COR5/COR6), §6 (INV-G10 amendment).
---
## Invariant mapping (what each rule forces here)
- **INV-COR4** — auto-approve requires `positive_sources ≥ N` distinct sources ∧ `has_negative == False`. `aggregate()` (Phase 1) already computes this; Phase 2 only *acts* on `corroborated == True`.
- **INV-COR2** — negative treatment never approves; **overruled** demotes. We split "negative" (blocks approval — already handled by `aggregate`) from **overruled** (actively *demotes an already-approved* halacha back to the chair).
- **INV-COR5** — the chair gate is preserved for the uncorroborated tail and all negatives. We only transition the two legal states; uncorroborated halachot are never touched.
- **INV-COR6** — provenance is retained: `reviewer` records the corroboration basis; the `halacha_citation_corroboration` rows remain the auditable evidence.
- **INV-G10 (amended §6)** — the human gate's *authority source* is cumulative judicial treatment for the corroborated subset; chair gate stays mandatory for the tail. Auto-approval is therefore not "AI judgment" but recorded human (citing-court) judgment (INV-COR1).
**Demotion scope decision (precise reading of §4 step 5):** *any* negative blocks auto-approval (via `aggregate.has_negative`), but only **overruled** actively demotes a halacha that is already approved. `distinguished`/`criticized`/`questioned` block new auto-approval but do not un-approve an existing chair/confidence approval — that stronger action is reserved for `overruled`, and surfaced to the chair via the read tool.
---
## Task 1: Config kill-switch
**Files:** Modify `mcp-server/src/legal_mcp/config.py`
- [ ] **Step 1:** After `HALACHA_CORROBORATION_MIN_CITES` (config.py:69) add:
```python
# X11 Phase 2: gate corroboration → approval. Default ON (Dafna validated the
# Phase 1 signal, 2026-06-01). Set to "false" to disable the auto-approve/demote
# wiring while keeping the signal (Phase 1) intact.
HALACHA_CORROBORATION_AUTO_APPROVE = os.environ.get(
"HALACHA_CORROBORATION_AUTO_APPROVE", "true"
).strip().lower() in ("1", "true", "yes", "on")
```
- [ ] **Step 2: Commit** `feat(config): HALACHA_CORROBORATION_AUTO_APPROVE kill-switch (X11 Phase 2)`
---
## Task 2: Pure decision function `approval_action` (TDD)
The whole approval policy distilled to one deterministic, offline-testable function.
**Files:** Modify `corroboration.py`; Test `tests/test_corroboration.py`
- [ ] **Step 1: Failing test** — append to `tests/test_corroboration.py`:
```python
def test_approval_action_corroborated_approves():
agg = {"positive_sources": 2, "has_negative": False, "corroborated": True}
assert cor.approval_action(agg, has_overruled=False) == "approve"
def test_approval_action_overruled_demotes_even_if_corroborated():
# overruled wins over a positive count (INV-COR2 strong form)
agg = {"positive_sources": 3, "has_negative": True, "corroborated": False}
assert cor.approval_action(agg, has_overruled=True) == "demote"
def test_approval_action_single_source_noop():
agg = {"positive_sources": 1, "has_negative": False, "corroborated": False}
assert cor.approval_action(agg, has_overruled=False) is None
def test_approval_action_negative_nonoverruled_noop():
# distinguished blocks approval but does not demote (no overruled)
agg = {"positive_sources": 2, "has_negative": True, "corroborated": False}
assert cor.approval_action(agg, has_overruled=False) is None
```
- [ ] **Step 2:** Run to verify FAIL (`approval_action` undefined).
- [ ] **Step 3:** Implement in `corroboration.py`:
```python
def approval_action(agg: dict, has_overruled: bool) -> str | None:
"""Decide the corroboration→approval action for ONE halacha (INV-COR2/COR4).
- 'demote' : a later court overruled it → back to the chair gate (overruled
outranks any positive count).
- 'approve' : corroborated (≥N distinct positives, 0 negatives).
- None : leave as-is (single source, non-overruled negative, or tail).
"""
if has_overruled:
return "demote"
if agg.get("corroborated"):
return "approve"
return None
```
- [ ] **Step 4:** Run to verify PASS (all). **Commit** `feat(corroboration): approval_action decision fn (INV-COR2/COR4, X11 Phase 2)`
---
## Task 3: DB transitions (legal states only)
**Files:** Modify `mcp-server/src/legal_mcp/services/db.py`
- [ ] **Step 1:** Add near `update_halacha` (db.py:3480-ish):
```python
async def approve_halacha_by_corroboration(
halacha_id: UUID, n_sources: int, min_cites: int,
) -> bool:
"""Approve a halacha on citation corroboration — ONLY if it is currently
awaiting the chair (pending_review). Never touches 'published'/'rejected'/
already-'approved' (INV-COR5: chair gate preserved for everything else).
Returns True iff a row transitioned."""
pool = await get_pool()
reviewer = f"corroborated ({n_sources} judicial citations ≥ {min_cites})"
row = await pool.fetchrow(
"UPDATE halachot SET review_status='approved', reviewer=$2, "
"reviewed_at=now(), updated_at=now() "
"WHERE id=$1 AND review_status='pending_review' RETURNING id",
halacha_id, reviewer,
)
return row is not None
async def demote_halacha_overruled(halacha_id: UUID) -> bool:
"""Demote an APPROVED halacha back to the chair gate because a later citing
court overruled it (INV-COR2). Only acts on 'approved''pending_review';
leaves 'published'/'rejected'/'pending_review' untouched. The reviewer note
records why it is back in the queue. Returns True iff a row transitioned."""
pool = await get_pool()
row = await pool.fetchrow(
"UPDATE halachot SET review_status='pending_review', "
"reviewer='flagged: overruled by later citation (X11)', "
"reviewed_at=NULL, updated_at=now() "
"WHERE id=$1 AND review_status='approved' RETURNING id",
halacha_id,
)
return row is not None
async def list_corroboration_grouped(case_law_id: UUID) -> dict[str, list[dict]]:
"""Per-halacha corroboration links for a cited precedent, in the
{source_id, treatment} shape `aggregate()` consumes. Distinct citing source
keyed by case_law/decision id (falls back to the citation row id)."""
pool = await get_pool()
rows = await pool.fetch(
"SELECT hcc.halacha_id::text AS halacha_id, "
" COALESCE(hcc.citing_case_law_id::text, hcc.citing_decision_id::text, "
" hcc.source_citation_id::text) AS source_id, "
" hcc.treatment "
"FROM halacha_citation_corroboration hcc "
"JOIN halachot h ON h.id = hcc.halacha_id "
"WHERE h.case_law_id = $1",
case_law_id,
)
out: dict[str, list[dict]] = {}
for r in rows:
out.setdefault(r["halacha_id"], []).append(
{"source_id": r["source_id"], "treatment": r["treatment"]}
)
return out
async def precedents_with_halachot_and_incoming_citations() -> list[str]:
"""case_law ids that have at least one halacha AND at least one incoming
citation (either graph) — the backfill target set."""
pool = await get_pool()
rows = await pool.fetch(
"SELECT c.id::text FROM case_law c "
"WHERE EXISTS (SELECT 1 FROM halachot h WHERE h.case_law_id=c.id) "
" AND (EXISTS (SELECT 1 FROM precedent_internal_citations p "
" WHERE p.cited_case_law_id=c.id) "
" OR EXISTS (SELECT 1 FROM case_law_citations cc "
" WHERE cc.case_law_id=c.id))",
)
return [r["id"] for r in rows]
```
- [ ] **Step 2: Commit** `feat(db): corroboration approve/demote transitions + backfill query (X11 Phase 2)`
---
## Task 4: `reconcile_approvals` + wire into `build_for_precedent` + `build_all`
**Files:** Modify `corroboration.py`
- [ ] **Step 1:** Add to `corroboration.py`:
```python
async def reconcile_approvals(case_law_id: str | UUID) -> dict:
"""Apply the corroboration→approval policy for every halacha of a precedent.
No-op (returns disabled) when the kill-switch is off. INV-COR2/COR4/COR5."""
if not config.HALACHA_CORROBORATION_AUTO_APPROVE:
return {"approved": 0, "demoted": 0, "disabled": True}
if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id)
grouped = await db.list_corroboration_grouped(case_law_id)
approved = demoted = 0
for halacha_id, links in grouped.items():
agg = aggregate(links)
has_overruled = any(l["treatment"] == "overruled" for l in links)
action = approval_action(agg, has_overruled)
if action == "approve":
if await db.approve_halacha_by_corroboration(
UUID(halacha_id), agg["positive_sources"],
config.HALACHA_CORROBORATION_MIN_CITES,
):
approved += 1
elif action == "demote":
if await db.demote_halacha_overruled(UUID(halacha_id)):
demoted += 1
return {"approved": approved, "demoted": demoted, "disabled": False}
```
- [ ] **Step 2:** At the end of `build_for_precedent`, replace the `return` with:
```python
appr = await reconcile_approvals(case_law_id)
return {"citations": len(cits), "linked": linked,
"approved": appr["approved"], "demoted": appr["demoted"]}
```
- [ ] **Step 3:** Add the corpus driver:
```python
async def build_all() -> dict:
"""Backfill: build the signal + apply approvals for every precedent that has
halachot and incoming citations. Idempotent (ON CONFLICT on the link table;
transitions only fire on the legal state)."""
ids = await db.precedents_with_halachot_and_incoming_citations()
totals = {"precedents": 0, "citations": 0, "linked": 0,
"approved": 0, "demoted": 0}
for cid in ids:
r = await build_for_precedent(cid)
totals["precedents"] += 1
for k in ("citations", "linked", "approved", "demoted"):
totals[k] += r.get(k, 0)
logger.info("corroboration backfill %s: %s", cid, r)
return totals
```
- [ ] **Step 4: Commit** `feat(corroboration): reconcile_approvals + build_all backfill (X11 Phase 2)`
---
## Task 5: Write MCP tool `corroboration_rebuild`
**Files:** Modify `mcp-server/src/legal_mcp/server.py`
- [ ] **Step 1:** Add near `halacha_corroboration` (server.py:926):
```python
@mcp.tool()
async def corroboration_rebuild(case_law_id: str = "") -> dict:
"""בנה/רענן את ה-corroboration ויישם אישור-אוטומטי. ריק = כל הקורפוס (backfill);
מזהה-תקדים = תקדים בודד. כותב halacha_citation_corroboration + מעדכן review_status
(corroborated→approved, overruled→pending_review). X11 Phase 2."""
from legal_mcp.services import corroboration as cor
if case_law_id.strip():
return await cor.build_for_precedent(case_law_id.strip())
return await cor.build_all()
```
- [ ] **Step 2:** Verify import/registration:
```bash
cd mcp-server && .venv/bin/python -c "from legal_mcp import server; print('corroboration_rebuild' in [t.name for t in server.mcp._tool_manager.list_tools()])"
```
Expected `True`.
- [ ] **Step 3: Commit** `feat(mcp): corroboration_rebuild write tool (X11 Phase 2)`
---
## Task 6: Backfill the corpus + verify
- [ ] **Step 1:** Snapshot approved/pending counts before.
- [ ] **Step 2:** Run `build_all()` from the venv (`DOTENV_PATH=/home/chaim/.env DATA_DIR=…`). Expect ~12 precedents, no exception, a small number of `approved`/`demoted`.
- [ ] **Step 3:** Verify: every halacha approved-by-corroboration has `reviewer LIKE 'corroborated %'`; no `published`/`rejected` changed; corroboration rows carry treatment+score. Spot-check one approved halacha via `halacha_corroboration`.
- [ ] **Step 4: Commit** any data-audit note under `data/audit/`.
---
## Out of scope (Phase 2 backlog — deliberately deferred)
- **Enrichment (INV-COR3 secondary):** sharpen `rule_statement` from citing framing — **proposal-only**, must not silently rewrite an approved rule. Bigger design; separate plan.
- **Treatment backfill of `case_law_citations.citation_type`** (default `'support'`) — orthogonal to corroboration (which classifies treatment fresh per citation into its own column).
---
## Self-Review
**Spec coverage:** INV-COR2 (overruled demote split from generic negative-block; Task 2/3/4), INV-COR4 (acts only on `corroborated`; Task 2/4), INV-COR5 (only legal-state transitions, tail untouched; Task 3 WHERE clauses), INV-COR6 (reviewer provenance + retained link rows; Task 3), INV-G10 amended (authority = citing courts; not AI; Task 2 comment). ✔
**Safety:** kill-switch default ON but env-disable-able; transitions are directional and bounded by `review_status` WHERE clauses (cannot touch chair-final states); demotion moves toward *more* human review. ✔
**Idempotency:** link table `ON CONFLICT` (Phase 1); approve only fires on `pending_review`, demote only on `approved` → re-runs converge. ✔

View File

@@ -0,0 +1,150 @@
# FU-1 — איחוד מסלול-הקליטה (Unified Ingest Path) — עיצוב
**סטטוס:** מאושר-לעיצוב · **תאריך:** 2026-05-30 · **ענף:** TBD (ייפתח בביצוע)
**מכסה:** GAP-01, GAP-02, GAP-04, GAP-05 · **מספק:** INV-ING1, INV-ING3, INV-G2, INV-G4
**מקורות:** [docs/spec/01-ingest.md](../../spec/01-ingest.md), [docs/spec/gap-audit.md](../../spec/gap-audit.md) · **משימה:** TaskMaster #59 (legal-ai)
**סוג-עבודה:** pure-code · **מיגרציה:** אין (אומת מול DB 2026-05-30 — ראה §6)
---
## 1. הבעיה
שתי פונקציות-קליטה מקבילות לישויות-אחיות, שמשכפלות את צעדי 210 של הפייפליין ומתפצלות
בפרטים:
- `services/precedent_library.py::ingest_precedent` (פסיקה חיצונית, `source_kind='external_upload'`)
- `services/internal_decisions.py::ingest_internal_decision` (החלטות-ועדה, `source_kind='internal_committee'`)
מסלולים מקבילים גוררים drift — צעד שקיים באחד וחסר באחר. הביטוי הקונקרטי: **GAP-02**
המסלול הפנימי מתזמן רק `request_halacha_extraction` ולא `request_metadata_extraction`,
ולכן החלטות-ועדה נקלטו בלי metadata. שש אסימטריות נוספות (GAP-01/04/05) מתועדות ב-01-ingest §4.
## 2. ההכרעה האדריכלית (מאומתת)
**Template Method skeleton + Strategy via config object.** פונקציה קנונית אחת מריצה את
שלד-הפייפליין (סדר-צעדים אכיף); כל מה שמשתנה לפי סוג נישא ב-config object מוזרק (`IntakeSpec`).
שתי הפונקציות הציבוריות נשמרות כ-API בעל-שם ומאצילות לליבה.
| החלטה | נימוק | מקורות (≥3) |
|-------|--------|-------------|
| מסלול קנוני יחיד (לא 2 מקבילים, לא ליבה-משותפת בין 2 כניסות) | Template Method: "אלגוריתמים כמעט-זהים עם הבדלים קטנים" → שלד אחד אוכף סדר-צעדים, צעד-חסר נעשה בלתי-אפשרי | refactoring.guru (Template Method); SourceMaking (Strategy); ADF parameterized-pipelines |
| שמירת `ingest_precedent`/`ingest_internal_decision` כ-API ציבורי | Fowler FlagArgument: "separate methods communicate more clearly"; ליבה משותפת מוסתרת כשהלוגיקה שזורה | martinfowler.com/bliki/FlagArgument; ardalis; luzkan smells |
| ווריאציה ב-config object (`IntakeSpec`), לא boolean-flags | flag-argument הוא code smell; config object נותן בהירות + הרחבה | Fowler; ardalis; dev.to flag-anti-pattern |
| `validate` כ-callable, `enum_fields` כ-data | callable להטרוגני (Strategy idiomatic ב-Python first-class), data להומוגני ("אל תכפה הכל ל-strategy") | Strategy/Wikipedia; Functional-Strategy dev.to; Strategy-in-Python Medium |
| `create_record` כ-callable מוזרק, לא `if source_kind` | Replace Conditional with Polymorphism + factory injection ("tell, don't ask") | refactoring.guru (Replace-Conditional); code-maze (Factory+DI); c-sharpcorner |
## 3. מבנה מודולים
**מודול חדש:** `mcp-server/src/legal_mcp/services/ingest.py`
```
services/ingest.py ← חדש (בית המסלול הקנוני)
├── IntakeSpec (frozen dataclass — מתאר-הסוג)
├── async ingest_document(spec, *, file_path|text, inputs, progress) ← ליבה: צעדים 110
├── _stage_file(src, root, subdir) (אחיד — מאוחד משני הקבצים)
├── _coerce_date / _safe_filename (אחיד — היום משוכפל)
└── _embed_pages(case_law_id, pdf, n) (עובר מ-precedent_library.py — צעד 7 אחיד)
```
**API ציבורי — חתימה ללא שינוי לקוראים:**
- `precedent_library.py::ingest_precedent(...)` → בונה `_EXTERNAL_SPEC`, קורא `ingest.ingest_document(...)`.
- `internal_decisions.py::ingest_internal_decision(...)` → בונה `_INTERNAL_SPEC`, קורא `ingest.ingest_document(...)`.
**לא זז (גבול FU-2):** `db.create_external_case_law` / `db.create_internal_committee_decision`
נשארות נפרדות; מנותבות דרך `IntakeSpec.create_record`. כל שאר הפונקציות בשני קבצי-השירות
(search_*, migrate_*, reextract_*, process_pending_extractions, enrich_*) **לא נוגעים בהן**.
**הקוראים שלא משתנים:** MCP tools (`tools/precedent_library.py`, `tools/internal_decisions.py`)
וה-HTTP API ב-`web/` ממשיכים לקרוא לאותן שתי פונקציות ציבוריות.
## 4. ה-IntakeSpec
```python
@dataclass(frozen=True)
class IntakeSpec:
source_kind: str # 'external_upload' | 'internal_committee'
id_field: str # 'citation' | 'case_number' (לוג/שגיאות)
staging_root: Path # PRECEDENT_LIBRARY_DIR | INTERNAL_DECISIONS_DIR
staging_subdir: Callable[[dict], str] # inputs → subdir (source_type | district | 'other')
validate: Callable[[dict], None] # מרים ValueError (citation-guard / chair_name-חובה)
enum_fields: dict[str, frozenset[str]] # נאכף לשני הסוגים (GAP-04)
derive: Callable[[dict], dict] # שדות-נגזרים (district, proceeding_type); identity לחיצוני
display_name_fallback: str # שם-השדה כשחסר case_name ('citation'|'case_number')
create_record: Callable[..., Awaitable[dict]] # create_external_case_law | create_internal_committee_decision
```
הליבה `ingest_document` **לא יודעת** איזה סוג רץ — רק מפעילה את ה-hooks בנקודות מוגדרות.
## 5. הפייפליין הקנוני (צעדים 110, לפי 01-ingest §2)
סדר-הביצוע בפועל (ה-DB-create מוקדם — נדרש `case_law_id` לפני אחסון chunks; תואם את הקוד הקיים):
| # | צעד | אחיד? | מקור-וריאציה |
|---|------|-------|---------------|
| 1 | ולידציית-קלט + enums | מנגנון אחיד | `spec.validate` + `spec.enum_fields` |
| 2 | גזירת-שדות | מנגנון אחיד | `spec.derive` (identity לחיצוני) |
| 3 | Stage file | מנגנון אחיד | `spec.staging_root` + `spec.staging_subdir` |
| 4 | Extract text (טקסט-ריק = כשל מדווח) | ✅ מלא | — (internal גם מקבל `text` ישיר, בלי קובץ) |
| 5 | Strip Nevo preamble | ✅ מלא | — |
| 6 | **DB create → `case_law_id`** (ספציפי-לסוג) | מנותב | `spec.create_record` (+ `display_name_fallback`) |
| 7 | Chunk (hierarchical/flat לפי `PARENT_DOC_RETRIEVAL_ENABLED`) | ✅ מלא | — (flag, לא סוג) |
| 8 | Embed children + Store chunks | ✅ מלא | — |
| 9 | **Multimodal page-image embed** (flag+PDF+page_count>0) | ✅ מלא | — (**GAP-05 fix**: היה רק בחיצוני) |
| 10 | **Queue metadata extraction** | ✅ מלא | — (**GAP-02 fix**: היה רק בחיצוני) |
| 11 | Queue halacha extraction | ✅ מלא | — |
| 12 | Set statuses (extraction=completed, halacha=pending) | ✅ מלא | — |
> הערה: 01-ingest §2 ממספר 110 בלי למנות מפורשות את ה-DB-create; כאן הוא צעד 6 כי הוא קודם
> ל-chunking בקוד בפועל. שגיאת-עיבוד אחרי create → `extraction_status=failed` (כמו היום).
**אילוץ `claude_session`:** הליבה רק **מתזמנת** (`request_*_extraction` — כתיבת-DB טהורה).
אין import של `halacha_extractor`/`precedent_metadata_extractor` במסלול-הקליטה — נשמר כפי שהיום.
## 6. שינויי-התנהגות וסיכון
| שינוי | השפעה | סיכון |
|--------|--------|--------|
| **GAP-02**: internal עכשיו מתזמן metadata | החלטות-ועדה חדשות יקבלו headnote/summary/tags | אין — תיקון; רשומות קיימות כבר מלאות (0/56 חסר) |
| **GAP-04**: ולידציית-enums על internal | קלט עם practice_area לא-חוקי יידחה בקליטה | נמוך — כל 56 הקיימות חוקיות; בודקים שקוראי-internal מעבירים ערכים חוקיים |
| **GAP-05 multimodal**: internal PDF עכשיו מטמיע עמודים | החלטות-ועדה חדשות PDF יקבלו page-images | אין (non-fatal); קיימות → backfill ב-**TaskMaster 61.2 (FU-3)** |
| **GAP-05 fallback/staging/derive/guard**: מאוחדים | התנהגות זהה, מסלול אחד | אין — citation-guard נשמר ב-`_EXTERNAL_SPEC.validate` |
**אין מיגרציה (אומת מול DB 2026-05-30):** internal_committee = 56 רשומות; metadata חסר = **0**;
enums לא-חוקיים = **0**; multimodal: 14/56 יש (42 חסר → FU-3 #61.2). הריפקטור משנה רק התנהגות
*קדימה*; אינו נוגע בנתונים שמורים.
**Drift מתועד (זניח, מכוון — מסקירת-קוד סופית):**
- **empty-chunks early-return:** כשה-chunker מחזיר ריק על טקסט לא-ריק (נדיר), המקור הציב
`halacha_status=completed` ויצא בלי לתזמן; הקנוני נופל הלאה ומתזמן את שני החילוצים עם
`halacha_status=pending`. עקבי עם INV-ING3 (תיזמון אחיד) — שיפור, לא רגרסיה.
- **thumbnails של multimodal** להחלטות-ועדה יושבים תחת `precedent-library/thumbnails/`
(ממופתח לפי `case_law_id`) — מכוון, מתועד ב-docstring של `spec_thumb_dir`.
- **`queue_halachot`** הוסר כליל (wrapper + `migrate_from_style_corpus`) — הדגל איבד משמעות
תחת INV-ING3; אומת שאין caller שמעביר אותו.
## 7. אסטרטגיית בדיקה
pytest offline עם monkeypatch לכל גבולות-ה-I/O (db, embeddings, chunker, extractor) — כתבנית
[tests/test_search_domain_scope.py](../../../mcp-server/tests/test_search_domain_scope.py)
ו-[tests/test_precedent_corpus_isolation.py](../../../mcp-server/tests/test_precedent_corpus_isolation.py).
קובץ חדש: `mcp-server/tests/test_unified_ingest.py`. רץ עם `.venv` המקומי.
מקרי-בדיקה (TDD — נכשלים לפני, עוברים אחרי):
1. **regression GAP-02**`ingest_internal_decision` מתזמן גם metadata **וגם** halacha (לוכד את הבאג המקורי).
2. שני הסוגים זורמים דרך `ingest.ingest_document` (לא דרך גוף-קוד נפרד).
3. ולידציית-enum דוחה `practice_area` לא-חוקי בשני הסוגים (GAP-04).
4. citation-guard עדיין חוסם ציטוט `ערר`/`בל"מ` במסלול החיצוני.
5. staging-subdir נפתר נכון (source_type לחיצוני, district לפנימי, 'other' ל-fallback).
6. מסלול-`text` (פנימי, בלי קובץ) ומסלול-`file_path` שניהם עובדים.
7. multimodal מותנה flag+PDF+page_count — **לא** בסוג-ה-intake; PDF פנימי → מטמיע, text → לא.
8. fallback לשם-תצוגה: חסר case_name → נופל למזהה הקנוני הנכון לכל סוג.
9. אידמפוטנטיות-חתימה: ערכי-החזרה של שתי הפונקציות הציבוריות נשמרים (תאימות-קוראים).
## 8. סדר-ביצוע
1. כתיבת `test_unified_ingest.py` (אדום).
2. `services/ingest.py``IntakeSpec` + `ingest_document` + הזזת `_embed_pages` + helpers אחידים.
3. `_EXTERNAL_SPEC` + צמצום `ingest_precedent` ל-wrapper.
4. `_INTERNAL_SPEC` + צמצום `ingest_internal_decision` ל-wrapper.
5. הרצת הבדיקות (ירוק) + lint.
6. בדיקת-עשן: import של שני קבצי-השירות + ה-MCP tools (ללא שבירת חתימות).

View File

@@ -0,0 +1,140 @@
# FU-2a — Idempotent Ingest + Write-Time Normalization + `searchable` Flag — עיצוב
**סטטוס:** מאושר-לעיצוב · **תאריך:** 2026-05-30 · **ענף:** TBD
**מכסה:** GAP-03, GAP-06, GAP-13 · **מספק:** INV-ING2, INV-G3, INV-G1, INV-ID1, INV-DM1
**מקורות:** [01-ingest.md](../../spec/01-ingest.md), [02-data-model.md](../../spec/02-data-model.md), [X1-identifiers.md](../../spec/X1-identifiers.md), [gap-audit.md](../../spec/gap-audit.md)
**משימה:** TaskMaster #60 · **תלוי ב:** FU-1 (#59) · **סוג:** pure-code (schema-additive)
**מיגרציה:** אין מיגרציית-מזהים (GAP-07/08 פוצלו ל-#67 / FU-2b). דגל `searchable` נגזר ו-recompute-בלבד.
---
## 1. היקף ומה מחוץ להיקף
FU-2 פוצל (החלטת-יו"ר 2026-05-30) לאחר שבדיקת-DB גילתה נתונים מבולגנים מהצפוי:
~52/56 רשומות `internal_committee` מחזיקות **ציטוט מלא** ב-`case_number`, יש ≥1 כפילות
(`8047-23`), ו-GAP-07 ("with-month canonical") דורש את המספר הרשמי שהוקצה — ידע-יו"ר.
- **בהיקף (FU-2a, כאן):** GAP-03 (upsert idempotent), GAP-06 (נרמול-בכתיבה), GAP-13 (`searchable`).
הכל **pure-code / schema-additive**, משנה התנהגות *קדימה*, אפס מוטציה של מזהים קיימים.
- **מחוץ להיקף (FU-2b, #67):** GAP-07 (תיאום מזהים מעורבים), GAP-08 (ניקוי ציטוט-כמזהה) —
מיגרציית-נתונים שמערבת dedup, סקירת-יו"ר per-record, גיבוי, reversibility.
**אינטראקציה FU-2a↔FU-2b (מתועד):** נרמול-בכתיבה חל רק על כתיבות *חדשות*. רשומות-עבר עם
ציטוט-מלא לא משתנות עד FU-2b. קליטה-חוזרת של רשומת-עבר מבולגנת תיצור רשומה *נקייה* חדשה
(לא תתנגש על המחרוזת השונה) — FU-2b יאחד. זה עקבי עם forward-only של FU-1.
## 2. הכרעות אדריכליות (מאומתות ≥3 מקורות)
| החלטה | נימוק | מקורות |
|-------|--------|--------|
| `INSERT … ON CONFLICT DO UPDATE` במקום SELECT-then-INSERT/UPDATE | אטומי, נטול race תחת Read-Committed; ה-SELECT-then-write הוא read-modify-write קלאסי | PostgreSQL INSERT docs; QueryPlane; on-systems.tech |
| **לחזור על predicate של ה-partial-index ב-ON CONFLICT** | V15 משתמש ב-partial unique indexes; Postgres דורש את ה-predicate ב-conflict target | PostgreSQL INSERT docs (§ON CONFLICT); QueryPlane gotchas |
| נרמול case_number **בכתיבה**, type-aware | נרמול הוא אחריות-גבול-קלט; ערכים שווי-משמעות → פלט זהה. פסיקה-חיצונית: הציטוט *הוא* המזהה → לא לחתוך | DDD value-objects (Medium/dev.to); gojko.net |
| דגל `searchable` **materialized** ונגזר-מחדש, לא מוסק בכל query | reify את חוזה-השלמות; חייב להיות נראה ל-health-check (לא הסקה סמויה) | DevIQ MISU; functional-architecture.org; Stemmler |
## 3. הקבצים
- **Modify** `mcp-server/src/legal_mcp/services/db.py`:
- `create_external_case_law` — להמיר ל-`ON CONFLICT` (target: `(case_number) WHERE source_kind <> 'internal_committee'`); זה גם מטפל בקידום `cited_only``external_upload` (אותו partial-index). לא לחתוך את ה-citation (זהו המזהה).
- `create_internal_committee_decision` — להמיר ל-`ON CONFLICT (case_number, proceeding_type) WHERE source_kind = 'internal_committee'`; לנרמל `case_number` בכניסה.
- `create_case` — לנרמל `case_number` בכניסה (כתיבה).
- הוספת helper `_canonical_case_number(s)` (שם מפורש; עוטף את הטרנספורם הדטרמיניסטי trim·prefix-strip·/→- של X1). `_normalize_case_number` הקיים (read-time) נשאר כ-shim.
- מיגרציית-schema **V21**: `ALTER TABLE case_law ADD COLUMN searchable boolean NOT NULL DEFAULT false`.
- פונקציה `recompute_searchable(case_law_id|all)` — נגזרת מחוזה-השלמות; נקראת בסיום-קליטה ובסיום-חילוץ-metadata.
- **Modify** `mcp-server/src/legal_mcp/services/ingest.py` — בסיום הצלחת הקליטה, לקרוא `db.recompute_searchable(case_law_id)` (אחיד לכל סוג; אחרי setting statuses).
- **Test** `mcp-server/tests/test_idempotent_ingest.py` (חדש) — offline, monkeypatched.
**גבול:** אין שינוי לחתימות הציבוריות של `ingest_precedent`/`ingest_internal_decision` (FU-1).
הנרמול וה-upsert יושבים בשכבת-ה-DB (גבול-הכתיבה), שקופים לקוראים.
## 4. נרמול type-aware (GAP-06)
`_canonical_case_number(s)` — דטרמיניסטי, תואם X1 §1, **לא מוסיף/מסיר חודש**:
```
trim → strip prefix לפני הספרה הראשונה → להחליף '/' ב-'-'
```
| נקודת-כתיבה | מדיניות | נימוק |
|--------------|---------|--------|
| `create_internal_committee_decision` | `_canonical_case_number(case_number)` | המזהה הקנוני = מספר-בסיס מנורמל |
| `create_case` | `_canonical_case_number(case_number)` | תיק פעיל — אותו כלל |
| `create_external_case_law` | `.strip()` בלבד (ללא prefix-strip) | פסיקה חיצונית: ה-citation הוא המזהה הקנוני (X1 §1); חיתוך היה הורס אותו |
> נרמול מטפל ב-prefix+separator בלבד. קלט שהוא ציטוט-מלא (party names, נבו) **לא** מנוקה ל-bare
> ע"י הנרמול — זה GAP-08/FU-2b. FU-2a מבטיח שקלט נקי-יחסית נשמר בצורה קנונית.
## 5. Idempotent upsert (GAP-03)
שתי פונקציות-ה-create עוברות מ-SELECT-then-INSERT/UPDATE ל-`INSERT … ON CONFLICT … DO UPDATE`,
עם **חזרה על ה-predicate** של ה-partial-index (V15):
- **internal:** `ON CONFLICT (case_number, proceeding_type) WHERE source_kind = 'internal_committee' DO UPDATE SET …`
- **external:** `ON CONFLICT (case_number) WHERE source_kind <> 'internal_committee' DO UPDATE SET …`
— מחליף את לוגיקת ה-SELECT הקיימת, **כולל** קידום `cited_only``external_upload` (אותה partial-
index חלה על שניהם; ה-DO UPDATE מקדם את source_kind וממלא שדות חסרים).
**`DO UPDATE` ממוקד:** רק שדות-קלט לא-ריקים דורסים (לשמר ערכים קיימים; `COALESCE(EXCLUDED.x, case_law.x)`),
ולא לדרוס מטא-דאטה שמולא ע"י חילוץ-LLM. אם ל-`case_law` יש טריגרי-`updated_at` — לסנן עם `WHERE`
על שינוי בפועל (gotcha מהמחקר). re-embed בקליטה-חוזרת = INV-ING4, שייך ל-FU-3 — כאן רק upsert-הרשומה.
## 6. דגל `searchable` (GAP-13)
עמודה חדשה `case_law.searchable boolean NOT NULL DEFAULT false`. **נגזרת** מחוזה-השלמות
(02-data-model §2a / INV-DM1), לא מוסקת ב-query:
```
searchable = (
case_number/citation קנוני לא-ריק
AND case_name<>'' AND practice_area<>'' AND source_kind<>''
AND EXISTS(precedent_chunk עם embedding NOT NULL)
AND extraction_status='completed'
AND (headnote<>'' OR summary<>'' OR jsonb_array_length(subject_tags)>0)
)
```
- `recompute_searchable(case_law_id)` נקראת בסיום-קליטה (ingest.py) ובסיום `precedent_metadata_extractor`.
- **Backfill (recompute-בלבד, הפיך):** מיגרציה V21 מריצה `recompute_searchable(all)` פעם אחת על רשומות
קיימות. זו גזירה הפיכה (ניתן להריץ שוב כל רגע) — אינה נוגעת במזהים, לא חלק מ-FU-2b.
- שכבת-החיפוש (`search_*`) תסונן ל-`searchable=true`**שינוי-התנהגות מתועד** (ראה §7).
- health-check יחשוף `count(*) FILTER (WHERE NOT searchable)` (זרע ל-GAP-14/FU-5).
## 7. שינויי-התנהגות וסיכון
| שינוי | השפעה | סיכון |
|--------|--------|--------|
| upsert ON CONFLICT | קליטה-חוזרת = update אטומי, לא כפילות; קידום cited_only נשמר | נמוך — מאומת מול partial-index הקיים |
| נרמול-בכתיבה (internal/cases) | קלט חדש נשמר כ-bare מנורמל | נמוך — type-aware; external לא נחתך |
| `searchable` מסנן חיפוש | רשומות שלא עומדות בחוזה-השלמות **לא יוחזרו** | ⚠️ בינוני — backfill עלול לסמן רשומות-עבר כ-non-searchable. **אימות:** להריץ recompute ב-dry-run ולדווח כמה ירדו מהחיפוש *לפני* הפעלת הסינון |
| backfill searchable | דגל נגזר על רשומות קיימות | נמוך — הפיך, recompute-בלבד, לא נוגע במזהים |
**אזהרת-backlog:** ה-rows עם ציטוט-מלא-כמזהה (FU-2b) עשויים בכל זאת לעמוד בחוזה-השלמות (יש להם
chunks+metadata), כך שהסינון לא בהכרח מפיל אותם. ה-dry-run ב-§7 יכמת זאת לפני הפעלה.
## 8. אסטרטגיית בדיקה
`tests/test_idempotent_ingest.py` — offline, monkeypatch ל-DB pool (או בדיקת-SQL מול sqlite-fallback אם קיים בפרויקט; אחרת monkeypatch כמו FU-1). מקרים:
1. `_canonical_case_number`: `"ערר 8137/24"``"8137-24"`, `"8126-03-25"``"8126-03-25"` (חודש נשמר), `" עע\"מ 1/20 "``"1-20"`.
2. נרמול type-aware: internal מנרמל; external **לא** חותך citation.
3. upsert: קליטה כפולה של אותו (case_number, proceeding_type) internal = רשומה אחת (לא שתיים).
4. upsert: קידום `cited_only``external_upload` על אותו case_number = עדכון, לא כפילות.
5. `DO UPDATE` ממוקד: מטא-דאטה קיים לא נדרס ע"י קלט ריק (COALESCE).
6. `recompute_searchable`: רשומה מלאה→true; חסרת-embedding/metadata/extraction→false.
7. ingest קורא recompute_searchable בסיום (שני הסוגים).
> בדיקת ON CONFLICT האמיתית דורשת Postgres. אם אין מסלול-בדיקה מול DB אמיתי בפרויקט,
> הבדיקות יאמתו את בניית-ה-SQL ואת הלוגיקה הטהורה (normalize, completeness predicate) ב-offline,
> ושכבת-ה-SQL תיבדק ב-smoke מול ה-DB המקומי (5433) ידנית בסיום, מתועד בתוכנית.
## 9. סדר-ביצוע
1. בדיקות אדומות (`test_idempotent_ingest.py`).
2. `_canonical_case_number` + נרמול-בכתיבה ב-3 פונקציות ה-create.
3. המרת שתי create ל-`ON CONFLICT … DO UPDATE` (עם predicate חוזר + COALESCE ממוקד).
4. מיגרציה V21: עמודה `searchable` + `recompute_searchable` + backfill recompute.
5. קריאה ל-`recompute_searchable` מ-ingest.py; חשיפת `count FILTER (WHERE NOT searchable)` ב-health-check.
6. **dry-run** של backfill מול DB 5433 → לדווח כמה רשומות יסומנו `searchable=false` ומאילו source_kind.
7. **שער החלטה (gated):** סינון `searchable=true` בשכבת-החיפוש מופעל **רק אם** ה-dry-run מראה
שאף רשומה לגיטימית לא יורדת מהחיפוש. אם רשומות-עבר לגיטימיות היו נופלות (למשל מבולגנות-FU-2b
שעדיין שמישות) — לדחות את הפעלת-הסינון לפולואו-אפ אחרי FU-2b, ולהשאיר את העמודה+health-check בלבד.
(להציף את ממצא ה-dry-run למשתמש לפני הפעלה — שינוי חיפוש הוא פעולה גלויה.)
8. בדיקות ירוקות + smoke מול DB מקומי + lint.

View File

@@ -0,0 +1,113 @@
# FU-3 — Re-Index on Content Change — עיצוב
**סטטוס:** מאושר-לעיצוב · **תאריך:** 2026-05-30 · **ענף:** TBD
**מכסה:** GAP-09 · **מספק:** INV-DM3, INV-G6, INV-ING4 (freshness) · **משימה:** TaskMaster #61
**תלוי ב:** FU-1 (#59) · **סוג:** pure-code + backfill-hash זול (אפס re-embed בריצה רגילה)
**מיגרציה:** V23 additive (2 עמודות-hash) + backfill-hash דטרמיניסטי הפיך. אין re-embed המוני.
---
## 1. הבעיה (מאומת בקוד)
`embedding` אינו עמודת `GENERATED` (בניגוד ל-tsvectors שמתעדכנים אוטומטית בשינוי-תוכן). חילוץ
embedding דורש קריאת-API, ולכן אי-אפשר להפוך אותו ל-GENERATED. הממצא של מיפוי-הקוד:
- **re-ingest דרך `ingest_document` כבר מבצע re-index נכון** — `_chunk_embed_store` רץ ללא-תנאי
ו-`store_precedent_chunks(_hierarchical)` הן DELETE-then-INSERT. אז המסלול המלא תקין.
- **3 פערים אמיתיים:** (א) אין **גילוי שינוי-תוכן** (אין `content_hash`/`updated_at` ב-case_law);
(ב) אין **נקודת re-index עצמאית** — כדי להטמיע מחדש חייבים לקלוט מחדש את ה**קובץ**, אך רשומות
רבות (למשל 42 החלטות-ועדה) נקלטו מ-`text` בלי קובץ; (ג) אין **גילוי-drift** בין תוכן ל-embeddings.
**אכיפת INV-G6** ("re-index בכל שינוי-תוכן") כשהטמעה אינה GENERATED = **גילוי (hash) + כלי-reindex
מתוכן-שמור + health-check** — בדיוק כדפוס ה-drift של FU-7 (detect-don't-auto-magic).
## 2. הכרעות אדריכליות (מאומתות ≥3 מקורות)
| החלטה | נימוק | מקורות |
|-------|--------|--------|
| `content_hash` (SHA-256 של full_text) לגילוי-שינוי, לא timestamp | hash תוכן הוא הדפוס המומלץ כשאין timestamp מהימן; דטרמיניסטי + collision-safe | Hash-based change detection (DeepWiki); Andy Dote content-hash; moby#9391 |
| re-index **מ-full_text שמור**, לא מ-re-extract/re-OCR | OCR לא-דטרמיניסטי; להשתמש בטקסט השמור (תואם [[feedback_no_reocr_retrofit]]) | RAG re-embed-on-edit (Medium); particula incremental update |
| detect→re-embed **רק שהשתנה** (לא rebuild מלא) + health-check staleness | incremental sync; ניטור recall כשהאינדקס מתיישן | apxml RAG updates; Pinecone/Weaviate (gap-audit) |
| backfill = hash בלבד (לא re-embed) — רשומות קיימות כבר מוטמעות | זול, הפיך, אפס עלות-API; re-embed רק כשתוכן באמת השתנה | — (נגזר מהמצב: 80 רשומות כבר embedded) |
## 3. הקבצים
- **Modify** `services/db.py`: V23 (`content_hash`, `indexed_hash` ב-case_law); `_content_hash(text)`;
כתיבת `content_hash` בכניסת `create_external_case_law`/`create_internal_committee_decision`/`create_case`;
`mark_indexed(case_law_id)` (מעתיק content_hash→indexed_hash); `recompute_content_hashes()` (backfill);
`list_stale_case_law()` (drift query).
- **Modify** `services/ingest.py`: אחרי `_chunk_embed_store` המוצלח → `mark_indexed(case_law_id)`; הוספת
`reindex_case_law(case_law_id)` — טוען row, chunk+embed+store מ-full_text שמור, ואז `mark_indexed`.
- **Modify** `services/metrics.py`: חשיפת `stale_embedding_case_law` count.
- **Add** MCP tool `precedent_reindex(case_law_id)` (wrapper דק ל-`ingest.reindex_case_law`) — מאפשר
הפעלה ידנית; voyage-API בלבד (אין CLI/LLM → בטוח גם בקונטיינר).
- **Test** `tests/test_reindex_on_change.py` (חדש).
**גבול:** אין שינוי לחתימות ציבוריות. `reindex_case_law` הוא **תוסף**; המסלול הקיים לא משתנה.
## 4. content_hash + indexed_hash
- `_content_hash(text) -> str`: `hashlib.sha256(text.encode()).hexdigest()`; על `""`/None → `""`.
- `content_hash` = hash של ה-full_text **הנוכחי**, נכתב בכל כתיבת-row (ב-create_*; גבול-הכתיבה כמו נרמול FU-2a).
- `indexed_hash` = ה-hash שעליו נבנו ה-chunks/embeddings **הנוכחיים**, נכתב ב-`mark_indexed` אחרי
store מוצלח (ב-ingest + ב-reindex).
- **טרי** ⇔ `content_hash = indexed_hash`. **stale**`content_hash IS DISTINCT FROM indexed_hash`
(כולל indexed_hash=NULL = "מעולם לא הוטמע מהתוכן הזה").
## 5. `reindex_case_law(case_law_id)` (GAP-09 enforcement)
```
load case_law row → full_text (שמור)
→ _chunk_embed_store(case_law_id, full_text, page_offsets=None, ...) # אותו מסלול קנוני
→ mark_indexed(case_law_id) # indexed_hash = content_hash
return {chunks, reindexed: true}
```
- **לא** קורא ל-extractor/OCR ולא ל-LLM — רק chunk (טקסט שמור) + embed (voyage) + store. תואם
[[feedback_no_reocr_retrofit]] ו-claude_session (אין CLI).
- multimodal: מדלג (page-images דורשים PDF; רשומות-טקסט אין להן — ראה §7). אם בעתיד יש קובץ — המסלול
המלא של ingest מטפל.
- idempotent (store = DELETE-then-INSERT; mark_indexed דטרמיניסטי).
## 6. גילוי-drift + health-check
- `list_stale_case_law()` → רשומות עם full_text לא-ריק ו-`content_hash IS DISTINCT FROM indexed_hash`.
- health-check (metrics.py) חושף `stale_embedding_case_law` count (INV-G6 observability; אחות ל-
`non_searchable_case_law`/`cases_with_stale_blocks` מ-FU-2a/FU-7).
## 7. #61.2 (multimodal backfill) — נסגר כלא-ישים
בדיקת-DB (2026-05-30): 42 החלטות-ועדה ללא page-images — **כולן** `document_id=NULL` ו-full_text
קיים, ואין PDF מקור בדיסק (`data/internal-decisions/` מכיל קובץ אחד). page-images דורשים **רינדור
PDF**; לרשומות-טקסט אין PDF → **בלתי-אפשרי**. לכן #61.2 נסגר כ-not-applicable. (אם יועלה PDF לאחת —
מסלול-ה-ingest הרגיל יטפל ב-multimodal.) FU-3 core מטמיע-מחדש את ה**טקסט** של כל 42 במידת-הצורך.
## 8. שינויי-התנהגות וסיכון
| שינוי | השפעה | סיכון |
|--------|--------|--------|
| content_hash בכתיבה | כל קליטה חדשה נושאת hash; טרי-מעצם-הקליטה | נמוך — additive |
| mark_indexed ב-ingest | רשומות חדשות = טרי (content=indexed) | נמוך |
| reindex_case_law | re-embed מתוכן שמור; עלות-API לפי-בקשה | נמוך — תוסף, ידני/מבוקר; לא רץ אוטומטית בהמוני |
| backfill hashes | content_hash לכולם; indexed_hash=content רק אם יש chunks, אחרת NULL | נמוך — הפיך, אפס re-embed |
| health-check stale count | חשיפת drift | נמוך — read-only |
## 9. אסטרטגיית בדיקה
`tests/test_reindex_on_change.py` — offline, monkeypatch. מקרים:
1. `_content_hash`: דטרמיניסטי; `""`/None→`""`; טקסט שונה→hash שונה.
2. stale-predicate: content≠indexed → stale; שווים → טרי; indexed=NULL → stale.
3. `mark_indexed` מריץ UPDATE שמעתיק content_hash→indexed_hash (monkeypatch conn).
4. `reindex_case_law`: טוען full_text, קורא _chunk_embed_store ו-mark_indexed (monkeypatch), לא קורא extractor/LLM.
5. create_* כותב content_hash (monkeypatch — assert ה-hash מועבר ל-INSERT/upsert).
> בדיקות-DB אמיתיות (V23, backfill, drift query) — smoke מול DB מקומי (5433) בסיום, כמו FU-2a/FU-7.
## 10. סדר-ביצוע
1. בדיקות אדומות.
2. V23 (`content_hash`,`indexed_hash`) + `_content_hash` + `mark_indexed` + כתיבת content_hash ב-create_*.
3. `reindex_case_law` ב-ingest.py + קריאת `mark_indexed` אחרי `_chunk_embed_store` בקליטה.
4. `list_stale_case_law` + health-check `stale_embedding_case_law`.
5. MCP tool `precedent_reindex`.
6. backfill (DB smoke): `recompute_content_hashes()` — content_hash לכולם, indexed_hash=content אם יש chunks.
7. בדיקות ירוקות + smoke מול DB + lint + סגירת #61.2 + TaskMaster #61.

View File

@@ -0,0 +1,122 @@
# FU-7 — Audit-Trail + Provenance — עיצוב
**סטטוס:** מאושר-לעיצוב · **תאריך:** 2026-05-30 · **ענף:** TBD
**מכסה:** GAP-17, GAP-18, GAP-19, GAP-20 · **מספק:** INV-AUD1, INV-AUD2, INV-AUD3, INV-EX1, INV-G9
**מקורות:** [X5-audit-provenance.md](../../spec/X5-audit-provenance.md), [06-export.md](../../spec/06-export.md), [gap-audit.md](../../spec/gap-audit.md)
**משימה:** TaskMaster #65 · **תלוי ב:** FU-1 (#59) · **סוג:** pure-code (schema-additive קל)
**מיגרציה:** אין. כל השינויים forward-only; backfill קל אופציונלי (provenance של בלוקים קיימים לא נאכף רטרואקטיבית).
---
## 1. מטרה והיקף
X5 §4 קובע את המנגנון הקנוני: **שימוש חוזר ב-`audit_log.log_action` עם `details` JSONB**
לא טבלה חדשה (כלל-הנדסה "סימטריה"). FU-7 ממיר את `audit_log` מ"כמעט-ריק" ל-audit-trail מקצה-לקצה,
מוסיף provenance בלוק→מקורות, אוכף ציטוט→קורפוס, ומגלה drift בין DOCX-החי לבלוקים.
| GAP | בעיה (מאומת בקוד) | יעד FU-7 |
|-----|--------------------|----------|
| GAP-18 | `log_action` נכתב רק ב-`case_subtype_override` (cases.py:203) | קריאות `log_action` ב-4 פעולות משנות-מצב: upload, extract_claims, write_block, export |
| GAP-19 | `decision_blocks` נושא `model_used` בלבד — אין קישור לקטעי-מקור | רשומת provenance ב-`audit_log.details` עם source ids שהזינו את הגנרציה |
| GAP-20 | אין אכיפה שציטוט פתיר לקורפוס | ולידציה דטרמיניסטית של `case_law_id` בציטוטים → flag לבלתי-פתירים |
| GAP-17 | `active_draft_path` הופך SoT אחרי revise/apply בלי re-sync לבלוקים | דגל `blocks_stale` דטרמיניסטי + חשיפת drift ב-health-check (לא re-sync שביר) |
## 2. הכרעות אדריכליות (מאומתות ≥3 מקורות)
| החלטה | נימוק | מקורות |
|-------|--------|--------|
| provenance כ-**event ב-`audit_log` append-only** (details payload), לא עמודה/טבלה חדשה | דפוס lineage בוגר: entity-key + event-type + actor + source-ids; X5 §4 (סימטריה) | Snowflake data-lineage; OvalEdge provenance; DesignGurus append-only audit |
| GAP-17 = **detect + flag**, מקור-אמת=בלוקים, לא auto-resync | auto-remediation דורש rollback אמין; reparse DOCX→blocks שביר (edits שוברים מבנה) | Flux GitOps drift; Terraform drift (env0); Spacelift |
| GAP-20 = **ולידציה מבנית** של `case_law_id` פתיר, לא NLP של ציטוט חופשי | NLP-ציטוט עברי חופף ל-`extract_internal_citations` הקיים; INV-AUD3 מנוסח סביב פתירוּת `case_law_id` | X5 INV-AUD3; RAG attribution (Lewis 2020); ISO 8000 |
| audit כ-**non-fatal** (כשל-audit מתעד warning, לא מפיל פעולה) | git הוא שכבת-השלמות (X5 §2.1); audit_log הוא observability "מי/מה/מתי" | X5 §2.1; דפוס audit fire-safe |
## 3. הקבצים
- **Modify** `tools/audit.py` — אין שינוי לחתימת `log_action`; להוסיף helper `log_action_safe(...)` שעוטף ב-try/except (warning, non-fatal) כדי שכשל-audit לא יפיל את הפעולה.
- **Modify** `tools/documents.py``document_upload` (~:14) + `extract_claims` (~:300): קריאת `log_action_safe`.
- **Modify** `services/block_writer.py``write_block`/`store_block` (~:1010): לאסוף source ids מ-context builders + לכתוב audit `write_block` עם provenance.
- **Modify** `tools/drafting.py``export_docx` (~:384): audit `export_docx`; `revise_draft` (~:647) + `apply_user_edit` (~:569): סימון `blocks_stale=true`.
- **Modify** `services/db.py` — מיגרציה V22: עמודת `cases.blocks_stale boolean DEFAULT false`; helper `mark_blocks_stale(case_id, val)`; helper `resolve_citation_case_law_ids(ids)` (בדיקת קיום); helper `audit_provenance_query` (קריאה — לא חובה).
- **Modify** `services/qa_validator.py` (או היכן שרץ QA) — בדיקת ציטוט→קורפוס: לכל `case_law_id` בציטוטי-הבלוק, אם לא פתיר → ממצא-QA (warning) + audit `citation_unresolved`.
- **Modify** health-check (metrics.py / processing_status) — חשיפת `cases_with_stale_blocks` count.
- **Test** `tests/test_audit_provenance.py` (חדש) — offline, monkeypatched.
**גבול:** אין שינוי לחתימות ציבוריות; אין מיגרציית-נתונים. provenance של בלוקים *קיימים* לא נאכף
רטרואקטיבית (forward-only) — תואם FU-1/FU-2a.
## 4. GAP-18 — audit על כל פעולה משנה-מצב
`log_action_safe(action, case_id=, document_id=, details=, user=)` — עטיפת `log_action` ב-try/except
(כשל → `logger.warning`, ה-action ממשיך). נקודות-הקריאה:
| פעולה | action | details |
|-------|--------|---------|
| document_upload | `"document_upload"` | `{title, doc_type, classification}` |
| extract_claims | `"extract_claims"` | `{docs_processed, claims_count}` |
| write_block (GAP-19) | `"write_block"` | `{decision_id, block_id, model_used, generation_type, source_document_ids, retrieved_case_law_ids, claim_ids}` |
| export_docx | `"export_docx"` | `{path, file_size, block_count}` |
## 5. GAP-19 — provenance בלוק→מקורות
`write_block` כבר אוסף הקשר מ-`_build_source_context` (document chunks), `_build_precedents_context`
(`para_results`/`caselaw_rows``case_law_id`s), `_build_claims_context` (claim ids). היעד: לאסוף את
המזהים הללו ל-dict `sources = {document_ids, case_law_ids, claim_ids}` ולכלול אותו ברשומת ה-audit
`write_block` (§4). כך `audit_log` עונה "מאיזו פסיקה/מסמך נולד הבלוק" — בלי עמודה/טבלה חדשה.
מפתח-הקישור: `details.decision_id`+`details.block_id` (audit_log עצמו keyed ב-case_id/document_id).
## 6. GAP-20 — ציטוט→קורפוס נאכף
`resolve_citation_case_law_ids(ids) -> {resolved: [...], unresolved: [...]}` — בדיקת `EXISTS` מול
`case_law`. בנקודת ה-QA (לפני export, משתלב עם שערי FU-6): לאסוף את כל ה-`case_law_id` מציטוטי-הבלוקים
(`decision_paragraphs.citations` אם מאוכלס, אחרת מ-provenance של §5), ולהריץ resolve. בלתי-פתירים →
**ממצא-QA (warning, לא חוסם-קריטי)** + audit `citation_unresolved`. אכיפה מבנית בלבד (case_law_id),
לא חילוץ-NLP של ציטוט חופשי.
> **הערה:** `decision_paragraphs` אינו מאוכלס כיום ע"י אף כלי (ממצא Explore). לכן ולידציית-הציטוט
> פועלת על ה-`case_law_id`s שנרשמו ב-provenance (§5); אם/כאשר decision_paragraphs יאוכלס — אותה
> ולידציה חלה עליו. זה שומר את ה-GAP סגור בלי לבנות צינור-ציטוטים חדש (מחוץ-להיקף).
## 7. GAP-17 — drift בין DOCX-חי לבלוקים
מקור-אמת = `decision_blocks` (INV-EX1). אחרי `revise_draft`/`apply_user_edit` שהופכים את
`active_draft_path` ל-SoT-בפועל בלי re-sync, מסמנים `cases.blocks_stale=true` (חוזה מפורש: "הבלוקים
ידועים כלא-מסונכרנים מול ה-DOCX-החי"). `export_docx` מ-blocks מאפס `blocks_stale=false` (הבלוקים שוב SoT).
health-check חושף `cases_with_stale_blocks`. **לא** מבצעים reparse DOCX→blocks (שביר).
| נקודה | פעולה על blocks_stale |
|-------|------------------------|
| revise_draft / apply_user_edit | `= true` (DOCX-חי חרג מהבלוקים) |
| export_docx (מ-blocks) | `= false` (בלוקים = SoT שוב) |
| write_block / save_block_content | `= false` (בלוק עודכן ב-DB) |
## 8. שינויי-התנהגות וסיכון
| שינוי | השפעה | סיכון |
|--------|--------|--------|
| audit על 4 פעולות | audit_log מתמלא; observability | נמוך — non-fatal, לא משנה תוצאת-פעולה |
| provenance ב-write_block audit | רשומת מקור לכל גנרציה חדשה | נמוך — forward-only; בלוקים קיימים לא מושפעים |
| ציטוט-QA warning | ציטוט בלתי-פתיר מסומן לאימות-יו"ר | נמוך — warning, לא חוסם export (לא קריטי) |
| `blocks_stale` flag | חשיפת drift; אינו חוסם | נמוך — דגל אינפורמטיבי; V22 additive |
## 9. אסטרטגיית בדיקה
`tests/test_audit_provenance.py` — offline, monkeypatch DB pool. מקרים:
1. `log_action_safe` בולע כשל-DB (warning) ולא מרים.
2. כל אחת מ-4 הפעולות קוראת ל-audit עם ה-action הנכון (monkeypatch log_action, assert call).
3. write_block audit כולל `source_document_ids`/`retrieved_case_law_ids` מה-context.
4. `resolve_citation_case_law_ids`: מפריד resolved/unresolved נכון (monkeypatch EXISTS).
5. ציטוט בלתי-פתיר → ממצא-QA warning (לא חוסם-קריטי).
6. `blocks_stale`: revise/apply → true; export-from-blocks → false.
7. health-check חושף `cases_with_stale_blocks`.
> בדיקות-DB אמיתיות (audit_log INSERT, V22, EXISTS) — smoke מול DB מקומי (5433) בסיום, כמו FU-2a.
## 10. סדר-ביצוע
1. בדיקות אדומות.
2. `log_action_safe` + מיגרציה V22 (`blocks_stale`) + helpers (`mark_blocks_stale`, `resolve_citation_case_law_ids`).
3. GAP-18: 4 קריאות audit (upload, extract_claims, export_docx + write_block בסיס).
4. GAP-19: איסוף source ids ב-write_block → provenance ב-audit.
5. GAP-20: ולידציית-ציטוט ב-QA + audit `citation_unresolved`.
6. GAP-17: `blocks_stale` ב-revise/apply/export/write_block + health-check.
7. בדיקות ירוקות + smoke מול DB + lint + TaskMaster.

View File

@@ -0,0 +1,101 @@
# FU-2b — תיאום מזהי `case_number` (Identifier Reconciliation) — עיצוב
**סטטוס:** מאושר-לעיצוב · **תאריך:** 2026-05-31 · **ענף:** TBD
**מכסה:** GAP-07, GAP-08 (scope: `internal_committee` בלבד) · **מספק:** INV-ID1, INV-ID2, INV-DM2
**משימה:** TaskMaster #67 · **תלוי ב:** FU-2a (#60, פונקציית הנרמול) · **סוג:** **data-migration + chair-gate**
**מחוץ-להיקף:** external_upload → **#68 / FU-2c** (נתונים סותרים, ראה §1).
---
## 1. הבעיה והיקף (מאומת מול DB, 2026-05-31)
`internal_committee` הוא הקורפוס שבו `case_number` חייב להיות **מספר-ועדה מנורמל** (X1 §1), אך
~52/56 רשומות מחזיקות **ציטוט-מלא** בשדה-המזהה (GAP-08 — "החלטות סופר"), בניגוד ל-INV-ID2
(ציטוט = שדה-תצוגה נגזר, לעולם לא מזהה).
**ממצאי-נתונים שמעצבים את המיגרציה:**
- **חילוץ דטרמיניסטי ונקי:** כל 56 הרשומות → בדיוק token-מספר אחד (regex `[0-9]{2,6}(?:[-/][0-9]{1,2}){1,2}`). 0 רב-משמעיים, 0 בלתי-פתירים.
- **עקביות מושלמת:** ב-55/56 המספר המחולץ **מופיע** ב-`citation_formatted`; **0 סתירות**. (1 רשומה בלי citation_formatted — כבר bare.)
- **0 התנגשויות-מפתח** על (bare, proceeding_type) → **אין dedup**.
- **אין בעיית with/without-month:** ה"צורות הכפולות" (1024-24 מול 1024-25 וכו') הן **שנים שונות** = תיקים שונים, לא padding.
- **edge יחיד ליו"ר:** `8047/23` קיים פעמיים — אחת `proceeding_type=ערר`, אחת `בל"מ` (48 chunks כל אחת). לפי X1 אלו **שתי רשומות מובחנות** (ערר מול בל"מ), אך זהות chunk-count מצדיקה אימות-יו"ר שאינן כפילות מתויגת-שגוי.
**external מופרד (#68):** ב-external נמצאה **סתירה** (`case_number=25226-04-25` מול
`citation_formatted=1975/24`) — ה-citation_formatted נוצר בנפרד ואינו ground-truth אמין; דורש
טיפול נפרד. בנוסף, זהות פסיקה-חיצונית היא טבעית הציטוט (אין מספר-ועדה). מחוץ ל-FU-2b.
## 2. ההכרעה (מבוססת X1 + ממצאי-נתונים)
הצורה הקנונית של `case_number` ל-internal = **trim · prefix-strip · `/`→`-`** על המספר הרשמי,
**בלי להמציא/להסיר חודש** (X1 §1; מקורות: Codd 1NF · Kleppmann DDIA · SSOT — verified ב-X1).
המיגרציה **דטרמיניסטית** (לא LLM): מחלצת את ה-token המספרי היחיד ומנרמלת. הציטוט כבר חי
ב-`citation_formatted` — אין מה לנגוע בו.
**דפוס-בטיחות (chair-gated reversible migration):** גיבוי-לפני-שינוי → dry-run שמפיק טבלת-תיאום
**שער-אישור-יו** → apply מפורש → אימות. זהו דפוס סטנדרטי למיגרציה בלתי-הפיכה על נתוני-ייצור.
## 3. הרכיבים
- **סקריפט** `scripts/fu2b_reconcile_internal_case_numbers.py` (לא MCP tool — מיגרציה חד-פעמית מבוקרת):
- `--dry-run` (ברירת-מחדל): מפיק טבלת-תיאום `data/audit/fu2b-reconciliation-<ts>.csv` +
`.md` קריא ליו"ר. עמודות: `id, current_case_number, proposed_bare, proceeding_type,
citation_formatted, consistency_ok, flag`.
- `--apply`: דורש קובץ-אישור (ראה §4); מגבה ואז מבצע.
- מעבד **רק** `source_kind='internal_committee'` ו**רק** רשומות שבהן `proposed_bare != case_number`
(idempotent — already-bare לא נוגעים).
- **חילוץ:** `_extract_bare(case_number) -> str|None` — regex token יחיד + `_canonical_case_number`
(מ-FU-2a, db.py) לנרמול הסופי. אם 0 או >1 tokens → `None` + flag `NEEDS_CHAIR`.
- **consistency guard:** אם `proposed_bare` **לא** מופיע ב-`citation_formatted` → flag `MISMATCH` (לא
יוחל אוטומטית; ליו"ר). (כיום 0 כאלה, אך הסקריפט בודק בזמן-ריצה.)
- **גיבוי:** לפני apply, כתיבת `data/audit/fu2b-backup-<ts>.csv` = `(id, old_case_number)` לכל רשומה
שתשונה → revert-script טריוויאלי.
- **edge 8047/23:** הסקריפט **לא** ממזג; מסמן את הזוג ב-flag `DUP_CHECK` בטבלה. ההכרעה (מובחנות מול
כפילות) היא של היו"ר; אם כפילות — מחיקה ידנית נפרדת (לא חלק מה-apply הדטרמיניסטי).
## 4. שער-אישור-היו"ר (chair gate)
1. הרצת `--dry-run` → טבלת-תיאום (`.md`) + סיכום (כמה ישתנו, אילו flags).
2. **הצגה לדפנה**: הטבלה (52 שורות: ציטוט-נוכחי → bare מוצע) + ה-edge של 8047/23. היא מסמנת
שורות שגויות (אם יש) ומכריעה על 8047/23.
3. תיקון flags לפי הערותיה (אם יש), ואז `--apply --approved data/audit/fu2b-approved-<ts>.csv`
(קובץ-האישור = הטבלה לאחר סקירתה; הסקריפט מחיל רק שורות שאושרו).
4. אימות אחרי apply: כל internal `case_number` תואם regex bare; 0 ציטוטים בשדה-המזהה;
`search`/`get_case_by_number` עדיין פותרים (FU-2a tolerant-read + הנרמול).
## 5. אינטראקציה עם FU-2a (forward-consistency)
FU-2a `_canonical_case_number` מנרמל prefix+separator אך **אינו מחלץ מספר מתוך ציטוט-מלא**. לכן
אם קליטה עתידית תעביר ציטוט-מלא כ-`case_number`, ייווצר שוב מזהה מלוכלך. **הערכת-סיכון:** נמוכה —
טופס-ההעלאה וה-MCP tool מעבירים שדה-`case_number` נפרד (בד"כ נקי). **החלטה:** FU-2b הוא ניקוי-נתונים
בלבד; הקשחת-כתיבה (חילוץ-token גם ב-create) **לא בהיקף** — תיפתח רק אם יתגלה caller שמעביר ציטוט.
(מתועד; לא לשנות התנהגות-כתיבה בלי ראיה.)
## 6. שינויי-התנהגות וסיכון
| שינוי | השפעה | סיכון |
|--------|--------|--------|
| `case_number` של ~52 internal → bare | חיפוש exact-match על המספר עובד; (case_number,proceeding_type) נקי | נמוך — דטרמיניסטי, גיבוי, שער-יו"ר, 0 collisions |
| 8047/23 edge | אולי מחיקת רשומה כפולה | בינוני — **רק** בהחלטת-יו"ר, מחיקה ידנית נפרדת, לא ב-apply האוטומטי |
| citation_formatted | **לא משתנה** (כבר תקין) | אין |
| FK/relations | `case_law_relations`/`precedent_internal_citations` מפנים ל-`id` (UUID), לא ל-case_number | אין — שינוי case_number לא שובר קשרים |
| chunks/embeddings | מפתח-זר `case_law_id` (UUID) — לא תלוי ב-case_number | אין — re-index לא נדרש |
## 7. אסטרטגיית בדיקה
- **בדיקות-יחידה offline** (`tests/test_fu2b_reconcile.py`): `_extract_bare` — token יחיד→bare מנורמל;
ציטוט מלא→המספר הנכון (דוגמאות אמיתיות: `"ערר (...) 403/17 אהרון ברק..."``403-17`,
`"...8136-10-24 שחר..."``8136-10-24` חודש נשמר); 0/רב-token→None+flag; consistency guard.
- **dry-run מול DB מקומי**: הטבלה מופקת, מספר-השורות-לשינוי = ~52, 0 MISMATCH, 1 DUP_CHECK (8047).
- **apply בסביבת-בדיקה**: על עותק/תיק-בדיקה — אימות idempotency (הרצה שנייה = 0 שינויים) + revert מהגיבוי.
- ה-apply בייצור רץ **רק אחרי אישור-יו** (לא חלק מה-CI/PR; ידני ומבוקר).
## 8. סדר-ביצוע
1. בדיקות אדומות ל-`_extract_bare` + consistency guard.
2. `_extract_bare` + הסקריפט (`--dry-run` בלבד תחילה) + הפקת טבלת-תיאום + גיבוי.
3. בדיקות ירוקות + dry-run מול DB → הפקת הטבלה.
4. **עצירה: הצגת הטבלה + 8047/23 ליו"ר (דפנה)** — שער-אישור.
5. (אחרי אישור) מימוש `--apply --approved` + אימות + revert-script.
6. הרצת apply בייצור (מבוקר) + אימות-אחרי + TaskMaster #67.
> צעדים 13 לא דורשים את דפנה (אני מכין הכל). צעד 4 הוא שער-האישור. צעדים 56 אחרי אישורה.

View File

@@ -0,0 +1,92 @@
# FU-5 — Retrieval Eval Harness + Backlog Visibility (design)
**Task:** #63 (legal-ai tag) · **Covers:** GAP-11, GAP-14 · **Provides:** INV-RET4, G8, INV-QA1, G10
**Status:** approved 2026-05-31 (gold-set strategy = hybrid, chair decision). Technical architecture
decided per `feedback_research_architecture_decisions` (chair adjudicates domain, not architecture).
## Problem
1. **GAP-11 (INV-RET4/G8):** retrieval quality is never measured. Only `telemetry.log_search_bg`
records queries (observation, not evaluation). No gold-set, no precision/recall. Every RRF-weight
/ `k` / embedder change is tuned "by feel".
2. **GAP-14 (INV-QA1/G10):** the halacha review backlog (`review_status='pending_review'`) is
invisible — the 10/19-approved gap was found by accident. The human gate has no visibility.
## Two independent units
### Unit A — Retrieval eval harness (GAP-11)
**Existing leverage:** `search_relevance_feedback` already captures a real ground-truth signal —
when a finalized decision cites a precedent, `infer_relevance_from_citations` marks it
`relevance_score=3` against the `search_logs` where it appeared (telemetry.py). This bootstraps the
gold-set without hand-labeling.
**A1. Gold-set — versioned file `data/eval/gold-set.jsonl`** (single SoT; reviewable/diffable/
chair-editable). One JSON object per line:
```json
{"id":"g001","query":"...","practice_area":"betterment_levy",
"corpus":"precedent_library|internal_decisions",
"relevant_case_law_ids":["uuid",...],"source":"bootstrap|chair","note":""}
```
**A2. Bootstrap generator — `scripts/eval_gold_bootstrap.py`** (host-side, mcp-server venv):
reads `search_relevance_feedback` (score=3) ⨝ `search_logs`, groups by normalized query →
relevant `case_law_id` set, emits `source=bootstrap` entries. Idempotent: re-run regenerates the
bootstrap section; never overwrites `source=chair` rows. **Chair gate:** Dafna reviews the file,
corrects/augments, promotes entries to `source=chair`.
**A3. Harness — `scripts/eval_retrieval.py`** (host-side, mcp-server venv; needs POSTGRES + VOYAGE):
runs the **production retrieval path** (same service functions the MCP search tools call) for each
gold query, computes per-query **precision@k, recall@k, MRR, nDCG@k** (k∈{5,10}); relevant = gold
ids. Aggregates mean overall + per corpus + per practice_area. Writes
`data/eval/eval-report-<ts>.{json,md}`, prints a summary, and a delta vs the committed
`data/eval/baseline.json`. `--update-baseline` rewrites the snapshot.
**"CI gate" — realized as discipline, not automation.** Retrieval needs the prod DB + Voyage API;
no CI runner has that access. The gate is: re-runnable harness + committed `baseline.json` + a
documented "run before/after any retrieval-layer change, attach the delta" rule (SCRIPTS.md). A true
automated CI gate would require a separate frozen corpus fixture — out of scope, noted as future.
**Scope:** the two precedent corpora (`search_precedent_library` + `search_internal_decisions`),
where the citation signal exists. `search_decisions`/`search_case_documents` return case-document
chunks (not `case_law`) and carry no citation ground-truth — deliberately out of scope.
**Metrics rationale:** precision@k + recall@k are spec-required (INV-RET4). MRR (first-relevant
rank) and nDCG@k (graded, position-weighted) are standard IR complements (Manning et al., 2008) —
nDCG matches the telemetry docstring's stated nDCG@10 aspiration.
### Unit B — Backlog visibility (GAP-14) — pure code
Expose the halacha review backlog where health is already surfaced:
- **`metrics.get_dashboard()`** (mcp-server/src/legal_mcp/services/metrics.py) — add
`halacha_backlog: {pending_review, approved, rejected, published, total, oldest_pending_at}` from
`halachot.review_status` + `min(created_at) where pending_review`. Surfaces through the
`get_metrics` MCP tool (agents + dashboard).
- **`/api/system/diagnostics`** (web/app.py) — add the same `halacha_backlog` block to the health
snapshot.
## Files
| File | Unit | Kind | Deploy |
|------|------|------|--------|
| `scripts/eval_gold_bootstrap.py` | A2 | new, host-side | none |
| `scripts/eval_retrieval.py` | A3 | new, host-side | none |
| `data/eval/gold-set.jsonl` | A1 | data (on disk; chair-reviewed) | none |
| `data/eval/baseline.json` | A3 | committed snapshot | none |
| `mcp-server/src/legal_mcp/services/metrics.py` | B | edit `get_dashboard` | Coolify |
| `web/app.py` | B | edit diagnostics | Coolify |
| `scripts/SCRIPTS.md` | A | doc | none |
## Test strategy
- Bootstrap: idempotent (re-run = same bootstrap rows; chair rows untouched); 0 chair rows clobbered.
- Harness: metric math unit-verified offline on a synthetic (ranking, relevant-set) fixture
(precision@k / recall@k / MRR / nDCG@k against hand-computed values) before any DB run.
- Unit B: `get_metrics` (no case_number) returns `halacha_backlog` with counts summing to total;
diagnostics endpoint returns the same block. Verified against prod counts.
## Chair gate (domain — the only thing requiring Dafna)
After bootstrap produces `gold-set.jsonl`, Dafna reviews: are these queries representative, and are
the marked precedents the *correct* answers? Her edits make the gold-set authoritative. Until then
the baseline is "provisional (bootstrap-only)".

View File

@@ -0,0 +1,78 @@
# FU-8a — מחסומי-תהליך → מחסומי-קוד (Process Barriers → Code Guards) — עיצוב
**סטטוס:** מאושר-לעיצוב · **תאריך:** 2026-05-31 · **ענף:** TBD
**מכסה:** GAP-21, GAP-22 · **מספק:** INV-MC1, INV-INT1, INV-INT3 · **משימה:** TaskMaster #66
**תלוי ב:** — · **סוג:** pure-code · **מחוץ-להיקף:** GAP-23 (חיווט ספ→סוכנים) → #69 / FU-8b.
---
## 1. הבעיה
שני מחסומים שהיום נשענים על **נוהל אנושי** ולא על **קוד**, ולכן ניתנים להפרה שקטה:
- **GAP-21 (INV-MC1):** סנכרון-סוכנים חוצה-חברות (`sync_agents_across_companies.py`) ידני ולא-נאכף.
ב-`--verify` (script:397) הוא **יוצא 0 גם כשיש drift**, ו-adapter_type-mismatch (script:388)
מודפס כ-"SKIPPING" ו**נבלע** — אין סיגנל-כשל שניתן לתלות בו gate.
- **GAP-22 (INV-INT1/INT3):** אין מחסום-קוד נגד עקיפת ה-helpers המאושרים של Paperclip — קריאת
`httpx`/`requests` גולמית ל-API של Paperclip (במקום `web/paperclip_api.pc_request`) או `INSERT`
ישיר ל-`agent_wakeup_requests` (במקום ה-wakeup API). היום זה כלל-נוהל ב-CLAUDE.md/HEARTBEAT בלבד.
## 2. ההכרעה (מאומתת ≥3 מקורות)
**Architectural fitness functions** — הופכים החלטת-ארכיטקטורה מ"הסכמה חברתית" ל**כלל נאכף שנתפס
ברגע ההפרה**, כ-assertion דמוי-טסט ב-CI. שני המחסומים מיושמים ככאלה:
| החלטה | נימוק | מקורות |
|-------|--------|--------|
| GAP-21: `--verify` יוצא **non-zero על drift**; adapter_type-mismatch = **drift** (לא silent skip) + דיווח רם | drift-verify הוא gate רק אם הוא יוצא non-zero (Terraform 0/2); "alert, don't skip silently" | InfoQ fitness-functions; Firefly CI-drift; Spacelift drift |
| GAP-22: **fitness-function (pytest שסורק את ה-repo)**, לא import-linter | הכלל הוא דפוס-*שימוש*/מחרוזת (http ל-URL, מחרוזת-SQL), לא גבול-import; fitness-function כ-test-assertion ב-CI הוא הכלי | InfoQ; Lukas Niessen; aipatternbook |
| לרוץ ב-**חבילת-הטסטים הקיימת** (לא CI חדש) | אין CI-lint בפרויקט (רק deploy.yaml); חבילת ה-pytest היא שער-האיכות הקיים | (נגזר מהמצב) |
## 3. הרכיבים
- **Modify** `scripts/sync_agents_across_companies.py` (GAP-21):
- `--verify` יחזיר **exit 1** כש-`plan` לא-ריק **או** כשיש adapter_type-mismatch (כיום `return` שקט).
- adapter_type-mismatch: נספר ל-`mismatches` ומדווח רם (`❌`), לא רק "SKIPPING"; נכלל בסיגנל-הכשל
של `--verify`. (ה-skip עצמו ב-`--apply` נשמר — לא מסנכרנים adapter_type אוטומטית — אבל `--verify`
**נכשל** כדי לאלץ טיפול ידני.)
- **Create** `mcp-server/tests/test_paperclip_access_guard.py` (GAP-22) — fitness-function שסורק את
עץ-המקור (`web/`, `mcp-server/src/`, `scripts/`, `plugin-legal-ai/` אם רלוונטי) ו**נכשל** אם נמצא:
1. קריאת-HTTP גולמית ל-Paperclip — `httpx`/`requests`/`aiohttp` עם `PAPERCLIP_API_URL` או
`localhost:3100`/`pc.nautilus`**מחוץ** ל-`web/paperclip_api.py` (ה-helper המאושר).
2. `INSERT INTO agent_wakeup_requests` (כל קובץ) — חייב לעבור דרך wakeup API.
3. `curl ... $PAPERCLIP_API_URL` ב-shell — מחוץ ל-`scripts/pc.sh`.
- מימוש: סריקת-טקסט ממוקדת (regex) עם **allowlist** מפורש (הקבצים המאושרים) + הודעת-כשל שמסבירה
את ה-helper הנכון. (AST מלא מיותר — הדפוסים הם מחרוזות-URL/SQL, לא מבנה-קוד.)
- **Create** `scripts/check_paperclip_access.py` (GAP-22, אופציונלי-דק) — wrapper הניתן להרצה ידנית/CI
שמריץ את אותה לוגיקת-סריקה (מייבא מהטסט או חולק helper); exit non-zero על הפרה. (אם הטסט מספיק —
לדלג, YAGNI.)
## 4. שינויי-התנהגות וסיכון
| שינוי | השפעה | סיכון |
|--------|--------|--------|
| `--verify` יוצא 1 על drift | הופך ל-gate שמיש (אפשר לתלות בו cron/CI) | נמוך — לא משנה `--apply`; משנה רק exit-code של verify |
| adapter_type-mismatch רם + נכלל ב-fail | drift של adapter לא נבלע | נמוך — דיווח; ה-skip ב-apply נשמר |
| fitness-function guard | הפרה עתידית תיכשל בטסטים | נמוך — **ה-repo נסרק 2026-05-31: 0 הפרות קיימות** (אין httpx גולמי ל-Paperclip, אין INSERT ל-agent_wakeup_requests, אין curl גולמי). הגדר הוא גדר-קדימה נקי, אפס תיקוני-קוד קיימים |
| allowlist | קבצים מאושרים (paperclip_api.py, pc.sh) פטורים | נמוך — מפורש ומתועד |
## 5. אסטרטגיית בדיקה
- **GAP-22 fitness-function** נבדק על עצמו: הטסט מאתר דפוס-הפרה מוזרק (fixture עם httpx ל-Paperclip)
ומאשר שהוא נתפס; ומאשר שה-helpers המאושרים (paperclip_api.py) **לא** מסומנים. כלומר הטסט בודק
את הסורק על דוגמאות חיוביות+שליליות, ואז מריץ אותו על ה-repo האמיתי (חייב לעבור — אחרת יש הפרה
קיימת לתקן).
- **GAP-21** — בדיקת-יחידה ללוגיקת ה-exit/mismatch: מתוך master/mirror סינתטיים, `--verify` עם drift
מחזיר 1; ללא drift מחזיר 0; adapter_type-mismatch → 1 + הודעה. (refactor של לוגיקת-ההכרעה לפונקציה
טהורה `_verify_exit_code(plan, mismatches)` שניתנת לבדיקה offline בלי DB/Paperclip.)
- חבילה מלאה ירוקה; smoke: `sync...py --verify` מול ה-state הנוכחי (לדווח אם drift קיים).
## 6. סדר-ביצוע
1. בדיקות אדומות: `_verify_exit_code` (GAP-21) + סורק ה-guard על fixtures (GAP-22).
2. GAP-21: refactor `--verify` ל-`_verify_exit_code` + ספירת mismatches + exit 1 + דיווח רם.
3. GAP-22: סורק (`tests/test_paperclip_access_guard.py`) + allowlist; **הרצה על ה-repo** וטיפול בהפרות קיימות (תיקון או allowlist מנומק).
4. (אופציונלי) `scripts/check_paperclip_access.py` אם רוצים הרצה עצמאית.
5. חבילה ירוקה + smoke (`--verify`) + SCRIPTS.md (אם נוסף סקריפט) + PR+merge + TaskMaster #66.
> **GAP-23 (#69)** — חיווט הספ ל-HEARTBEAT/סוכנים — מחוץ-להיקף (משנה התנהגות-ייצור, דורש החלטה).

View File

@@ -42,6 +42,38 @@ POSTGRES_URL = os.environ.get(
# Redis # Redis
REDIS_URL = os.environ.get("REDIS_URL", "redis://127.0.0.1:6380/0") REDIS_URL = os.environ.get("REDIS_URL", "redis://127.0.0.1:6380/0")
# Claude CLI — model + effort for halacha extraction.
# All LLM calls go through the local `claude -p` CLI (claude_session.py).
# By default the CLI uses the developer's session default model with no
# explicit effort. For halacha extraction we pin Opus 4.8 @ xhigh: the
# 2026-05-31 A/B (scripts/ab_halacha_opus48.py) showed it cuts over-extraction
# (~124→51 on שטיין) at 100% quote-verification with honest confidence
# calibration. Env-overridable so the model/effort can be tuned without a
# code change (set to "" to fall back to the CLI default). Other extractors
# (claims, metadata, block-writing, QA) keep the CLI default unless similarly
# pinned.
HALACHA_EXTRACT_MODEL = os.environ.get("HALACHA_EXTRACT_MODEL", "claude-opus-4-8")
HALACHA_EXTRACT_EFFORT = os.environ.get("HALACHA_EXTRACT_EFFORT", "xhigh")
# Effort for BULK queue-drain extraction (process_pending over many precedents).
# xhigh is the quality sweet-spot for a single precedent but very slow at scale
# (a 64-chunk case ≈ 20 min). Bulk drains use a lighter effort to cut wall-clock;
# interactive single re-extraction keeps HALACHA_EXTRACT_EFFORT (xhigh). Tune via
# env (set to 'xhigh' to make bulk match single, or 'medium' for max speed).
HALACHA_BULK_EXTRACT_EFFORT = os.environ.get("HALACHA_BULK_EXTRACT_EFFORT", "high")
# Concurrent chunks WITHIN a single extraction. Each `claude -p` @ xhigh holds
# ~300MB RSS + heavy CPU; cross-process overlap (agent retries) on top of this
# froze the box on 2026-05-31 (hard reboot). A global advisory lock now caps
# the system to ONE extraction at a time; this caps the chunks within it.
HALACHA_CHUNK_CONCURRENCY = int(os.environ.get("HALACHA_CHUNK_CONCURRENCY", "3"))
HALACHA_CORROBORATION_MATCH_FLOOR = float(os.environ.get("HALACHA_CORROBORATION_MATCH_FLOOR", "0.50"))
HALACHA_CORROBORATION_MIN_CITES = int(os.environ.get("HALACHA_CORROBORATION_MIN_CITES", "2"))
# X11 Phase 2: gate corroboration → approval. Default ON (Dafna validated the
# Phase 1 signal, 2026-06-01). Set to "false" to disable the auto-approve/demote
# wiring while keeping the Phase 1 signal intact.
HALACHA_CORROBORATION_AUTO_APPROVE = os.environ.get(
"HALACHA_CORROBORATION_AUTO_APPROVE", "true"
).strip().lower() in ("1", "true", "yes", "on")
# Voyage AI # Voyage AI
VOYAGE_API_KEY = os.environ.get("VOYAGE_API_KEY", "") VOYAGE_API_KEY = os.environ.get("VOYAGE_API_KEY", "")
VOYAGE_MODEL = os.environ.get("VOYAGE_MODEL", "voyage-law-2") VOYAGE_MODEL = os.environ.get("VOYAGE_MODEL", "voyage-law-2")
@@ -112,6 +144,42 @@ HALACHA_AUTO_APPROVE_THRESHOLD = float(
os.environ.get("HALACHA_AUTO_APPROVE_THRESHOLD", "0.80") os.environ.get("HALACHA_AUTO_APPROVE_THRESHOLD", "0.80")
) )
# Halacha dedup-on-insert — within-precedent semantic cosine ceiling. Before
# storing a halacha, store_halachot_for_chunk skips it if its rule-embedding has
# cosine >= this value against an already-stored halacha of the SAME precedent
# (exact normalized supporting_quote is always skipped regardless). 0.93 is the
# conservative auto-skip floor: the 2026-06-03 cleanup showed the 0.90-0.95 band
# is "almost entirely" same-rule-reworded, but auto-skip is unreviewed so we sit
# just above the manual-cleanup 0.90 to avoid dropping a genuinely distinct
# principle. Set > 1.0 to disable semantic dedup (exact-quote dedup still runs).
HALACHA_DEDUP_COSINE = float(os.environ.get("HALACHA_DEDUP_COSINE", "0.93"))
# Halacha dedup TAIL band (#82.3) — the [BAND_COSINE, DEDUP_COSINE) range is too
# low to auto-skip but suspicious. A halacha whose nearest same-precedent
# neighbor sits in this band AND has high LEXICAL overlap (Jaccard/Levenshtein
# on rule_statement) is flagged 'near_duplicate' (blocks auto-approve → review),
# not skipped — catching paraphrases the cosine threshold misses without
# dropping a possibly-distinct principle unreviewed. 0.83 from the same cleanup.
HALACHA_DEDUP_BAND_COSINE = float(os.environ.get("HALACHA_DEDUP_BAND_COSINE", "0.83"))
# Halacha NLI entailment validator (#81.3) — after extraction, a claude_session
# judge checks each halacha's rule_statement is entailed by its supporting_quote.
# Non-entailed (neutral/contradiction) → quality flag 'nli_unsupported' that
# blocks auto-approve. Runs through the local CLI (zero cost); fails OPEN if the
# CLI is unavailable (e.g. container). 'low' effort — entailment is a simple call.
HALACHA_NLI_ENABLED = os.environ.get("HALACHA_NLI_ENABLED", "true").lower() == "true"
HALACHA_NLI_MODEL = os.environ.get("HALACHA_NLI_MODEL", HALACHA_EXTRACT_MODEL)
HALACHA_NLI_EFFORT = os.environ.get("HALACHA_NLI_EFFORT", "low")
# Halacha over-extraction consolidation (#81.5) — after a precedent finishes
# extracting, a claude_session pass folds facets of the SAME legal question
# (below the #82 dedup cosine) into one canonical; the rest are marked rejected
# (reversible). Cross-chunk safety net for over-splitting. Runs through the local
# CLI (zero cost); fails OPEN. 'high' effort — folding needs careful judgment.
HALACHA_CONSOLIDATE_ENABLED = os.environ.get("HALACHA_CONSOLIDATE_ENABLED", "true").lower() == "true"
HALACHA_CONSOLIDATE_MODEL = os.environ.get("HALACHA_CONSOLIDATE_MODEL", HALACHA_EXTRACT_MODEL)
HALACHA_CONSOLIDATE_EFFORT = os.environ.get("HALACHA_CONSOLIDATE_EFFORT", "high")
# Google Cloud Vision (OCR for scanned PDFs) # Google Cloud Vision (OCR for scanned PDFs)
GOOGLE_CLOUD_VISION_API_KEY = os.environ.get("GOOGLE_CLOUD_VISION_API_KEY", "") GOOGLE_CLOUD_VISION_API_KEY = os.environ.get("GOOGLE_CLOUD_VISION_API_KEY", "")

View File

@@ -84,10 +84,24 @@ async def case_create(
) )
# INV-TOOL5 / GAP-53: hard cap on list/search result sizes (OWASP API4:2023 —
# Unrestricted Resource Consumption). Non-positive is treated as "max", not "all".
_MAX_LIMIT = 200
def _clamp_limit(limit: int, hard_max: int = _MAX_LIMIT) -> int:
"""Clamp a caller-supplied result limit to [1, hard_max]."""
try:
n = int(limit)
except (TypeError, ValueError):
return hard_max
return hard_max if n <= 0 else min(n, hard_max)
@mcp.tool() @mcp.tool()
async def case_list(status: str = "", limit: int = 50) -> str: async def case_list(status: str = "", limit: int = 50) -> str:
"""רשימת תיקי ערר. סינון אופציונלי לפי סטטוס (new/in_progress/drafted/reviewed/final).""" """רשימת תיקי ערר. סינון אופציונלי לפי סטטוס (new/in_progress/drafted/reviewed/final)."""
return await cases.case_list(status, limit) return await cases.case_list(status, _clamp_limit(limit))
@mcp.tool() @mcp.tool()
@@ -108,7 +122,7 @@ async def case_update(
tags: list[str] | None = None, tags: list[str] | None = None,
expected_outcome: str = "", expected_outcome: str = "",
) -> str: ) -> str:
"""עדכון פרטי תיק. expected_outcome: rejection/partial_acceptance/full_acceptance/betterment_levy.""" """עדכון פרטי תיק. expected_outcome: rejection/partial_acceptance/full_acceptance (betterment_levy הוא practice_area, לא תוצאה)."""
return await cases.case_update( return await cases.case_update(
case_number, status, title, subject, notes, case_number, status, title, subject, notes,
hearing_date, decision_date, tags, expected_outcome, hearing_date, decision_date, tags, expected_outcome,
@@ -156,13 +170,33 @@ async def precedent_remove(precedent_id: str) -> str:
return await precedents.precedent_remove(precedent_id) return await precedents.precedent_remove(precedent_id)
@mcp.tool()
async def search_case_precedents(
query: str, practice_area: str = "", limit: int = 10,
) -> str:
"""חיפוש בציטוטי-פסיקה שדפנה צירפה ידנית לתיקים (טבלת case_precedents) —
קורפוס "case-attached". זה **לא** ספריית-הפסיקה הסמכותית.
GAP-49 (INV-TOOL2): שם קודם היה `precedent_search_library` — הפוך וכמעט-זהה
ל-`search_precedent_library` (הספרייה הסמכותית), מה שסיכן ציטוט מהמקור הלא-נכון.
אל תצטט מכאן כמקור-סמכות ל-CREAC; לזה השתמש ב-`search_precedent_library`.
Args:
query: מחרוזת חיפוש (מול citation ו-quote)
practice_area: סינון תחום משפטי (אופציונלי)
limit: תקרת תוצאות
"""
return await precedents.search_case_precedents(query, practice_area, _clamp_limit(limit))
@mcp.tool() @mcp.tool()
async def precedent_search_library( async def precedent_search_library(
query: str, practice_area: str = "", limit: int = 10, query: str, practice_area: str = "", limit: int = 10,
) -> str: ) -> str:
"""חיפוש בציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents). """DEPRECATED (GAP-49) — שם-מטעה. השתמש ב-`search_case_precedents` (ציטוטים
שונה מ-search_precedent_library שמחפש בקורפוס הפסיקה הסמכותית.""" מצורפים-לתיק) או ב-`search_precedent_library` (ספריית-הפסיקה הסמכותית).
return await precedents.precedent_search_library(query, practice_area, limit) Alias זמני לתאימות-לאחור — מנתב ל-search_case_precedents."""
return await precedents.search_case_precedents(query, practice_area, _clamp_limit(limit))
# ── External Precedent Library — authoritative case-law corpus ───── # ── External Precedent Library — authoritative case-law corpus ─────
@@ -214,7 +248,7 @@ async def precedent_library_list(
""" """
return await plib.precedent_library_list( return await plib.precedent_library_list(
practice_area, court, precedent_level, source_type, search, practice_area, court, precedent_level, source_type, search,
source_kind, limit, source_kind, _clamp_limit(limit),
) )
@@ -258,6 +292,12 @@ async def precedent_extract_metadata(case_law_id: str) -> str:
return await plib.precedent_extract_metadata(case_law_id) return await plib.precedent_extract_metadata(case_law_id)
@mcp.tool()
async def precedent_reindex(case_law_id: str) -> str:
"""re-chunk + re-embed פסיקה קיימת מה-full_text השמור (FU-3/GAP-09). אינו מריץ OCR/LLM — רק chunking + voyage embeddings. idempotent."""
return await plib.precedent_reindex(case_law_id)
@mcp.tool() @mcp.tool()
async def style_corpus_enrich(corpus_id: str, overwrite: bool = False) -> str: async def style_corpus_enrich(corpus_id: str, overwrite: bool = False) -> str:
"""חילוץ מטא-דאטה (summary, outcome, key_principles, appeal_subtype) להחלטה בקורפוס הסגנון של דפנה. ברירת מחדל: ממלא רק שדות ריקים. שלח `overwrite=true` כדי לרענן.""" """חילוץ מטא-דאטה (summary, outcome, key_principles, appeal_subtype) להחלטה בקורפוס הסגנון של דפנה. ברירת מחדל: ממלא רק שדות ריקים. שלח `overwrite=true` כדי לרענן."""
@@ -267,13 +307,19 @@ async def style_corpus_enrich(corpus_id: str, overwrite: bool = False) -> str:
@mcp.tool() @mcp.tool()
async def style_corpus_pending_enrichment(limit: int = 50) -> str: async def style_corpus_pending_enrichment(limit: int = 50) -> str:
"""רשימת החלטות בקורפוס הסגנון שעדיין חסרות summary/outcome/key_principles — מועמדות לחילוץ.""" """רשימת החלטות בקורפוס הסגנון שעדיין חסרות summary/outcome/key_principles — מועמדות לחילוץ."""
return await train_tools.list_corpus_pending_enrichment(limit) return await train_tools.list_corpus_pending_enrichment(_clamp_limit(limit))
@mcp.tool()
async def extraction_status() -> str:
"""סטטוס תור-החילוץ — כמה פסיקות ממתינות לחילוץ metadata/halacha + גיל הבקשה הוותיקה. read-only (חושף את התור ש-precedent_process_pending מרוקן)."""
return await plib.extraction_status()
@mcp.tool() @mcp.tool()
async def precedent_process_pending(kind: str = "metadata", limit: int = 20) -> str: async def precedent_process_pending(kind: str = "metadata", limit: int = 20) -> str:
"""ריקון תור בקשות חילוץ שנשלחו מ-UI. kind: 'metadata' או 'halacha'. מריץ extractor מקומית עם CLI על כל פריט בתור, ומנקה את הסימון אחרי הצלחה.""" """ריקון תור בקשות חילוץ שנשלחו מ-UI. kind: 'metadata' או 'halacha'. מריץ extractor מקומית עם CLI על כל פריט בתור, ומנקה את הסימון אחרי הצלחה."""
return await plib.precedent_process_pending(kind, limit) return await plib.precedent_process_pending(kind, _clamp_limit(limit))
@mcp.tool() @mcp.tool()
@@ -290,7 +336,7 @@ async def search_precedent_library(
"""חיפוש סמנטי בקורפוס הפסיקה הסמכותית. מחזיר הלכות (מאושרות בלבד) + קטעי טקסט. השתמש כש-legal-writer צריך לצטט פסיקה מחייבת בבלוק י (CREAC: rule + explanation).""" """חיפוש סמנטי בקורפוס הפסיקה הסמכותית. מחזיר הלכות (מאושרות בלבד) + קטעי טקסט. השתמש כש-legal-writer צריך לצטט פסיקה מחייבת בבלוק י (CREAC: rule + explanation)."""
return await plib.search_precedent_library( return await plib.search_precedent_library(
query, practice_area, court, precedent_level, appeal_subtype, query, practice_area, court, precedent_level, appeal_subtype,
None, subject_tag, limit, include_halachot, None, subject_tag, _clamp_limit(limit), include_halachot,
) )
@@ -314,7 +360,7 @@ async def halacha_review(
@mcp.tool() @mcp.tool()
async def halachot_pending(limit: int = 100) -> str: async def halachot_pending(limit: int = 100) -> str:
"""תור ההלכות הממתינות לאישור.""" """תור ההלכות הממתינות לאישור."""
return await plib.halachot_pending(limit) return await plib.halachot_pending(_clamp_limit(limit))
# Documents # Documents
@@ -433,7 +479,7 @@ async def search_decisions(
) -> str: ) -> str:
"""חיפוש סמנטי בהחלטות קודמות ובמסמכים — מסונן לפי תחום משפטי.""" """חיפוש סמנטי בהחלטות קודמות ובמסמכים — מסונן לפי תחום משפטי."""
return await search.search_decisions( return await search.search_decisions(
query, limit, section_type, practice_area, appeal_subtype, case_number, query, _clamp_limit(limit), section_type, practice_area, appeal_subtype, case_number,
) )
@@ -444,7 +490,7 @@ async def search_case_documents(
limit: int = 10, limit: int = 10,
) -> str: ) -> str:
"""חיפוש סמנטי בתוך מסמכי תיק ספציפי.""" """חיפוש סמנטי בתוך מסמכי תיק ספציפי."""
return await search.search_case_documents(case_number, query, limit) return await search.search_case_documents(case_number, query, _clamp_limit(limit))
@mcp.tool() @mcp.tool()
@@ -457,7 +503,7 @@ async def find_similar_cases(
) -> str: ) -> str:
"""מציאת תיקים דומים על בסיס תיאור — מסונן לפי תחום משפטי.""" """מציאת תיקים דומים על בסיס תיאור — מסונן לפי תחום משפטי."""
return await search.find_similar_cases( return await search.find_similar_cases(
description, limit, practice_area, appeal_subtype, case_number, description, _clamp_limit(limit), practice_area, appeal_subtype, case_number,
) )
@@ -490,7 +536,7 @@ async def search_internal_decisions(
כשרוצים להרחיב מעבר לטקסט המקורי. default False. כשרוצים להרחיב מעבר לטקסט המקורי. default False.
""" """
return await search.search_internal_decisions( return await search.search_internal_decisions(
query, practice_area, appeal_subtype, district, chair_name, limit, include_halachot, query, practice_area, appeal_subtype, district, chair_name, _clamp_limit(limit), include_halachot,
include_cited_by=include_cited_by, include_cited_by=include_cited_by,
) )
@@ -502,13 +548,25 @@ async def get_style_guide() -> str:
return await drafting.get_style_guide() return await drafting.get_style_guide()
@mcp.tool()
async def style_distance(case_number: str) -> str:
"""מדד מרחק-סגנון (T7) — האם הטיוטה מתכנסת לסגנון דפנה: סטיית יחסי-זהב,
ספירת אנטי-דפוסים, ושיעור-השינוי draft→final מפנקס-ההתאמה. ללא LLM."""
import json as _json
from legal_mcp.services import style_distance as _sd
result = await _sd.style_distance(case_number)
return _json.dumps(result, ensure_ascii=False, indent=2)
@mcp.tool() @mcp.tool()
async def draft_section( async def draft_section(
case_number: str, case_number: str,
section: str, section: str,
instructions: str = "", instructions: str = "",
) -> str: ) -> str:
"""הרכבת הקשר מלא לניסוח סעיף (עובדות + תקדימים + סגנון).""" """DEPRECATED (GAP-50/INV-TOOL2) — הרכבת הקשר לניסוח לפי **סעיף** (granularity ישן).
העדף את `get_block_context(case_number, block_id)` — הקשר לפי-בלוק, התואם
לארכיטקטורת 12-הבלוקים הקנונית. נשמר זמנית לתאימות-לאחור."""
return await drafting.draft_section(case_number, section, instructions) return await drafting.draft_section(case_number, section, instructions)
@@ -565,6 +623,12 @@ async def extract_appraiser_facts(case_number: str) -> str:
return await drafting.extract_appraiser_facts(case_number) return await drafting.extract_appraiser_facts(case_number)
@mcp.tool()
async def get_appraiser_facts(case_number: str) -> str:
"""קריאת עובדות-השמאי שכבר חולצו (facts + סתירות) — ללא חילוץ-מחדש יקר. ה-get המקביל ל-extract_appraiser_facts."""
return await drafting.get_appraiser_facts(case_number)
@mcp.tool() @mcp.tool()
async def write_interim_draft(case_number: str, instructions: str = "") -> str: async def write_interim_draft(case_number: str, instructions: str = "") -> str:
"""כתיבת ארבעת הבלוקים לטיוטת ביניים (רקע, תכניות+היתרים, טענות, הליכים) — אותו skill וטמפלט.""" """כתיבת ארבעת הבלוקים לטיוטת ביניים (רקע, תכניות+היתרים, טענות, הליכים) — אותו skill וטמפלט."""
@@ -648,7 +712,7 @@ async def set_outcome(
outcome: str, outcome: str,
reasoning: str = "", reasoning: str = "",
) -> str: ) -> str:
"""הזנת תוצאה לתיק: rejected (דחייה), accepted (קבלה), partial (קבלה חלקית). אם אין נימוק — מפעיל סיעור מוחות.""" """הזנת תוצאה לתיק: rejection (דחייה), partial_acceptance (קבלה חלקית), full_acceptance (קבלה מלאה). ערכי-legacy ממופים. אם אין נימוק — מפעיל סיעור מוחות."""
return await workflow.set_outcome(case_number, outcome, reasoning) return await workflow.set_outcome(case_number, outcome, reasoning)
@@ -807,7 +871,7 @@ async def missing_precedent_list(
case_number=case_number, case_number=case_number,
status=status, status=status,
legal_topic=legal_topic, legal_topic=legal_topic,
limit=limit, limit=_clamp_limit(limit),
) )
@@ -872,7 +936,7 @@ async def list_internal_citations(
return await cit_tools.list_internal_citations( return await cit_tools.list_internal_citations(
case_law_id=case_law_id, case_law_id=case_law_id,
linked_only=linked_only, linked_only=linked_only,
limit=limit, limit=_clamp_limit(limit),
) )
@@ -888,7 +952,7 @@ async def list_incoming_citations(
""" """
return await cit_tools.list_incoming_citations( return await cit_tools.list_incoming_citations(
case_law_id=case_law_id, case_law_id=case_law_id,
limit=limit, limit=_clamp_limit(limit),
) )
@@ -911,9 +975,33 @@ async def list_chair_feedback(
case_number: str = "", case_number: str = "",
category: str = "", category: str = "",
unresolved_only: bool = True, unresolved_only: bool = True,
limit: int = 100,
) -> str: ) -> str:
"""הצגת הערות יו"ר שתועדו — אפשר לסנן לפי תיק, קטגוריה, מטופלות.""" """הצגת הערות יו"ר שתועדו — אפשר לסנן לפי תיק, קטגוריה, מטופלות."""
return await workflow.list_chair_feedback(case_number, category, unresolved_only) return await workflow.list_chair_feedback(case_number, category, unresolved_only, _clamp_limit(limit))
@mcp.tool()
async def halacha_corroboration(halacha_id: str) -> dict:
"""החזר את ה-corroboration של הלכה: הציטוטים שמתקפים אותה, הטיפול, וסיכום (X11, read-only)."""
from uuid import UUID
from legal_mcp.services import corroboration as cor, db
links = await db.list_corroboration_for_halacha(UUID(halacha_id))
agg = cor.aggregate(
[{"source_id": (l["citing_case_law_id"] or l["citing_decision_id"] or ""), "treatment": l["treatment"]} for l in links]
)
return {"halacha_id": halacha_id, "summary": agg, "citations": links}
@mcp.tool()
async def corroboration_rebuild(case_law_id: str = "") -> dict:
"""בנה/רענן את ה-corroboration ויישם אישור-אוטומטי. ריק = כל הקורפוס (backfill);
מזהה-תקדים = תקדים בודד. כותב halacha_citation_corroboration ומעדכן review_status
(corroborated→approved, overruled→pending_review). X11 Phase 2."""
from legal_mcp.services import corroboration as cor
if case_law_id.strip():
return await cor.build_for_precedent(case_law_id.strip())
return await cor.build_all()
def main(): def main():

View File

@@ -44,6 +44,26 @@ async def log_action(
json.dumps(details or {}, ensure_ascii=False)[:200]) json.dumps(details or {}, ensure_ascii=False)[:200])
async def log_action_safe(
action: str,
case_id: "UUID | None" = None,
document_id: "UUID | None" = None,
details: dict | None = None,
user: str = "system",
) -> None:
"""Non-fatal audit: never let an audit-log failure break the caller's action.
The authoritative integrity trail is git (X5 §2.1); audit_log is the
'who/what/when' observability layer, so a write failure is logged as a
warning and swallowed.
"""
try:
await log_action(action, case_id=case_id, document_id=document_id,
details=details, user=user)
except Exception as e: # noqa: BLE001 — observability must not break the op
logger.warning("audit log_action failed (non-fatal) for %s: %s", action, e)
async def get_audit_log( async def get_audit_log(
case_id: UUID | None = None, case_id: UUID | None = None,
action: str | None = None, action: str | None = None,

View File

@@ -19,8 +19,14 @@ from datetime import date
from uuid import UUID from uuid import UUID
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import db, embeddings, claude_session from legal_mcp.services import db, embeddings, claude_session, audit
from legal_mcp.services.lessons import get_content_checklist, get_methodology_summary from legal_mcp.services.lessons import (
OUTCOME_LABELS_HE,
PRACTICE_AREA_OVERRIDES,
canonical_outcome,
get_content_checklist,
get_methodology_summary,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -242,8 +248,12 @@ BLOCK_PROMPTS = {
## חומרי מקור: ## חומרי מקור:
{source_context} {source_context}
## פסיקה רלוונטית (צטט מכאן ומהידע הכללי שלך): ## דוגמאות-סגנון מהחלטות דפנה — מבנה וקול בלבד:
{precedents_context} ⚠️ אלה דוגמאות ל**איך** דפנה כותבת (מבנה, קצב, תנועות-הנמקה, ביטויים) — **לא מקור-תוכן**. הכלל המבחין: נוסחה/בוילרפלייט קבוע (פתיח דוקטרינלי, תבנית-סיום) → מותר להעתיק; ניתוח/טענות ספציפיים → **הכלל את הדפוס והתאם לתיק שלפניך**, אל תעתיק; מהות משפטית (הלכה/עובדה) מתיק אחר → **אסור** להעתיק.
{daphna_style_exemplars}
## פסיקה רלוונטית לציטוט (צטט מכאן ומהידע הכללי שלך):
{case_law_citations}
## סגנון דפנה: ## סגנון דפנה:
{style_context}""", {style_context}""",
@@ -270,10 +280,11 @@ BLOCK_PROMPTS = {
} }
# Discussion structure by outcome # Discussion structure by outcome
# GAP-51: keyed by canonical outcomes (rejection/partial_acceptance/full_acceptance).
STRUCTURE_GUIDANCE = { STRUCTURE_GUIDANCE = {
"rejected": "דחייה — שכבות הגנה (concentric circles): טענה ראשית → נדחית, טענה חלופית → נדחית, חיזוק.", "rejection": "דחייה — שכבות הגנה (concentric circles): טענה ראשית → נדחית, טענה חלופית → נדחית, חיזוק.",
"accepted": "קבלה — נימוק-נימוק: כל נימוק = CREAC מלא, בניית שכנוע הדרגתי.", "full_acceptance": "קבלה — נימוק-נימוק: כל נימוק = CREAC מלא, בניית שכנוע הדרגתי.",
"partial": "קבלה חלקית — מיפוי מתחים: מה מתקבל ולמה, מה נדחה ולמה, איזון.", "partial_acceptance": "קבלה חלקית — מיפוי מתחים: מה מתקבל ולמה, מה נדחה ולמה, איזון.",
} }
@@ -305,7 +316,9 @@ async def write_block(
# Template blocks # Template blocks
if block_id in TEMPLATE_WRITERS: if block_id in TEMPLATE_WRITERS:
content = TEMPLATE_WRITERS[block_id](case, decision) content = TEMPLATE_WRITERS[block_id](case, decision)
return _build_result(block_id, content, block_cfg) r = _build_result(block_id, content, block_cfg)
r["sources"] = {"document_ids": [], "claim_ids": [], "case_law_ids": []}
return r
# AI-generated blocks # AI-generated blocks
prompt_template = BLOCK_PROMPTS.get(block_id) prompt_template = BLOCK_PROMPTS.get(block_id)
@@ -318,15 +331,22 @@ async def write_block(
claims_context = await _build_claims_context(case_id) claims_context = await _build_claims_context(case_id)
direction_context = _build_direction_context(decision) direction_context = _build_direction_context(decision)
plans_context = await _build_plans_context(case_id) plans_context = await _build_plans_context(case_id)
precedents_context = await _build_precedents_context(case_id, block_id) daphna_style_exemplars, case_law_citations, _precedent_case_law_ids = (
style_context = await _build_style_context() await _build_precedents_context(case_id, block_id)
)
style_context = await _build_style_context(case.get("practice_area", ""))
discussion_context = await _build_previous_blocks_context(case_id, decision) discussion_context = await _build_previous_blocks_context(case_id, decision)
appraiser_facts_context = await _build_appraiser_facts_context(case_id) appraiser_facts_context = await _build_appraiser_facts_context(case_id)
appraiser_conflicts_context = await _build_appraiser_conflicts_context(case_id) appraiser_conflicts_context = await _build_appraiser_conflicts_context(case_id)
post_hearing_context = await _build_post_hearing_context(case_id) post_hearing_context = await _build_post_hearing_context(case_id)
outcome = (decision or {}).get("outcome", "rejected") outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "") structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
if case.get("practice_area") == "betterment_levy":
structure_guidance = (
structure_guidance + " | היטל השבחה: "
+ " ".join(PRACTICE_AREA_OVERRIDES["betterment_levy"]["discussion_rules"])
).strip()
# Content checklist — tells block-yod WHAT topics to cover # Content checklist — tells block-yod WHAT topics to cover
content_checklist = "" content_checklist = ""
@@ -349,7 +369,8 @@ async def write_block(
claims_context=claims_context, claims_context=claims_context,
direction_context=direction_context, direction_context=direction_context,
plans_context=plans_context, plans_context=plans_context,
precedents_context=precedents_context, daphna_style_exemplars=daphna_style_exemplars,
case_law_citations=case_law_citations,
style_context=style_context, style_context=style_context,
discussion_context=discussion_context, discussion_context=discussion_context,
structure_guidance=structure_guidance, structure_guidance=structure_guidance,
@@ -391,7 +412,11 @@ async def write_block(
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
content = await claude_session.query(prompt, timeout=timeout) content = await claude_session.query(prompt, timeout=timeout)
return _build_result(block_id, content, block_cfg) sources = await _collect_block_sources(case_id, block_id)
sources["case_law_ids"] = _precedent_case_law_ids
result = _build_result(block_id, content, block_cfg)
result["sources"] = sources
return result
def _build_result(block_id: str, content: str, block_cfg: dict) -> dict: def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
@@ -408,11 +433,32 @@ def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
} }
async def _collect_block_sources(case_id: UUID, block_id: str) -> dict:
"""Deterministic source ids available to a block's generation (GAP-19).
document_ids: case documents matching the block's allowed doc-types.
claim_ids: extracted claims for the case. (case_law_ids are captured
separately from the precedent search inside write_block.)
"""
allowed = _BLOCK_DOC_TYPES.get(block_id, []) # [] = all docs; None = no source docs
if allowed is None:
docs = [] # mirror _build_source_context: this block consumes no raw source docs
else:
docs = await db.list_documents(case_id)
if allowed:
docs = [d for d in docs if d.get("doc_type") in allowed]
claims = await db.get_claims(case_id)
return {
"document_ids": [str(d["id"]) for d in docs],
"claim_ids": [str(c["id"]) for c in claims],
}
# ── Context builders ────────────────────────────────────────────── # ── Context builders ──────────────────────────────────────────────
def _build_case_context(case: dict, decision: dict | None) -> str: def _build_case_context(case: dict, decision: dict | None) -> str:
outcome = (decision or {}).get("outcome", "") outcome = canonical_outcome((decision or {}).get("outcome", ""))
outcome_heb = {"rejected": "דחייה", "accepted": "קבלה", "partial": "קבלה חלקית"}.get(outcome, "") outcome_heb = OUTCOME_LABELS_HE.get(outcome, "")
return f"""- מספר תיק: {case['case_number']} return f"""- מספר תיק: {case['case_number']}
- כותרת: {case.get('title', '')} - כותרת: {case.get('title', '')}
- עוררים: {', '.join(case.get('appellants', []))} - עוררים: {', '.join(case.get('appellants', []))}
@@ -668,33 +714,64 @@ async def _build_post_hearing_context(case_id: UUID) -> str:
return "\n".join(lines) return "\n".join(lines)
async def _build_precedents_context(case_id: UUID, block_id: str) -> str: async def _build_precedents_context(
"""Search for similar precedent paragraphs from other decisions and case law.""" case_id: UUID, block_id: str,
parts = [] ) -> tuple[str, str, list[str]]:
"""Two SEPARATE streams (INV-LRN5 — keep style apart from substance):
1. style_exemplars — Dafna's own block-level paragraphs (HOW she writes; structure/voice).
2. case_law_citations — precedent case-law (substantive material to quote).
Returns (style_exemplars, case_law_citations, case_law_ids).
"""
style_parts: list[str] = []
caselaw_parts: list[str] = []
case_law_ids: list[str] = []
# block → golden-ratio section, for targeted exemplar retrieval (T2)
_BLOCK_SECTION = {
"block-vav": "background", "block-zayin": "claims",
"block-yod": "discussion", "block-yod-alef": "summary",
}
try: try:
case = await db.get_case(case_id) case = await db.get_case(case_id)
case_number = case.get("case_number", "") if case else "" case_number = case.get("case_number", "") if case else ""
subject = case.get("subject", "") if case else "" subject = case.get("subject", "") if case else ""
practice_area = case.get("practice_area", "") if case else ""
decision = await db.get_decision_by_case(case_id)
outcome = (decision or {}).get("outcome", "")
query = f"דיון משפטי בנושא {subject}" if subject else "דיון משפטי ועדת ערר" query = f"דיון משפטי בנושא {subject}" if subject else "דיון משפטי ועדת ערר"
query_emb = await embeddings.embed_query(query) query_emb = await embeddings.embed_query(query)
section = _BLOCK_SECTION.get(block_id)
# Search 1: paragraph_embeddings (from other decisions by Dafna) # Stream 1a (PRIMARY): Dafna's own block-level prose from her corpus
# (style_exemplars) — matched by section + outcome + practice_area (T2/T3).
if section:
exemplars = await db.search_style_exemplars(
query_embedding=query_emb, section=section,
outcome=outcome or None, practice_area=practice_area or None, limit=6,
)
exemplars = [e for e in exemplars if e.get("decision_number", "") != case_number]
for e in exemplars[:4]:
style_parts.append(
f"[דוגמת-סגנון (מבנה/קול בלבד — התאם, אל תעתיק תוכן) — "
f"{e.get('decision_number', '?')}, {section}, "
f"outcome={e.get('outcome') or ''}]\n{e['paragraph_text'][:1100]}"
)
# Stream 1b: paragraphs from pipeline cases (legacy path; may be empty)
para_results = await db.search_similar_paragraphs( para_results = await db.search_similar_paragraphs(
query_embedding=query_emb, limit=10, block_type="block-yod", query_embedding=query_emb, limit=10, block_type="block-yod",
) )
# Filter out same case
para_results = [r for r in para_results if r.get("case_number", "") != case_number] para_results = [r for r in para_results if r.get("case_number", "") != case_number]
for r in para_results[:4]: for r in para_results[:2]:
parts.append( style_parts.append(
f"[החלטת {r.get('case_number', '?')}{r.get('case_title', '')}, " f"[דוגמת-סגנון — החלטת {r.get('case_number', '?')} "
f"בלוק {r.get('block_type', '')}]\n{r['content'][:500]}" f"{r.get('case_title', '')}, בלוק {r.get('block_type', '')}]\n{r['content'][:500]}"
) )
# Search 2: case_law_embeddings (precedent case law) # Stream 2: case_law_embeddings — substantive precedent (citations)
pool = await db.get_pool() pool = await db.get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
caselaw_rows = await conn.fetch( caselaw_rows = await conn.fetch(
"""SELECT cl.case_number, cl.case_name, cl.court, cl.summary, cl.key_quote, """SELECT cl.id, cl.case_number, cl.case_name, cl.court, cl.summary, cl.key_quote,
1 - (cle.embedding <=> $1) AS score 1 - (cle.embedding <=> $1) AS score
FROM case_law_embeddings cle FROM case_law_embeddings cle
JOIN case_law cl ON cl.id = cle.case_law_id JOIN case_law cl ON cl.id = cle.case_law_id
@@ -703,9 +780,10 @@ async def _build_precedents_context(case_id: UUID, block_id: str) -> str:
query_emb, query_emb,
) )
for r in caselaw_rows[:3]: for r in caselaw_rows[:3]:
case_law_ids.append(str(r["id"]))
text = r["key_quote"] or r["summary"] or "" text = r["key_quote"] or r["summary"] or ""
if text: if text:
parts.append( caselaw_parts.append(
f"[פסיקה: {r['case_number']} {r['case_name']} ({r.get('court', '')})] " f"[פסיקה: {r['case_number']} {r['case_name']} ({r.get('court', '')})] "
f"score={r['score']:.3f}\n{text[:400]}" f"score={r['score']:.3f}\n{text[:400]}"
) )
@@ -713,16 +791,63 @@ async def _build_precedents_context(case_id: UUID, block_id: str) -> str:
except Exception as e: except Exception as e:
logger.warning("Failed to fetch precedents: %s", e) logger.warning("Failed to fetch precedents: %s", e)
return "\n\n".join(parts) if parts else "(אין תקדימים)" return (
"\n\n".join(style_parts) if style_parts else "(אין דוגמאות-סגנון)",
"\n\n".join(caselaw_parts) if caselaw_parts else "(אין פסיקה רלוונטית)",
case_law_ids,
)
async def _build_style_context() -> str: # Cache for the abstract voice profile (read once per process).
"""Build comprehensive style guide from DB patterns + SKILL.md rules. _VOICE_FINGERPRINT_CACHE: str | None = None
Per Anthropic: explicit style instructions reduce generic output. # Style-acquisition policy (INV-LRN5): how to USE the style material below.
_COPY_POLICY = """## מדיניות-סגנון (איך להשתמש בחומר שלהלן) — חובה:
**היעד: לכתוב בקול ובשיטה של דפנה — לא להעתיק.** הפרופיל שלהלן הוא ההכללה של *איך* דפנה כותבת; הַחֵל אותו על העובדות של התיק שלפניך.
- **תוכן קבוע/נוסחאי** (פתיח דוקטרינלי, תבנית-סיום, ביטויי-מעבר) → מותר להשתמש כלשונו.
- **ניתוח/טענות ספציפיים** → הכלל את הדפוס והתאם לתיק; אל תעתיק ניסוח מתיק אחר.
- **מהות משפטית (הלכה/עובדה/תקדים) מתיק אחר** → אסור לגרור לכאן; המהות באה מחומרי-המקור והפסיקה של *התיק הזה* בלבד.
"""
def _load_voice_fingerprint() -> str:
"""Load the abstract authorial-style profile (daphna-voice-fingerprint.md).
This is the PRIMARY style channel (Authorial Style Profiling): the generalized
'how Dafna writes', injected so the writer adapts it rather than copying exemplars.
Read-only consumption of a learning artifact (Writing↔Acquisition separation).
"""
global _VOICE_FINGERPRINT_CACHE
if _VOICE_FINGERPRINT_CACHE is not None:
return _VOICE_FINGERPRINT_CACHE
try:
path = config.DATA_DIR.parent / "docs" / "daphna-voice-fingerprint.md"
_VOICE_FINGERPRINT_CACHE = path.read_text(encoding="utf-8")
except Exception as e:
logger.warning("voice-fingerprint not loaded: %s", e)
_VOICE_FINGERPRINT_CACHE = ""
return _VOICE_FINGERPRINT_CACHE
async def _build_style_context(practice_area: str = "") -> str:
"""Build comprehensive style guide: abstract voice profile (primary) +
SKILL.md rules + DB patterns + accumulated chair learnings.
Per Anthropic: explicit style instructions reduce generic output. The voice
fingerprint is the primary abstract-profile channel (T0 / INV-LRN4-5).
Accumulated learnings (T15) — the chair's /methodology edits and /training
decision_lessons — are appended LAST and marked authoritative, so everything
we have learned to date reaches the writer (not just hardcoded defaults).
""" """
lines = [] lines = []
# Copy-policy first, then the abstract voice profile (the PRIMARY channel).
lines.append(_COPY_POLICY)
fingerprint = _load_voice_fingerprint()
if fingerprint:
lines.append("## פרופיל-הקול של דפנה (טביעת-אצבע — המנגנון המרכזי):\n")
lines.append(fingerprint)
# Core style rules (from SKILL.md analysis) # Core style rules (from SKILL.md analysis)
lines.append("""## כללי סגנון דפנה תמיר — חובה: lines.append("""## כללי סגנון דפנה תמיר — חובה:
@@ -780,6 +905,41 @@ async def _build_style_context() -> str:
for item in items[:8]: for item in items[:8]:
lines.append(f"- {item['pattern_text']}") lines.append(f"- {item['pattern_text']}")
# ── למידה מצטברת (T15) — עריכות היו"ר ב-/methodology + לקחי /training ──
# גובר על ברירות-המחדל לעיל. כך כל מה שלמדנו עד היום מגיע לכותב.
learned: list[str] = []
try:
for cat, label in (
("golden_ratios", "יחסי-זהב (אחוזי-סעיפים)"),
("discussion_rules", "כללי-דיון"),
("content_checklists", "צ׳קליסטים"),
("transition_phrases", "ביטויי-מעבר"),
("anti_patterns", "אנטי-דפוסים (להימנע)"),
):
ov = await db.get_methodology_overrides(cat)
if ov:
learned.append(f"\n**{label} — ערכי היו\"ר (גוברים על ברירת-המחדל):**")
for k, v in ov.items():
learned.append(f"- {k}: {json.dumps(v, ensure_ascii=False)}")
except Exception as e:
logger.warning("methodology overrides not loaded: %s", e)
try:
lessons = await db.get_recent_decision_lessons(limit=15, practice_area=practice_area)
if lessons:
learned.append("\n**לקחים מהחלטות קודמות (decision_lessons):**")
for ls in lessons:
src = ls.get("decision_number") or ls.get("source") or ""
learned.append(f"- [{ls.get('category', '')}] {ls['lesson_text']}" + (f" ({src})" if src else ""))
except Exception as e:
logger.warning("decision_lessons not loaded: %s", e)
if learned:
lines.append(
"\n## ⭐ למידה מצטברת — חובה, גובר על כל ברירת-מחדל לעיל "
"(עריכות היו\"ר ב-/methodology + לקחי /training):"
)
lines.extend(learned)
return "\n".join(lines) return "\n".join(lines)
@@ -841,15 +1001,22 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = ""
claims_context = await _build_claims_context(case_id) claims_context = await _build_claims_context(case_id)
direction_context = _build_direction_context(decision) direction_context = _build_direction_context(decision)
plans_context = await _build_plans_context(case_id) plans_context = await _build_plans_context(case_id)
precedents_context = await _build_precedents_context(case_id, block_id) daphna_style_exemplars, case_law_citations, _ = (
style_context = await _build_style_context() await _build_precedents_context(case_id, block_id)
)
style_context = await _build_style_context(case.get("practice_area", ""))
discussion_context = await _build_previous_blocks_context(case_id, decision) discussion_context = await _build_previous_blocks_context(case_id, decision)
appraiser_facts_context = await _build_appraiser_facts_context(case_id) appraiser_facts_context = await _build_appraiser_facts_context(case_id)
appraiser_conflicts_context = await _build_appraiser_conflicts_context(case_id) appraiser_conflicts_context = await _build_appraiser_conflicts_context(case_id)
post_hearing_context = await _build_post_hearing_context(case_id) post_hearing_context = await _build_post_hearing_context(case_id)
outcome = (decision or {}).get("outcome", "rejected") outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "") structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
if case.get("practice_area") == "betterment_levy":
structure_guidance = (
structure_guidance + " | היטל השבחה: "
+ " ".join(PRACTICE_AREA_OVERRIDES["betterment_levy"]["discussion_rules"])
).strip()
# Content checklist + methodology for block-yod # Content checklist + methodology for block-yod
content_checklist = "" content_checklist = ""
@@ -868,7 +1035,8 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = ""
claims_context=claims_context, claims_context=claims_context,
direction_context=direction_context, direction_context=direction_context,
plans_context=plans_context, plans_context=plans_context,
precedents_context=precedents_context, daphna_style_exemplars=daphna_style_exemplars,
case_law_citations=case_law_citations,
style_context=style_context, style_context=style_context,
discussion_context=discussion_context, discussion_context=discussion_context,
structure_guidance=structure_guidance, structure_guidance=structure_guidance,
@@ -896,7 +1064,8 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = ""
"source_documents": source_context, "source_documents": source_context,
"claims": claims_context, "claims": claims_context,
"direction": direction_context, "direction": direction_context,
"precedents": precedents_context, "precedents": case_law_citations,
"style_exemplars": daphna_style_exemplars,
"style_guide": style_context, "style_guide": style_context,
"previous_blocks": discussion_context, "previous_blocks": discussion_context,
} }
@@ -919,36 +1088,39 @@ async def save_block_content(case_id: UUID, block_id: str, content: str) -> dict
result["generation_type"] = "claude-code" result["generation_type"] = "claude-code"
result["model_used"] = "claude-code" result["model_used"] = "claude-code"
await store_block(UUID(decision["id"]), result) await store_block(UUID(decision["id"]), result) # store_block syncs the file (#35)
await db.mark_blocks_stale(case_id, False)
# Also write/update the draft file on disk
await _update_draft_file(case_id, UUID(decision["id"]))
return result return result
async def _update_draft_file(case_id: UUID, decision_id: UUID) -> None: async def _update_draft_file(decision_id: UUID) -> None:
"""Rebuild drafts/decision.md from all blocks in DB.""" """Rebuild drafts/decision.md from all blocks in DB — the single
from pathlib import Path regenerate-draft hook (lessons #35 / GAP-88). Called after EVERY
decision_blocks mutation (store_block, renumber) so the on-disk file never
case = await db.get_case(case_id) drifts from the DB. legal-qa validates against the DB; export and the chair
if not case: read the file — keeping them identical kills the "QA fails twice on the same
return already-fixed issue" loop (CMPA-62). Resolves case from decision_id so no
caller has to thread case_id through."""
case_dir = config.find_case_dir(case["case_number"])
draft_dir = case_dir / "drafts"
draft_dir.mkdir(parents=True, exist_ok=True)
pool = await db.get_pool() pool = await db.get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
case_row = await conn.fetchrow(
"SELECT c.case_number FROM decisions d JOIN cases c ON c.id = d.case_id "
"WHERE d.id = $1",
decision_id,
)
if not case_row:
return
rows = await conn.fetch( rows = await conn.fetch(
"SELECT content FROM decision_blocks WHERE decision_id = $1 AND content != '' ORDER BY block_index", "SELECT content FROM decision_blocks WHERE decision_id = $1 AND content != '' ORDER BY block_index",
decision_id, decision_id,
) )
draft_dir = config.find_case_dir(case_row["case_number"]) / "drafts"
draft_dir.mkdir(parents=True, exist_ok=True)
draft_path = draft_dir / "decision.md" draft_path = draft_dir / "decision.md"
draft_path.write_text("\n\n".join(row["content"] for row in rows if row["content"]), encoding="utf-8") draft_path.write_text("\n\n".join(row["content"] for row in rows if row["content"]), encoding="utf-8")
logger.info("Draft file updated: %s (%d blocks)", draft_path, len(rows)) logger.info("Draft file synced: %s (%d blocks)", draft_path, len(rows))
# ── Renumbering ─────────────────────────────────────────────────── # ── Renumbering ───────────────────────────────────────────────────
@@ -1002,6 +1174,11 @@ async def renumber_all_blocks(decision_id: UUID) -> dict:
) )
updated += 1 updated += 1
# #35 — renumber mutates content via raw UPDATE (bypasses store_block), so
# sync the draft file here too, otherwise the file keeps stale numbering.
if updated:
await _update_draft_file(decision_id)
return {"total_paragraphs": current_num - 1, "blocks_updated": updated} return {"total_paragraphs": current_num - 1, "blocks_updated": updated}
@@ -1034,6 +1211,9 @@ async def store_block(decision_id: UUID, block_result: dict) -> None:
block_result["model_used"], block_result["model_used"],
block_result["temperature"], block_result["temperature"],
) )
# #35 — regenerate the on-disk draft on every persist so DB and file stay
# identical (legal-qa reads DB; export/chair read the file).
await _update_draft_file(decision_id)
async def write_and_store_block( async def write_and_store_block(
@@ -1049,4 +1229,15 @@ async def write_and_store_block(
result = await write_block(case_id, block_id, instructions) result = await write_block(case_id, block_id, instructions)
await store_block(UUID(decision["id"]), result) await store_block(UUID(decision["id"]), result)
await audit.log_action_safe(
"write_block", case_id=case_id,
details={
"decision_id": str(decision["id"]),
"block_id": block_id,
"model_used": result.get("model_used"),
"generation_type": result.get("generation_type"),
"sources": result.get("sources", {}),
},
)
await db.mark_blocks_stale(case_id, False)
return result return result

View File

@@ -104,6 +104,14 @@ def _assign_pages(chunks: list[Chunk], text: str, page_offsets: list[int]) -> No
# used to carve tiny boundary chunks ("דיון). במסגרת ה") that polluted search. # used to carve tiny boundary chunks ("דיון). במסגרת ה") that polluted search.
MIN_SECTION_CHARS = 60 MIN_SECTION_CHARS = 60
# A split chunk shorter than this (stripped chars) must not stand alone — it
# rides with adjacent content instead. This is the chunk-level analogue of
# MIN_SECTION_CHARS and matches the query-time filter that hides <50-char
# chunks. Without it, a section that opens with a short header line ("דיון",
# "טענות המשיבים") followed by a paragraph larger than chunk_size flushed the
# header as its own tiny chunk (#79, follow-up to #55).
MIN_CHUNK_CHARS = 50
def _split_into_sections(text: str) -> list[tuple[str, str]]: def _split_into_sections(text: str) -> list[tuple[str, str]]:
"""Split text into (section_type, text) pairs based on Hebrew headers. """Split text into (section_type, text) pairs based on Hebrew headers.
@@ -168,11 +176,20 @@ def _split_section(text: str, chunk_size: int, overlap: int) -> list[str]:
chunks: list[str] = [] chunks: list[str] = []
current: list[str] = [] current: list[str] = []
current_tokens = 0 current_tokens = 0
current_chars = 0
for para in paragraphs: for para in paragraphs:
para_tokens = _estimate_tokens(para) para_tokens = _estimate_tokens(para)
if current_tokens + para_tokens > chunk_size and current: # Don't flush a buffer that is still below MIN_CHUNK_CHARS — let it
# absorb this paragraph even if that overflows chunk_size. A short
# header line ("דיון") must ride with the following paragraph rather
# than be emitted as a tiny fragment chunk (#79).
if (
current_tokens + para_tokens > chunk_size
and current
and current_chars >= MIN_CHUNK_CHARS
):
chunks.append("\n".join(current)) chunks.append("\n".join(current))
# Keep overlap # Keep overlap
overlap_paras: list[str] = [] overlap_paras: list[str] = []
@@ -185,13 +202,21 @@ def _split_section(text: str, chunk_size: int, overlap: int) -> list[str]:
overlap_tokens += pt overlap_tokens += pt
current = overlap_paras current = overlap_paras
current_tokens = overlap_tokens current_tokens = overlap_tokens
current_chars = sum(len(p) for p in current)
current.append(para) current.append(para)
current_tokens += para_tokens current_tokens += para_tokens
current_chars += len(para)
if current: if current:
chunks.append("\n".join(current)) chunks.append("\n".join(current))
# Fold a trailing tiny chunk back into its predecessor — a short trailing
# line (e.g. a stray quote fragment) shouldn't stand alone either (#79).
if len(chunks) >= 2 and len(chunks[-1].strip()) < MIN_CHUNK_CHARS:
tail = chunks.pop()
chunks[-1] = f"{chunks[-1]}\n{tail}"
return chunks return chunks

View File

@@ -29,6 +29,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import os
from legal_mcp.config import parse_llm_json from legal_mcp.config import parse_llm_json
@@ -40,6 +41,38 @@ logger = logging.getLogger(__name__)
DEFAULT_TIMEOUT = 1800 DEFAULT_TIMEOUT = 1800
LONG_TIMEOUT = 3600 # opus block writing on full case context LONG_TIMEOUT = 3600 # opus block writing on full case context
# #85 — two complementary hardenings for the same symptom (`claude -p` failing
# with a fast non-zero exit + empty stderr on large/slow cold prompts: CEO
# write_interim_draft, learning_loop distillation):
#
# 1. CLEAN ENV (defensive): a running Claude Code session exports markers into
# child processes; a *nested* ``claude -p`` inherits them. Stripping them lets
# every nested invocation launch as a clean top-level session. Could not be
# reproduced deterministically, so it's a suspect, not a proven cause. Auth/
# config (CLAUDE_CONFIG_DIR, ANTHROPIC_*, PATH, HOME) are kept.
# 2. RETRY (the real fix): the SAME large prompt that exits 1 once succeeds on a
# plain retry — the bail is transient. Retry with linear backoff. Timeouts and
# "CLI not found" stay deterministic and are NOT retried.
# See TaskMaster legal-ai #85.
_SESSION_MARKER_PREFIXES = ("CLAUDECODE", "CLAUDE_CODE_", "CLAUDE_AGENT_")
_SESSION_MARKER_EXACT = frozenset({"AI_AGENT", "CLAUDE_EFFORT"})
MAX_RETRIES = 3
RETRY_BACKOFF_BASE = 5 # seconds; sleep = base * attempt_number
def _clean_subprocess_env() -> dict[str, str]:
"""Copy the current env minus Claude Code session markers.
Lets a nested ``claude -p`` start fresh instead of detecting it is
already inside a Claude Code session (#85).
"""
env = dict(os.environ)
for key in list(env):
if key in _SESSION_MARKER_EXACT or key.startswith(_SESSION_MARKER_PREFIXES):
del env[key]
return env
async def query( async def query(
prompt: str, prompt: str,
@@ -47,6 +80,8 @@ async def query(
max_turns: int = 1, max_turns: int = 1,
*, *,
system: str | None = None, system: str | None = None,
model: str | None = None,
effort: str | None = None,
) -> str: ) -> str:
"""Send a prompt to Claude Code headless and return the text response. """Send a prompt to Claude Code headless and return the text response.
@@ -62,6 +97,13 @@ async def query(
CLI doesn't expose API-level caching. The parameter exists so CLI doesn't expose API-level caching. The parameter exists so
extractors can structure their calls cleanly today, and to make extractors can structure their calls cleanly today, and to make
a future SDK-backed path drop-in. a future SDK-backed path drop-in.
model: Optional model alias/id (e.g. ``claude-opus-4-8``). When set,
passed as ``--model``; otherwise the CLI's session default is
used. Lets quality-sensitive extractors (halacha) pin a stronger
model without changing the default for every caller.
effort: Optional effort level (``low``/``medium``/``high``/``xhigh``/
``max``). When set, passed as ``--effort``. Pairs with ``model``;
an empty string is treated as "unset" (CLI default).
Returns: Returns:
The text response from Claude. The text response from Claude.
@@ -80,15 +122,26 @@ async def query(
"--output-format", "json", "--output-format", "json",
"--max-turns", str(max_turns), "--max-turns", str(max_turns),
] ]
if model:
cmd += ["--model", model]
if effort:
cmd += ["--effort", effort]
size_info = f"; prompt_len={len(full_prompt):,} chars" if len(full_prompt) > 100_000 else ""
last_err = "unknown error"
for attempt in range(1, MAX_RETRIES + 1):
try: try:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*cmd, *cmd,
stdin=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
env=_clean_subprocess_env(),
cwd=os.path.expanduser("~"),
) )
except FileNotFoundError: except FileNotFoundError:
# Deterministic — never retry.
raise RuntimeError( raise RuntimeError(
"Claude CLI not found. This module only works when invoked " "Claude CLI not found. This module only works when invoked "
"from the local MCP server — see the architectural rule in " "from the local MCP server — see the architectural rule in "
@@ -103,7 +156,8 @@ async def query(
timeout=timeout, timeout=timeout,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
# wait_for cancellation alone leaves the child running. # wait_for cancellation alone leaves the child running. A timeout is
# a real ceiling, not a transient blip — don't retry.
try: try:
proc.kill() proc.kill()
await proc.wait() await proc.wait()
@@ -112,14 +166,14 @@ async def query(
raise RuntimeError(f"Claude CLI timed out after {timeout}s") raise RuntimeError(f"Claude CLI timed out after {timeout}s")
if proc.returncode != 0: if proc.returncode != 0:
stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error" # The CLI sometimes writes its diagnostic to stdout (or nowhere)
size_info = f"; prompt_len={len(full_prompt):,} chars" if len(full_prompt) > 100_000 else "" # rather than stderr (#85) — surface whichever is present.
raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}{size_info}") stderr = stderr_b.decode("utf-8", errors="replace").strip()
stdout = stdout_b.decode("utf-8", errors="replace").strip() stdout = stdout_b.decode("utf-8", errors="replace").strip()
if not stdout: last_err = f"exit {proc.returncode}: {(stderr or stdout or 'no output')[:500]}"
raise RuntimeError("Claude CLI returned empty response") else:
stdout = stdout_b.decode("utf-8", errors="replace").strip()
if stdout:
# claude -p --output-format json returns {"type":"result","result":"..."} # claude -p --output-format json returns {"type":"result","result":"..."}
try: try:
data = json.loads(stdout) data = json.loads(stdout)
@@ -128,6 +182,19 @@ async def query(
return stdout return stdout
except json.JSONDecodeError: except json.JSONDecodeError:
return stdout return stdout
last_err = "empty response"
# Transient failure — retry with linear backoff unless this was the last try.
if attempt < MAX_RETRIES:
logger.warning(
"claude -p attempt %d/%d failed (%s%s) — retrying in %ds",
attempt, MAX_RETRIES, last_err, size_info, RETRY_BACKOFF_BASE * attempt,
)
await asyncio.sleep(RETRY_BACKOFF_BASE * attempt)
raise RuntimeError(
f"Claude CLI failed after {MAX_RETRIES} attempts ({last_err}){size_info}"
)
async def query_json( async def query_json(
@@ -135,12 +202,15 @@ async def query_json(
timeout: int = DEFAULT_TIMEOUT, timeout: int = DEFAULT_TIMEOUT,
*, *,
system: str | None = None, system: str | None = None,
model: str | None = None,
effort: str | None = None,
) -> dict | list | None: ) -> dict | list | None:
"""Send a prompt and parse the response as JSON. """Send a prompt and parse the response as JSON.
Uses parse_llm_json for robust parsing (handles markdown wrapping, truncation). Uses parse_llm_json for robust parsing (handles markdown wrapping, truncation).
``model``/``effort`` are forwarded to :func:`query` (see its docstring).
""" """
raw = await query(prompt, timeout=timeout, system=system) raw = await query(prompt, timeout=timeout, system=system, model=model, effort=effort)
return parse_llm_json(raw) return parse_llm_json(raw)
@@ -216,6 +286,7 @@ async def query_streaming(
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
cwd=cwd, cwd=cwd,
env=_clean_subprocess_env(),
) )
except FileNotFoundError: except FileNotFoundError:
yield { yield {

View File

@@ -0,0 +1,165 @@
"""X11 citation corroboration — classify treatment, match to halacha, aggregate.
Phase 1: builds the SIGNAL only (no approval changes). See docs/spec/X11.
All LLM calls go through the local claude_session bridge (Opus 4.8 @ xhigh),
same architectural rule as the other extractors (local MCP only).
"""
from __future__ import annotations
import logging
from uuid import UUID
from legal_mcp import config
from legal_mcp.config import parse_llm_json
from legal_mcp.services import claude_session
from legal_mcp.services import db, embeddings
logger = logging.getLogger(__name__)
TREATMENT_POSITIVE = {"followed", "explained"}
TREATMENT_NEGATIVE = {"distinguished", "criticized", "questioned", "overruled"}
TREATMENT_NEUTRAL = {"mentioned"}
_VALID_TREATMENT = TREATMENT_POSITIVE | TREATMENT_NEGATIVE | TREATMENT_NEUTRAL
def is_positive(t: str) -> bool: return t in TREATMENT_POSITIVE
def is_negative(t: str) -> bool: return t in TREATMENT_NEGATIVE
def _coerce_treatment(raw: dict) -> str:
t = str((raw or {}).get("treatment", "")).strip().lower()
return t if t in _VALID_TREATMENT else "mentioned"
def accept_match(best: tuple[str, float] | None, floor: float = config.HALACHA_CORROBORATION_MATCH_FLOOR) -> str | None:
"""Return the halacha_id iff similarity clears the floor (INV-COR3)."""
if not best:
return None
halacha_id, sim = best
return halacha_id if sim >= floor else None
def aggregate(links: list[dict], min_cites: int = config.HALACHA_CORROBORATION_MIN_CITES) -> dict:
"""Aggregate per-halacha corroboration (INV-COR4/COR2).
links: [{"source_id": str, "treatment": str}, ...] already matched to ONE halacha.
positive_sources = count of DISTINCT source_id whose treatment is positive.
has_negative = any negative treatment present.
corroborated = positive_sources >= min_cites AND not has_negative.
"""
positive = {l["source_id"] for l in links if is_positive(l["treatment"])}
has_negative = any(is_negative(l["treatment"]) for l in links)
return {
"positive_sources": len(positive),
"has_negative": has_negative,
"corroborated": len(positive) >= min_cites and not has_negative,
}
def approval_action(agg: dict, has_overruled: bool) -> str | None:
"""Decide the corroboration→approval action for ONE halacha (INV-COR2/COR4).
- 'demote' : a later court overruled it → back to the chair gate (overruled
outranks any positive count, INV-COR2 strong form).
- 'approve' : corroborated (≥N distinct positives, 0 negatives — INV-COR4).
- None : leave as-is (single source, non-overruled negative, or the
uncorroborated tail — INV-COR5 keeps the chair gate).
"""
if has_overruled:
return "demote"
if agg.get("corroborated"):
return "approve"
return None
_TREATMENT_PROMPT = """אתה משפטן בכיר. נתון ציטוט של פסק/החלטה קודמים בתוך החלטה מאוחרת.
סווג כיצד ההחלטה המאוחרת **מטפלת** בתקדים המצוטט, לפי אחת מהקטגוריות:
- followed — אימצה והחילה את ההלכה.
- explained — הסבירה/הזכירה בלי לחלוק.
- distinguished — אבחנה (קבעה שלא חל בנסיבות).
- criticized — מתחה ביקורת בלי לבטל.
- questioned — הטילה ספק.
- overruled — דחתה/ביטלה את ההלכה.
- mentioned — אזכור-אגב בלי טיפול.
החזר JSON בלבד: {"treatment": "<קטגוריה>"}.
"""
async def classify_treatment(cited_citation: str, context: str) -> str:
"""Return one treatment label for how `context` treats `cited_citation`."""
user = f"תקדים מצוטט: {cited_citation}\n\n--- ההקשר המצטט ---\n{context}\n--- סוף ---"
try:
result = await claude_session.query_json(
user, system=_TREATMENT_PROMPT,
model=config.HALACHA_EXTRACT_MODEL or None,
effort=config.HALACHA_EXTRACT_EFFORT or None,
)
except Exception as e:
logger.warning("classify_treatment failed: %s", e)
return "mentioned"
return _coerce_treatment(result if isinstance(result, dict) else {})
async def build_for_precedent(case_law_id: str | UUID) -> dict:
"""For one cited precedent: classify+match+store each incoming citation. Idempotent."""
if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id)
cits = await db.incoming_citations_for_precedent(case_law_id)
linked = 0
for c in cits:
ctx = (c.get("context") or "").strip()
if not ctx:
continue
vecs = await embeddings.embed_texts([ctx], input_type="query")
best = await db.nearest_halacha_for_vector(case_law_id, vecs[0])
halacha_id = accept_match(best)
if not halacha_id:
continue
treatment = await classify_treatment(c.get("citing_case_law_id") or c.get("citing_decision_id") or "", ctx)
await db.store_corroboration(
halacha_id, c["source_id"],
c.get("citing_case_law_id"), c.get("citing_decision_id"),
treatment, best[1], ctx,
)
linked += 1
appr = await reconcile_approvals(case_law_id)
return {"citations": len(cits), "linked": linked,
"approved": appr["approved"], "demoted": appr["demoted"]}
async def reconcile_approvals(case_law_id: str | UUID) -> dict:
"""Apply the corroboration→approval policy to every halacha of a precedent
(INV-COR2/COR4/COR5). No-op when the kill-switch is off. Idempotent: approve
only fires on ``pending_review``, demote only on ``approved``, so re-runs
converge."""
if not config.HALACHA_CORROBORATION_AUTO_APPROVE:
return {"approved": 0, "demoted": 0, "disabled": True}
if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id)
grouped = await db.list_corroboration_grouped(case_law_id)
approved = demoted = 0
for halacha_id, links in grouped.items():
agg = aggregate(links)
has_overruled = any(l["treatment"] == "overruled" for l in links)
action = approval_action(agg, has_overruled)
if action == "approve":
if await db.approve_halacha_by_corroboration(
UUID(halacha_id), agg["positive_sources"],
config.HALACHA_CORROBORATION_MIN_CITES,
):
approved += 1
elif action == "demote":
if await db.demote_halacha_overruled(UUID(halacha_id)):
demoted += 1
return {"approved": approved, "demoted": demoted, "disabled": False}
async def build_all() -> dict:
"""Backfill: build the signal + apply approvals for every precedent that has
halachot and incoming citations. Idempotent (link table ``ON CONFLICT`` +
state-gated transitions)."""
ids = await db.precedents_with_halachot_and_incoming_citations()
totals = {"precedents": 0, "citations": 0, "linked": 0,
"approved": 0, "demoted": 0}
for cid in ids:
r = await build_for_precedent(cid)
totals["precedents"] += 1
for k in ("citations", "linked", "approved", "demoted"):
totals[k] += r.get(k, 0)
logger.info("corroboration backfill %s: %s", cid, r)
return totals

File diff suppressed because it is too large Load Diff

View File

@@ -112,6 +112,84 @@ def _suppress_paragraph_numbering(paragraph) -> None:
pPr.append(numPr) pPr.append(numPr)
def _ensure_decision_numbering(doc) -> int:
"""T9 — define a single continuous decimal list (RTL) and return its numId.
Dafna's decisions are ALWAYS sequentially numbered (1. 2. 3. ...). The template
ships no numbering definition, so previously the body paragraphs were stripped of
their manual "N." prefix and styled "List Paragraph" — which carries NO numPr,
yielding UNNUMBERED output. Here we inject one decimal abstractNum + num into the
numbering part once per document; body paragraphs then reference it (real Word
auto-numbering → renumbers automatically, copy-pastes cleanly).
"""
cached = getattr(doc, "_decision_num_id", None)
if cached is not None:
return cached
numbering = doc.part.numbering_part.element # <w:numbering>
def _next_id(tag: str, attr: str) -> int:
ids = [int(el.get(qn(attr))) for el in numbering.findall(qn(tag))
if el.get(qn(attr)) and el.get(qn(attr)).isdigit()]
return (max(ids) + 1) if ids else 1
abstract_id = _next_id("w:abstractNum", "w:abstractNumId")
num_id = _next_id("w:num", "w:numId")
abstract = OxmlElement("w:abstractNum")
abstract.set(qn("w:abstractNumId"), str(abstract_id))
mlt = OxmlElement("w:multiLevelType")
mlt.set(qn("w:val"), "singleLevel")
abstract.append(mlt)
lvl = OxmlElement("w:lvl")
lvl.set(qn("w:ilvl"), "0")
for tag, val in (("w:start", "1"), ("w:numFmt", "decimal"),
("w:lvlText", "%1."), ("w:lvlJc", "right")):
el = OxmlElement(tag)
el.set(qn("w:val"), val)
lvl.append(el)
lvl_ppr = OxmlElement("w:pPr")
ind = OxmlElement("w:ind")
ind.set(qn("w:start"), "720")
ind.set(qn("w:hanging"), "360")
lvl_ppr.append(ind)
lvl.append(lvl_ppr)
abstract.append(lvl)
num = OxmlElement("w:num")
num.set(qn("w:numId"), str(num_id))
anum_ref = OxmlElement("w:abstractNumId")
anum_ref.set(qn("w:val"), str(abstract_id))
num.append(anum_ref)
# abstractNum elements must precede num elements in <w:numbering>.
last_abstract = numbering.findall(qn("w:abstractNum"))
if last_abstract:
last_abstract[-1].addnext(abstract)
else:
numbering.insert(0, abstract)
numbering.append(num)
doc._decision_num_id = num_id
return num_id
def _apply_list_numbering(paragraph, num_id: int) -> None:
"""Attach paragraph to the continuous decision list (real auto-numbering)."""
pPr = paragraph._p.get_or_add_pPr()
existing = pPr.find(qn("w:numPr"))
if existing is not None:
pPr.remove(existing)
numPr = OxmlElement("w:numPr")
ilvl = OxmlElement("w:ilvl")
ilvl.set(qn("w:val"), "0")
nid = OxmlElement("w:numId")
nid.set(qn("w:val"), str(num_id))
numPr.append(ilvl)
numPr.append(nid)
pPr.append(numPr)
def _clear_body(doc) -> None: def _clear_body(doc) -> None:
"""Remove all paragraphs in the document body while keeping sectPr. """Remove all paragraphs in the document body while keeping sectPr.
@@ -485,12 +563,15 @@ def _write_block_to_docx(doc, block_id: str, title: str, content: str) -> None:
_add_image_placeholder(doc, stripped.strip("[]📷 ")) _add_image_placeholder(doc, stripped.strip("[]📷 "))
continue continue
# Numbered body paragraph ("1. text") → List Paragraph with auto-num. # Numbered body paragraph ("1. text") → real Word auto-numbering (T9).
# The literal prefix is dropped; Word renders "1. 2. 3. ..." via numId. # The literal prefix is dropped and a numPr referencing the document's
# continuous decimal list is attached, so Word renders "1. 2. 3. ..."
# itself (renumbers on edit, copy-pastes without stray digits).
num_match = _NUM_PREFIX_RE.match(stripped) num_match = _NUM_PREFIX_RE.match(stripped)
if num_match: if num_match:
body_text = num_match.group(2).strip() body_text = num_match.group(2).strip()
_add_styled_paragraph(doc, body_text, style="List Paragraph") para = _add_styled_paragraph(doc, body_text, style="List Paragraph")
_apply_list_numbering(para, _ensure_decision_numbering(doc))
continue continue
_add_styled_paragraph(doc, stripped, style="Normal") _add_styled_paragraph(doc, stripped, style="Normal")

View File

@@ -262,8 +262,15 @@ def _ocr_with_google_vision(image_bytes: bytes, page_num: int) -> str:
def _extract_doc(path: Path) -> str: def _extract_doc(path: Path) -> str:
"""Extract text from legacy .doc file by converting to .docx via LibreOffice.""" """Extract text from legacy .doc file by converting to .docx via LibreOffice."""
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
# Isolate the LibreOffice user profile per call: headless soffice
# locks a single shared profile, so concurrent .doc conversions would
# otherwise fail with a profile-lock error.
result = subprocess.run( result = subprocess.run(
["libreoffice", "--headless", "--convert-to", "docx", str(path), "--outdir", tmp_dir], [
"libreoffice",
f"-env:UserInstallation=file://{tmp_dir}/lo-profile",
"--headless", "--convert-to", "docx", str(path), "--outdir", tmp_dir,
],
capture_output=True, text=True, timeout=120, capture_output=True, text=True, timeout=120,
) )
if result.returncode != 0: if result.returncode != 0:
@@ -351,8 +358,28 @@ def render_pages_for_multimodal(
_NEVO_MARKERS = ("ספרות:", "חקיקה שאוזכרה:", "מיני-רציו:", "פסקי דין שאוזכרו:", _NEVO_MARKERS = ("ספרות:", "חקיקה שאוזכרה:", "מיני-רציו:", "פסקי דין שאוזכרו:",
"כתבי עת:", "הועתק מנבו") "כתבי עת:", "הועתק מנבו")
# Markers for where the actual decision body begins (everything before is Nevo
# preamble: bibliography + מיני-רציו). Two families:
# - ועדת ערר / district openings (בפנינו / הערר שבנדון / ...)
# - COURT-RULING openings (#86.1): a פסק-דין header or the authoring judge's
# line. Without these, Nevo court judgments — exactly the ones carrying a
# מיני-רציו — slipped through unstripped (e.g. בג"ץ 1764/05).
#
# #86.2 hardening — two over-strip bugs found while backfilling:
# 1. ``פסק-דין`` headers are often markdown-wrapped (``**פסק דין**``); the old
# ``^פסק[- ]דין`` required the keyword to be the very first char of the line
# and allowed only one separator, so it missed the header and fell through
# to a citation 32K deep (עמ"נ 50567-07-21). We now tolerate leading
# markdown/whitespace and 0-3 separators.
# 2. Bare ``השופט``/``הנשיא`` matched *citations* ("השופט מ' חשין, פסקה 23"),
# stripping real decision body. The authoring-judge line ends with a COLON
# ("השופט י' עמית:"); citations use a comma. We now require the colon.
_DECISION_START = re.compile( _DECISION_START = re.compile(
r"^(בפנינו|לפנינו|הערר שבנדון|ועדת הערר לתכנון|רקע עובדתי|עסקינן)", r"^[ \t>*_#]{0,6}(?:"
r"בפנינו|לפנינו|לפניי|הערר שבנדון|ועדת הערר לתכנון|רקע עובדתי|עסקינן|"
r"פסק[ \t\-]{0,3}די(?:ן|נו)|" # פסק-דין / פסק דין / **פסק דין** header (final-nun ן vs דינו)
r"(?:כב(?:וד)?['׳\"]?\s*)?(?:ה?שופט[ת]?|ה?נשיא[ה]?|המשנה לנשיא)\s+[^\n,]{1,40}:" # author line → colon
r")",
re.MULTILINE, re.MULTILINE,
) )
@@ -362,7 +389,9 @@ def strip_nevo_preamble(text: str) -> str:
Returns the original text unchanged if no preamble is detected. Returns the original text unchanged if no preamble is detected.
""" """
head = text[:400] # Window wide enough to catch the Nevo markers even when a long court/parties
# header precedes them (court rulings push חקיקה שאוזכרה:/מיני-רציו: down).
head = text[:1500]
if not any(marker in head for marker in _NEVO_MARKERS): if not any(marker in head for marker in _NEVO_MARKERS):
return text return text
m = _DECISION_START.search(text) m = _DECISION_START.search(text)
@@ -371,3 +400,41 @@ def strip_nevo_preamble(text: str) -> str:
logger.debug("Stripped %d chars of Nevo preamble", m.start()) logger.debug("Stripped %d chars of Nevo preamble", m.start())
return stripped return stripped
return text return text
_RATIO_MARKER = "מיני-רציו:"
def extract_nevo_ratio(text: str) -> str:
"""Return the Nevo מיני-רציו block (editorial holdings summary), or ''.
The mini-ratio is Nevo's own headnote — a concise, professionally-written
list of the holdings. We capture it *before* :func:`strip_nevo_preamble`
discards it, to serve as a free gold-set for benchmarking how well our
halacha extractor covers the real holdings (#86.3).
The block runs from the ``מיני-רציו:`` marker to whichever comes first:
the decision body (``_DECISION_START``) or the next preamble marker
(bibliography / legislation). Returns '' when there is no mini-ratio.
"""
if not text:
return ""
start = text.find(_RATIO_MARKER)
if start == -1:
return ""
body = text[start + len(_RATIO_MARKER):]
# End at the earliest of: decision body start, or a following preamble
# marker (ספרות: / חקיקה שאוזכרה: / ...). Both are measured relative to
# the ratio body so we never run past it into the judgment itself.
end = len(body)
dm = _DECISION_START.search(body)
if dm:
end = min(end, dm.start())
for marker in _NEVO_MARKERS:
if marker == _RATIO_MARKER:
continue
pos = body.find(marker)
if pos != -1:
end = min(end, pos)
return body[:end].strip()

View File

@@ -26,14 +26,28 @@ from uuid import UUID
from legal_mcp import config from legal_mcp import config
from legal_mcp.config import parse_llm_json from legal_mcp.config import parse_llm_json
from legal_mcp.services import claude_session, db, embeddings, proofreader from legal_mcp.services import (
claude_session, db, embeddings, halacha_quality, proofreader,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Concurrency model mirrors claims_extractor — each ``claude -p`` subprocess # Concurrency model mirrors claims_extractor — each ``claude -p`` subprocess
# holds ~300 MB RSS, so we cap parallel chunks to keep the box healthy. # holds ~300 MB RSS, so we cap parallel chunks to keep the box healthy.
CHUNK_CONCURRENCY = 3 # Env-tunable (HALACHA_CHUNK_CONCURRENCY) — see config.py.
CHUNK_CONCURRENCY = config.HALACHA_CHUNK_CONCURRENCY
# Global cross-process serialization key for halacha extraction. Every
# extraction (whichever process/agent/driver launched it) takes a PostgreSQL
# advisory lock on this key first; if another extraction already holds it the
# call returns ``status='busy'`` and the request stays pending for the next
# drain. This makes "one extraction at a time" hold across SEPARATE OS
# processes (agent fallback retries spawn independent `python -c` drivers — an
# in-process Semaphore cannot see them). Root cause of the 2026-05-31 freeze:
# 4-5 overlapping driver processes × CHUNK_CONCURRENCY each → 12-16 concurrent
# xhigh `claude -p` procs → load 69 → hard reboot.
_HALACHA_EXTRACT_LOCK_KEY = 0x48414C41 # 'HALA'
CHUNK_RETRY_ATTEMPTS = 1 CHUNK_RETRY_ATTEMPTS = 1
# If at least this fraction of chunks crash and the precedent yields zero # If at least this fraction of chunks crash and the precedent yields zero
@@ -73,9 +87,10 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה
לא-הלכה (אין לחלץ): לא-הלכה (אין לחלץ):
- אמרת אגב (obiter dicta) — הערות שאינן הכרחיות להכרעה. - אמרת אגב (obiter dicta) — הערות שאינן הכרחיות להכרעה.
- ממצאים עובדתיים ספציפיים לתיק ("העורר לא הוכיח X"). - **סוגיה שהערכאה לא הכריעה בה** — אם בית המשפט אומר במפורש "אין צורך להכריע", "מבלי לקבוע מסמרות", "איני רואה לקבוע מסמרות", "למעלה מן הצורך", "אגב אורחא" — זו אינה הלכה. מבחן ההיפוך (Wambaugh): אם שלילת הכלל לא הייתה משנה את תוצאת הפסק — זו אמרת אגב, לא הלכה.
- ממצאים עובדתיים ספציפיים לתיק או יישום על נסיבות התיק ("העורר לא הוכיח X", "במקרה דנן", שמות צדדים, סכומים/מספרים קונקרטיים) — חלץ את **העיקרון המופשט** בלבד, לא את יישומו על עובדות התיק.
- ציטוטי הלכות מפסקי דין אחרים שלא אומצו במפורש בפסק זה. - ציטוטי הלכות מפסקי דין אחרים שלא אומצו במפורש בפסק זה.
- הצהרות על דין קיים שאינן מיושמות בהכרעה. - הצהרות על דין קיים שאינן מיושמות בהכרעה, וכן ניסוח שהוא העתק של הציטוט ללא הפשטה.
הבחנה קריטית: כאשר הפסק מצטט הלכה מפסק קודם, חלץ אותה רק אם בית המשפט בפסק הנוכחי **מאמץ ומחיל** אותה (לא רק מזכיר אותה ברקע). הבחנה קריטית: כאשר הפסק מצטט הלכה מפסק קודם, חלץ אותה רק אם בית המשפט בפסק הנוכחי **מאמץ ומחיל** אותה (לא רק מזכיר אותה ברקע).
@@ -109,10 +124,10 @@ HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה
] ]
## כללי איכות ## כללי איכות
1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תמציא הלכה. 1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת ו**שלמה** מהקלט (משפט שלם, לא חתוך באמצע). אם אין ציטוט מתאים — אל תמציא הלכה.
2. **מספר הלכות** — פסק רגיל מכיל 1-4 הלכות מחייבות. אל תמתח את הרשימה. אם אין הלכה — החזר []. 2. **מספר הלכות** — פסק רגיל מכיל 1-4 הלכות מחייבות. אל תמתח את הרשימה. אם אין הלכה — החזר [].
3. **לא לפצל יתר על המידה** — אם שני סעיפים מבטאים את אותו עיקרון, אחד את הניסוח. 3. **לא לפצל יתר על המידה — קריטי** — כל הלכה = שאלה משפטית מובחנת אחת. אם כמה סעיפים מבטאים פנים שונים של אותה שאלה משפטית — אחד אותם לכלל אחד (בחר את הניסוח הכללי/המחייב ביותר). אל תחזיר את אותו עיקרון בכמה ניסוחים.
4. **שפה** — rule_statement בעברית משפטית מקצועית, לא צמצום מילולי של הציטוט. 4. **שפה והפשטה** — rule_statement בעברית משפטית מקצועית בגוף שלישי, כעיקרון בר-הכללה לתיקים עתידיים — **לא** צמצום מילולי של הציטוט ולא קביעה התלויה בעובדות התיק.
5. **subject_tags** — 2-5 תגיות בעברית, snake_case (חניה, קווי_בניין, שיקול_דעת, פגם_פרוצדורלי, סמכות, מועדים, פגיעה_במקרקעין, ירידת_ערך). 5. **subject_tags** — 2-5 תגיות בעברית, snake_case (חניה, קווי_בניין, שיקול_דעת, פגם_פרוצדורלי, סמכות, מועדים, פגיעה_במקרקעין, ירידת_ערך).
6. **confidence** — 0..1. מתחת ל-0.7 = ספק לגבי היות זה הלכה מחייבת. 6. **confidence** — 0..1. מתחת ל-0.7 = ספק לגבי היות זה הלכה מחייבת.
""" """
@@ -131,8 +146,9 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
- **מסקנה מנומקת ומשכנעת** (rule_type=`persuasive`) — מסקנה שלמה של הפנל בסוגיה, עם ההיגיון התומך, ניתנת לציטוט כאסמכתא משכנעת. - **מסקנה מנומקת ומשכנעת** (rule_type=`persuasive`) — מסקנה שלמה של הפנל בסוגיה, עם ההיגיון התומך, ניתנת לציטוט כאסמכתא משכנעת.
**אין לחלץ:** **אין לחלץ:**
- ממצאים עובדתיים ספציפיים לתיק ("העורר לא הוכיח X"). - ממצאים עובדתיים ספציפיים לתיק או יישום על נסיבות התיק ("העורר לא הוכיח X", "במקרה דנן", שמות צדדים, סכומים קונקרטיים) — חלץ את העיקרון/היישום בניסוח בר-הכללה בלבד.
- ציטוטים מפסקי דין אחרים ללא ניתוח של הפנל. - סוגיה שהפנל לא הכריע בה ("אין צורך להכריע", "מבלי לקבוע מסמרות", "למעלה מן הצורך").
- ציטוטים מפסקי דין אחרים ללא ניתוח של הפנל, וכן ניסוח שהוא העתק של הציטוט ללא הפשטה.
- אמרות אגב חסרות חשיבות. - אמרות אגב חסרות חשיבות.
## תחומים אפשריים (practice_areas) — תחומי ועדת הערר בלבד ## תחומים אפשריים (practice_areas) — תחומי ועדת הערר בלבד
@@ -158,9 +174,9 @@ HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמח
## כללי איכות ## כללי איכות
1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תוסיף את ההלכה. 1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תוסיף את ההלכה.
2. **מספר הלכות** — החלטה ארוכה של ועדת ערר יכולה להניב 2-8 פריטים (יישומים + מסקנות). אם אין מה לחלץ — החזר []. 2. **מספר הלכות** — החלטה ארוכה של ועדת ערר יכולה להניב 2-8 פריטים (יישומים + מסקנות). אל תמתח את הרשימה. אם אין מה לחלץ — החזר [].
3. **rule_type מדויק** — application = יישום הלכה ידועה. interpretive = פרשנות. procedural = פרוצדורה. persuasive = מסקנה כללית בעלת ערך כאסמכתא. 3. **rule_type מדויק** — application = יישום הלכה ידועה. interpretive = פרשנות. procedural = פרוצדורה. persuasive = מסקנה כללית בעלת ערך כאסמכתא.
4. **לא לפצל יתר על המידה** — שני סעיפים זהים מבחינה רעיונית = פריט אחד. 4. **לא לפצל יתר על המידה — קריטי** — כל פריט = שאלה משפטית מובחנת אחת. פנים שונים של אותה שאלה = פריט אחד (בחר את הניסוח הכללי ביותר). אל תחזיר את אותו עיקרון בכמה ניסוחים.
5. **שפה** — עברית משפטית מקצועית, גוף שלישי. 5. **שפה** — עברית משפטית מקצועית, גוף שלישי.
6. **subject_tags** — 2-5 תגיות בעברית, snake_case. 6. **subject_tags** — 2-5 תגיות בעברית, snake_case.
7. **confidence** — 0..1. דייק. 7. **confidence** — 0..1. דייק.
@@ -268,6 +284,92 @@ def _coerce_halacha(raw: dict, is_binding: bool = True) -> dict | None:
} }
async def _nli_check(items: list[dict]) -> list[str]:
"""Entailment verdict per item (rule ⊨ quote) via claude_session — #81.3.
Local CLI, zero cost. FAILS OPEN: any error returns all-'entailed' so a
flaky/unavailable judge (e.g. in the container) never blocks a halacha.
"""
if not items:
return []
try:
raw = await claude_session.query_json(
halacha_quality.build_nli_prompt(items),
system=halacha_quality.NLI_SYSTEM,
model=config.HALACHA_NLI_MODEL or None,
effort=config.HALACHA_NLI_EFFORT or None,
)
except Exception as e:
logger.warning("halacha NLI check failed (fail-open, no flags): %s", e)
return ["entailed"] * len(items)
return halacha_quality.parse_nli_verdicts(raw, len(items))
def _consolidation_priority(r: dict):
"""Canonical = the row to KEEP within a fold group (lower sorts first)."""
status_rank = {"approved": 0, "published": 0, "pending_review": 1}.get(
r.get("review_status"), 2)
return (
status_rank,
-float(r.get("confidence") or 0.0),
0 if r.get("quote_verified") else 1,
-len(r.get("rule_statement") or ""),
str(r["id"]),
)
async def _consolidate_precedent(case_law_id: UUID) -> int:
"""#81.5 — fold facets of the SAME legal question into one canonical.
Per-precedent claude_session pass (local CLI, zero cost). Keeps the best row
of each fold group; marks the rest ``rejected`` (reversible — out of the
active corpus AND the review queue, but recoverable). FOLD-ONLY. Fails OPEN:
any error / parse failure → 0 folds (never touches data on doubt).
"""
if not config.HALACHA_CONSOLIDATE_ENABLED:
return 0
try:
rows = [
r for r in await db.list_halachot(case_law_id=case_law_id, limit=10_000)
if r.get("review_status") != "rejected"
]
if len(rows) < 2:
return 0
by_idx = {r["halacha_index"]: r for r in rows}
raw = await claude_session.query_json(
halacha_quality.build_consolidation_prompt(rows),
system=halacha_quality.CONSOLIDATE_SYSTEM,
model=config.HALACHA_CONSOLIDATE_MODEL or None,
effort=config.HALACHA_CONSOLIDATE_EFFORT or None,
)
groups = halacha_quality.parse_fold_groups(raw)
if not groups:
return 0
canonicals: set[str] = set()
losers: set[str] = set()
for g in groups:
members = [by_idx[i] for i in g if i in by_idx]
if len(members) < 2:
continue
members.sort(key=_consolidation_priority)
canonicals.add(str(members[0]["id"]))
for m in members[1:]:
losers.add(str(m["id"]))
# Never reject a row that is the canonical of any group.
loser_ids = [i for i in losers if i not in canonicals]
if not loser_ids:
return 0
return await db.update_halachot_batch(
loser_ids, "rejected", reviewer="auto-consolidated (#81.5 facet-fold)",
)
except Exception as e:
logger.warning(
"halacha consolidation failed for %s (fail-open, no folds): %s",
case_law_id, e,
)
return 0
async def _extract_chunk( async def _extract_chunk(
chunk_text: str, chunk_text: str,
section_type: str, section_type: str,
@@ -275,6 +377,7 @@ async def _extract_chunk(
chunk_total: int, chunk_total: int,
context: str, context: str,
is_binding: bool, is_binding: bool,
effort: str | None = None,
) -> tuple[list[dict], bool]: ) -> tuple[list[dict], bool]:
"""Run the halacha extractor on one chunk with retry. """Run the halacha extractor on one chunk with retry.
@@ -304,7 +407,12 @@ async def _extract_chunk(
last_err: Exception | None = None last_err: Exception | None = None
for attempt in range(CHUNK_RETRY_ATTEMPTS + 1): for attempt in range(CHUNK_RETRY_ATTEMPTS + 1):
try: try:
result = await claude_session.query_json(user_msg, system=base_prompt) result = await claude_session.query_json(
user_msg,
system=base_prompt,
model=config.HALACHA_EXTRACT_MODEL or None,
effort=(effort or config.HALACHA_EXTRACT_EFFORT) or None,
)
except Exception as e: except Exception as e:
last_err = e last_err = e
logger.warning( logger.warning(
@@ -325,11 +433,25 @@ async def _extract_chunk(
return [], False return [], False
async def extract(case_law_id: UUID | str) -> dict: async def extract(case_law_id: UUID | str, force: bool = False,
"""Extract halachot from an uploaded precedent and store them. effort: str | None = None) -> dict:
"""Extract halachot from an uploaded precedent — globally serialized.
Idempotent: replaces any existing halachot for this case_law_id. ``effort`` overrides the per-chunk LLM effort (default
All inserted rows start as ``review_status='pending_review'``. ``config.HALACHA_EXTRACT_EFFORT`` = xhigh). Bulk queue-drains pass the
lighter ``config.HALACHA_BULK_EXTRACT_EFFORT`` to cut wall-clock at scale.
``force=False`` (default) RESUMES: chunks already extracted (checkpointed)
are skipped, so a crash/interrupt never loses completed work or re-pays for
it. ``force=True`` wipes prior halachot + checkpoints and re-extracts all
(used by explicit re-extraction).
Takes a PostgreSQL advisory lock so only ONE extraction runs at a time
across ALL processes (agent retries + batch ``process_pending`` spawn
independent OS drivers; an in-process Semaphore can't see them). If another
extraction already holds the lock this returns ``status='busy'`` and the
precedent stays pending for the next drain — no second xhigh run piles on
(this is the fix for the 2026-05-31 box freeze).
Returns: Returns:
``{"status": "...", "extracted": N, "verified": M, "stored": K, ...}`` ``{"status": "...", "extracted": N, "verified": M, "stored": K, ...}``
@@ -337,6 +459,41 @@ async def extract(case_law_id: UUID | str) -> dict:
if isinstance(case_law_id, str): if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id) case_law_id = UUID(case_law_id)
pool = await db.get_pool()
lock_conn = await pool.acquire()
try:
got = await lock_conn.fetchval(
"SELECT pg_try_advisory_lock($1)", _HALACHA_EXTRACT_LOCK_KEY,
)
if not got:
logger.warning(
"halacha extract: global lock held by another extraction — "
"skipping %s (stays pending for next drain)", case_law_id,
)
return {
"status": "busy", "extracted": 0, "stored": 0,
"case_law_id": str(case_law_id),
}
try:
return await _extract_impl(case_law_id, force=force, effort=effort)
finally:
await lock_conn.fetchval(
"SELECT pg_advisory_unlock($1)", _HALACHA_EXTRACT_LOCK_KEY,
)
finally:
await pool.release(lock_conn)
async def _extract_impl(case_law_id: UUID, force: bool = False,
effort: str | None = None) -> dict:
"""Core extraction (caller holds the global advisory lock for the duration).
Crash-safe + resumable: each chunk's halachot are stored AND the chunk is
checkpointed (``precedent_chunks.halacha_extracted_at``) the moment it
finishes. A crash/interrupt loses at most the in-flight chunk; a re-run
resumes — already-done chunks are skipped, failed/pending chunks retried.
``force=True`` wipes prior halachot + checkpoints and re-extracts all.
"""
record = await db.get_case_law(case_law_id) record = await db.get_case_law(case_law_id)
if not record: if not record:
return {"status": "not_found", "extracted": 0, "stored": 0} return {"status": "not_found", "extracted": 0, "stored": 0}
@@ -363,85 +520,96 @@ async def extract(case_law_id: UUID | str) -> dict:
await db.set_case_law_halacha_status(case_law_id, "completed") await db.set_case_law_halacha_status(case_law_id, "completed")
return {"status": "no_chunks", "extracted": 0, "stored": 0} return {"status": "no_chunks", "extracted": 0, "stored": 0}
await db.set_case_law_halacha_status(case_law_id, "processing") # force = clean slate; otherwise resume (skip already-checkpointed chunks).
await db.delete_halachot(case_law_id) if force:
await db.reset_halacha_extraction(case_law_id)
for c in chunks:
c["halacha_extracted_at"] = None
await db.set_case_law_halacha_status(case_law_id, "processing")
pending = [c for c in chunks if c.get("halacha_extracted_at") is None]
# Legacy guard: a precedent extracted before V25 has halachot but NO chunk
# checkpoints. Re-extracting (append-per-chunk) would DUPLICATE them. If
# nothing is checkpointed yet but halachot already exist, backfill the
# checkpoints and treat as complete instead of re-extracting.
if not force and len(pending) == len(chunks):
already = await db.list_halachot(case_law_id=case_law_id, limit=1)
if already:
await db.mark_all_chunks_extracted(case_law_id)
total = len(await db.list_halachot(case_law_id=case_law_id, limit=10_000))
await db.set_case_law_halacha_status(case_law_id, "completed")
logger.info(
"halacha_extractor: case_law=%s legacy-backfill — %d existing "
"halachot, checkpoints backfilled (no re-extract).",
case_law_id, total,
)
return {"status": "completed", "extracted": total, "stored": total,
"legacy_backfill": True, "total_chunks": len(chunks)}
if not pending:
# Resume found nothing left — every chunk already extracted.
total = len(await db.list_halachot(case_law_id=case_law_id, limit=10_000))
await db.set_case_law_halacha_status(case_law_id, "completed")
return {"status": "completed", "extracted": total, "stored": total,
"resumed": True, "total_chunks": len(chunks)}
full_text = record.get("full_text") or ""
citation = record.get("case_number", "") citation = record.get("case_number", "")
court = record.get("court", "") court = record.get("court", "")
date_str = str(record.get("date") or "") date_str = str(record.get("date") or "")
context = f"מקור: {citation}{court}, {date_str}" context = f"מקור: {citation}{court}, {date_str}"
idx_by_id = {c["id"]: i for i, c in enumerate(chunks)}
sem = asyncio.Semaphore(CHUNK_CONCURRENCY) sem = asyncio.Semaphore(CHUNK_CONCURRENCY)
store_lock = asyncio.Lock() # serialize per-chunk stores (index continuity)
async def _bounded(idx: int, chunk_row: dict) -> tuple[list[dict], bool]: stored_total = 0
async with sem:
return await _extract_chunk(
chunk_row["content"], chunk_row["section_type"],
idx, len(chunks), context, is_binding,
)
chunk_results = await asyncio.gather(
*[_bounded(i, c) for i, c in enumerate(chunks)]
)
raw_halachot: list[dict] = []
failed_chunks = 0 failed_chunks = 0
for items, ok in chunk_results:
raw_halachot.extend(items)
if not ok:
failed_chunks += 1
# If most chunks failed (rate limit storm, claude_session crash, etc.) async def _process(chunk_row: dict) -> None:
# do NOT touch the DB status — leave it 'processing' so the caller can nonlocal stored_total, failed_chunks
# retry without the request falling out of the queue. The caller async with sem:
# (`process_pending_extractions`) is responsible for either retrying or items, ok = await _extract_chunk(
# finalising the status as 'failed' after retries are exhausted. This chunk_row["content"], chunk_row["section_type"],
# is the bug that produced 317/10's silent `no_halachot` after a idx_by_id[chunk_row["id"]], len(chunks), context, is_binding,
# 129-chunk neighbour saturated the API. effort,
failure_rate = failed_chunks / len(chunks) if chunks else 0
if failure_rate >= EXTRACTION_FAILURE_THRESHOLD and not raw_halachot:
logger.error(
"halacha_extractor: case_law=%s extraction_failed — "
"%d/%d chunks failed (rate=%.0f%%), no halachot retrieved. "
"DB status left as 'processing' for caller-level retry.",
case_law_id, failed_chunks, len(chunks), failure_rate * 100,
) )
return { if not ok:
"status": "extraction_failed", failed_chunks += 1 # leave chunk un-checkpointed → retried on resume
"extracted": 0, return
"stored": 0,
"failed_chunks": failed_chunks,
"total_chunks": len(chunks),
}
if not raw_halachot:
await db.set_case_law_halacha_status(case_law_id, "completed")
return {
"status": "no_halachot",
"extracted": 0,
"stored": 0,
"failed_chunks": failed_chunks,
"total_chunks": len(chunks),
}
# Validate against the full text of the precedent for the quote check.
full_text = record.get("full_text") or ""
cleaned: list[dict] = [] cleaned: list[dict] = []
for raw in raw_halachot: for raw in items:
coerced = _coerce_halacha(raw, is_binding=is_binding) coerced = _coerce_halacha(raw, is_binding=is_binding)
if coerced is None: if coerced is None:
continue continue
coerced["quote_verified"] = _verify_quote( coerced["quote_verified"] = _verify_quote(
coerced["supporting_quote"], full_text, coerced["supporting_quote"], full_text,
) )
# Strict-rubric quality gate (docs/halacha-strict-rubric.md):
# flags block auto-approval (route to pending_review); a court
# non-decision is re-typed obiter so it never reads as a holding.
flags = halacha_quality.compute_quality_flags(
coerced["rule_statement"], coerced["supporting_quote"],
coerced["reasoning_summary"], coerced["quote_verified"],
coerced["rule_type"],
)
coerced["quality_flags"] = flags
if halacha_quality.FLAG_NON_DECISION in flags and coerced["rule_type"] != "obiter":
coerced["rule_type"] = "obiter"
# #81.4 — a binding-labeled rule that reads as a case-application is
# re-typed application (it carries FLAG_APPLICATION either way).
elif (halacha_quality.FLAG_APPLICATION in flags
and coerced["rule_type"] == "binding"):
coerced["rule_type"] = "application"
cleaned.append(coerced) cleaned.append(coerced)
# #81.3 NLI entailment — one batched judge call per chunk (fail-open).
if not cleaned: if config.HALACHA_NLI_ENABLED and cleaned:
await db.set_case_law_halacha_status(case_law_id, "completed") verdicts = await _nli_check(cleaned)
return {"status": "no_valid_halachot", "extracted": len(raw_halachot), "stored": 0} for h, v in zip(cleaned, verdicts):
if v != "entailed" and halacha_quality.FLAG_NLI_UNSUPPORTED not in h["quality_flags"]:
# Embed rule_statement + reasoning_summary so semantic search hits the h["quality_flags"].append(halacha_quality.FLAG_NLI_UNSUPPORTED)
# rule directly rather than the surrounding chunk centroid. if cleaned:
embed_inputs = [ embed_inputs = [
f"{h['rule_statement']}{h['reasoning_summary']}".strip("") f"{h['rule_statement']}{h['reasoning_summary']}".strip("")
for h in cleaned for h in cleaned
@@ -451,23 +619,63 @@ async def extract(case_law_id: UUID | str) -> dict:
except Exception as e: except Exception as e:
logger.error("halacha_extractor: embeddings failed: %s", e) logger.error("halacha_extractor: embeddings failed: %s", e)
vectors = [None] * len(cleaned) vectors = [None] * len(cleaned)
for h, vec in zip(cleaned, vectors):
h["embedding"] = vec
# Store this chunk's halachot AND checkpoint the chunk, atomically.
async with store_lock:
stored_total += await db.store_halachot_for_chunk(
case_law_id, chunk_row["id"], cleaned,
)
for halacha, vec in zip(cleaned, vectors): await asyncio.gather(*[_process(c) for c in pending])
halacha["embedding"] = vec
stored = await db.store_halachot(case_law_id, cleaned) # Decide final status from what's LEFT (re-read checkpoints).
after = await db.list_precedent_chunks(case_law_id, section_types=EXTRACTABLE_SECTIONS)
if not after:
after = await db.list_precedent_chunks(case_law_id)
still_pending = sum(1 for c in after if c.get("halacha_extracted_at") is None)
total = len(await db.list_halachot(case_law_id=case_law_id, limit=10_000))
verified = sum(1 for h in cleaned if h["quote_verified"]) if still_pending:
# Some chunks failed this run. Leave status 'processing' so a resume
# continues them (no progress is lost — done chunks are checkpointed).
if total == 0 and failed_chunks >= len(pending) * EXTRACTION_FAILURE_THRESHOLD:
logger.error(
"halacha_extractor: case_law=%s extraction_failed — %d/%d pending "
"chunks failed, 0 stored. status left 'processing' for retry.",
case_law_id, failed_chunks, len(pending),
)
return {"status": "extraction_failed", "extracted": 0, "stored": 0,
"failed_chunks": failed_chunks, "pending_chunks": still_pending,
"total_chunks": len(chunks)}
logger.warning(
"halacha_extractor: case_law=%s partial — %d chunks still pending, "
"%d halachot stored so far. status 'processing' (resume to finish).",
case_law_id, still_pending, total,
)
return {"status": "partial", "extracted": total, "stored": stored_total,
"pending_chunks": still_pending, "total_chunks": len(chunks)}
# All chunks done. #81.5: fold cross-chunk facets of one legal question
# (the prompt dedups within a chunk; this catches across chunks).
folded = await _consolidate_precedent(case_law_id)
stored = total
verified = sum(1 for h in await db.list_halachot(case_law_id=case_law_id, limit=10_000)
if h.get("quote_verified"))
await db.set_case_law_halacha_status(case_law_id, "completed") await db.set_case_law_halacha_status(case_law_id, "completed")
logger.info( logger.info(
"halacha_extractor: case_law=%s extracted=%d cleaned=%d verified=%d stored=%d", "halacha_extractor: case_law=%s completed — %d halachot stored "
case_law_id, len(raw_halachot), len(cleaned), verified, stored, "(%d new this run), %d quote-verified, %d folded, %d chunks",
case_law_id, total, stored_total, verified, folded, len(chunks),
) )
return { return {
"status": "completed", "status": "completed",
"extracted": len(raw_halachot), "extracted": total,
"valid": len(cleaned),
"verified": verified, "verified": verified,
"folded": folded,
"stored": stored, "stored": stored,
"stored_this_run": stored_total,
"total_chunks": len(chunks),
} }

View File

@@ -0,0 +1,360 @@
"""Pure quality validators + dedup helpers for halacha extraction.
These encode the "strict rules" rubric (docs/halacha-strict-rubric.md) that
drove the 2026-06-03 corpus cleanup (1454→534), so that future extraction
comes out clean instead of accumulating duplicates, obiter dicta, truncated
quotes and thin restatements that clog the review queue.
Everything here is a PURE function (no DB, no LLM) so it is fully unit-tested.
The DB-touching dedup-on-insert (uses these helpers) lives in
``db.store_halachot_for_chunk``.
Flags produced by :func:`compute_quality_flags` BLOCK auto-approval (the item
routes to ``pending_review`` regardless of confidence) but never delete — the
chair still sees flagged items, just out of the auto-approved stream.
"""
from __future__ import annotations
import re
# ── Hebrew text normalization (shared with the extractor's quote check) ──
_HEB_QUOTE_VARIANTS = "\"'׳״‘’“”«»„′″"
def normalize_text(text: str) -> str:
"""Collapse whitespace and unify Hebrew quote-mark variants for matching.
Kept dependency-free (the extractor previously routed through
``proofreader._fix_hebrew_quotes``; here we inline a quote-class collapse so
this module stays pure and importable from anywhere).
"""
if not text:
return ""
# Unify the half-dozen quote/gershayim variants to a single ASCII quote.
unified = re.sub(f"[{re.escape(_HEB_QUOTE_VARIANTS)}]", '"', text)
return re.sub(r"\s+", " ", unified).strip()
# ── Non-decision / obiter detection (Wambaugh: the court did not decide) ──
#
# High-precision markers only. Phrases like "לכאורה" / "ניתן להניח" alone are
# too common to flag reliably, so we require the explicit "declined to rule"
# formulations the rubric calibration confirmed on שפר (idx 32: "איני רואה
# לקבוע מסמרות") and on 8027-25 (idx 18-19: "אין צורך להכריע").
NON_DECISION_MARKERS = (
"אין צורך להכריע",
"איני נדרש להכריע",
"איננו נדרשים להכריע",
"אין אנו נדרשים להכריע",
"מתייתר הצורך להכריע",
"אין צורך לקבוע מסמרות",
"מבלי לקבוע מסמרות",
"איני רואה לקבוע מסמרות",
"איננו רואים לקבוע מסמרות",
"אין לקבוע מסמרות",
"אין מקום לקבוע מסמרות",
"לא ראינו לקבוע מסמרות",
"למעלה מן הצורך",
"למעלה מהצורך",
"למעלה מן הדרוש",
"מעבר לנדרש",
"אגב אורחא",
"אגב אורחה",
)
def detect_non_decision(*texts: str) -> str | None:
"""Return the first non-decision marker found across ``texts`` (or None).
Scans rule_statement + reasoning_summary + supporting_quote — the court's
own hedge usually sits in the quote/reasoning, not the abstracted rule.
"""
joined = normalize_text(" ".join(t for t in texts if t))
for marker in NON_DECISION_MARKERS:
if marker in joined:
return marker
return None
# ── Truncated / incomplete supporting-quote detection ──
#
# Conservative: only flag a CLEAR mid-word cut — the quote's last whitespace-
# delimited token is a single Hebrew letter (a dangling construct/prefix such
# as the "...על ה" in 8099-02-17 idx 6). A complete clause ends in a full word,
# so this does not fire on quotes that merely lack a trailing period (the
# calibration showed ~1/3 of valid quotes drop the final period legitimately).
_HEB_LETTER = "א-ת"
def is_quote_truncated(quote: str) -> bool:
norm = normalize_text(quote)
if not norm:
return True
tokens = norm.split(" ")
last = tokens[-1].strip('".,;:)]')
# dangling single Hebrew letter at the end == cut mid-word
if len(last) == 1 and re.match(f"[{_HEB_LETTER}]", last):
return True
return False
# ── Thin restatement: rule_statement adds nothing over the quote ──
#
# Flag when the rule is essentially a copy of the quote: high token overlap AND
# the rule is no longer than the quote. A genuine halacha ABSTRACTS the rule, so
# it introduces wording the verbatim quote lacks and/or generalizes (longer or
# differently phrased).
_THIN_OVERLAP = 0.85
_THIN_LEN_RATIO = 1.10
def _tokens(text: str) -> set[str]:
norm = normalize_text(text)
return {t for t in re.split(r"[^א-ת0-9]+", norm) if len(t) > 1}
def is_thin_restatement(rule_statement: str, supporting_quote: str) -> bool:
rule_t = _tokens(rule_statement)
quote_t = _tokens(supporting_quote)
if not rule_t or not quote_t:
return False
overlap = len(rule_t & quote_t) / len(rule_t)
len_ratio = len(normalize_text(rule_statement)) / max(1, len(normalize_text(supporting_quote)))
return overlap >= _THIN_OVERLAP and len_ratio <= _THIN_LEN_RATIO
# ── Fact-dependent application: not a generalizable holding (#81.4) ──
#
# The strict rubric's cut_application (docs/halacha-strict-rubric.md §3, §27):
# a determination that rests on the case's specific facts/parties/amounts is an
# illustration, not a holding — it must not enter the corpus as a binding rule.
# The extractor already classifies ``rule_type='application'``; this is a
# HIGH-PRECISION secondary catch for rules the model mislabeled as binding,
# using only the unambiguous "applied to THIS case" deixis (bare party words
# like "המערער" appear in genuine rules too, so they are deliberately excluded).
_FACT_DEPENDENT_MARKERS = (
"במקרה דנן",
"במקרה שבפנינו",
"במקרה שלפנינו",
"במקרה שלפניי",
"בענייננו",
"בנדון דידן",
"בנדון דנן",
"במקרה שלנו",
"בנסיבות המקרה שלפנינו",
"בנסיבות תיק זה",
"בתיק שלפנינו",
"בערר שלפנינו",
"בערר דנן",
)
def is_fact_dependent(rule_statement: str) -> bool:
"""True when the rule is phrased as an application to THIS case (not a holding)."""
norm = normalize_text(rule_statement)
return any(marker in norm for marker in _FACT_DEPENDENT_MARKERS)
# ── Lexical near-duplicate signal (the 0.830.90 cosine tail) — #82.3 ──
#
# Embedding cosine alone misses paraphrases that float just below the dedup
# threshold (0.93). A secondary lexical signal — Jaccard over word-shingles +
# normalized Levenshtein on the rule_statement — catches "same rule, reworded"
# in that band without lowering the global cosine threshold. Hybrid
# lexical+semantic beats either alone (arXiv:1805.11611). Pure functions.
def _shingles(text: str, k: int = 2) -> set[str]:
words = [w for w in re.split(r"[^א-ת0-9]+", normalize_text(text)) if w]
if len(words) < k:
return {" ".join(words)} if words else set()
return {" ".join(words[i : i + k]) for i in range(len(words) - k + 1)}
def jaccard_shingles(a: str, b: str, k: int = 2) -> float:
sa, sb = _shingles(a, k), _shingles(b, k)
if not sa or not sb:
return 0.0
return len(sa & sb) / len(sa | sb)
def normalized_levenshtein(a: str, b: str) -> float:
"""1.0 == identical, 0.0 == fully different (edit distance / max len)."""
a, b = normalize_text(a), normalize_text(b)
if not a and not b:
return 1.0
if not a or not b:
return 0.0
# classic DP edit distance (rule_statements are short — a few hundred chars)
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
cur = [i]
for j, cb in enumerate(b, 1):
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
prev = cur
return 1.0 - prev[-1] / max(len(a), len(b))
_LEX_JACCARD_MIN = 0.55
_LEX_LEVENSHTEIN_MIN = 0.70
def lexical_near_duplicate(
a: str, b: str, jaccard_min: float = _LEX_JACCARD_MIN,
levenshtein_min: float = _LEX_LEVENSHTEIN_MIN,
) -> bool:
"""High lexical overlap → likely the same rule reworded (for the cosine tail)."""
return (jaccard_shingles(a, b) >= jaccard_min
or normalized_levenshtein(a, b) >= levenshtein_min)
# ── Aggregate ──
FLAG_NON_DECISION = "non_decision"
FLAG_TRUNCATED_QUOTE = "truncated_quote"
FLAG_THIN_RESTATEMENT = "thin_restatement"
FLAG_QUOTE_UNVERIFIED = "quote_unverified"
FLAG_NLI_UNSUPPORTED = "nli_unsupported" # rule not entailed by its quote (#81.3)
FLAG_APPLICATION = "application" # fact-dependent, not a holding (#81.4)
FLAG_NEAR_DUPLICATE = "near_duplicate" # cosine-tail lexical dup (#82.3)
# ── NLI entailment check (rule_statement ⊨ supporting_quote) — #81.3 ──
#
# Pure prompt-builder + verdict-parser; the LLM call itself runs through
# claude_session in halacha_extractor (local CLI, zero cost). A rule that the
# quote does not actually support (neutral) or contradicts is the model
# over-reaching beyond its source — flag it (blocks auto-approve). EVERYTHING
# here fails OPEN: any parse ambiguity resolves to "entailed" so a flaky judge
# never blocks a genuine halacha.
NLI_SYSTEM = (
"אתה בודק היסק (entailment) משפטי. לכל זוג {כלל, ציטוט} החלט האם **הכלל נובע מהציטוט** — "
"כלומר הציטוט תומך בכלל ואינו מרחיב מעבר למה שנכתב בו. שלוש תוויות בלבד:\n"
"- entailed = הכלל נתמך במלואו בציטוט.\n"
"- neutral = הציטוט אינו תומך בכלל (הכלל מרחיב/מוסיף מעבר לציטוט).\n"
"- contradiction = הכלל סותר את הציטוט.\n"
'החזר JSON array בלבד באורך מספר הזוגות, לדוגמה: ["entailed","neutral",...]. '
"ללא markdown, ללא הסבר."
)
_NLI_LABELS = {"entailed", "neutral", "contradiction"}
def build_nli_prompt(items: list[dict]) -> str:
"""Build the user message: a numbered list of {rule, quote} pairs."""
blocks = []
for i, h in enumerate(items, 1):
rule = (h.get("rule_statement") or "").strip()
quote = (h.get("supporting_quote") or "").strip()
blocks.append(f"### זוג {i}\nכלל: {rule}\nציטוט: {quote}")
return "\n\n".join(blocks)
def parse_nli_verdicts(raw, n: int) -> list[str]:
"""Coerce the judge's output into exactly ``n`` labels — fail-open.
Any shape mismatch / unknown label resolves to 'entailed' so a flaky or
unavailable judge never blocks a halacha.
"""
if not isinstance(raw, list) or len(raw) != n:
return ["entailed"] * n
out: list[str] = []
for item in raw:
v = item.get("verdict") if isinstance(item, dict) else item
v = str(v or "").strip().lower()
out.append(v if v in _NLI_LABELS else "entailed")
return out
# ── Over-extraction consolidation (fold facets of one legal question) — #81.5 ──
#
# #82 dedup-on-insert removes near-EXACT dups (cosine ≥ 0.93). #81.5 handles the
# remaining over-extraction: facets of the SAME legal question, phrased
# differently, that sit BELOW the dedup threshold (the שפר 14-vs-4 / 403-17→89
# granularity gap). A per-precedent claude_session pass groups such facets; the
# extractor keeps one canonical per group and marks the rest rejected (reversible,
# out of the active corpus + review queue). FOLD-ONLY — never merges distinct
# legal questions, never invents. Fails OPEN (parse error → no folds).
CONSOLIDATE_SYSTEM = (
"אתה מאחד פנים-כפולים של הלכות שחולצו מאותו פסק דין. בהינתן רשימה ממוספרת של הלכות, "
"זהה קבוצות של הלכות שהן **אותה שאלה משפטית** בניסוחים או פנים שונים. "
"כללים: (1) אַחֵד רק הלכות שעונות על אותה שאלה משפטית בדיוק; (2) **אל תאַחֵד** הלכות "
"שעונות על שאלות משפטיות שונות (גם אם קרובות בנושא); (3) הלכה ייחודית — אל תכלול בשום קבוצה. "
'החזר JSON array של קבוצות, כל קבוצה = array של מספרי-האינדקס שיש לאַחֵד (לפחות 2 חברים). '
"לדוגמה: [[2,5,9],[14,18]]. אם אין מה לאַחֵד החזר []. ללא markdown, ללא הסבר."
)
def build_consolidation_prompt(items: list[dict]) -> str:
"""Numbered list of a precedent's halachot (index + rule + reasoning)."""
blocks = []
for h in items:
idx = h.get("halacha_index")
rule = (h.get("rule_statement") or "").strip()
reason = (h.get("reasoning_summary") or "").strip()
line = f"[{idx}] {rule}"
if reason:
line += f" (היגיון: {reason})"
blocks.append(line)
return "\n".join(blocks)
def parse_fold_groups(raw) -> list[list[int]]:
"""Coerce judge output into a list of fold-groups (≥2 int indices each).
Fails SAFE: any malformed shape → [] (no folding). Non-int / <2-member
groups are dropped.
"""
if not isinstance(raw, list):
return []
groups: list[list[int]] = []
for g in raw:
if not isinstance(g, list):
continue
members: list[int] = []
for x in g:
try:
members.append(int(x))
except (TypeError, ValueError):
continue
# dedup within group, preserve order
seen: set[int] = set()
members = [m for m in members if not (m in seen or seen.add(m))]
if len(members) >= 2:
groups.append(members)
return groups
def compute_quality_flags(
rule_statement: str,
supporting_quote: str,
reasoning_summary: str = "",
quote_verified: bool = True,
rule_type: str = "binding",
) -> list[str]:
"""Return the list of quality flags for one halacha (empty == clean).
Any non-empty result blocks auto-approval (routes to pending_review).
"""
flags: list[str] = []
if detect_non_decision(rule_statement, reasoning_summary, supporting_quote):
flags.append(FLAG_NON_DECISION)
if is_quote_truncated(supporting_quote):
flags.append(FLAG_TRUNCATED_QUOTE)
if is_thin_restatement(rule_statement, supporting_quote):
flags.append(FLAG_THIN_RESTATEMENT)
if not quote_verified:
flags.append(FLAG_QUOTE_UNVERIFIED)
# #81.4 — an application (fact-dependent) item is an illustration, not a
# generalizable holding: never auto-approve it. Trust the model's
# rule_type='application' and add a high-precision deixis catch.
if rule_type == "application" or is_fact_dependent(rule_statement):
flags.append(FLAG_APPLICATION)
return flags

View File

@@ -0,0 +1,295 @@
"""Canonical ingest pipeline (FU-1).
One pipeline for all sibling-entity intake types (external precedent,
internal committee decision). Per-type variation rides on an ``IntakeSpec``
config object — never a parallel function. See
docs/spec/01-ingest.md and docs/superpowers/specs/2026-05-30-fu1-unified-ingest-design.md.
claude_session rule preserved: this module only QUEUES extraction
(``request_*_extraction`` = pure DB writes). It never imports
halacha_extractor / precedent_metadata_extractor, so it is safe to call
from the FastAPI container where the ``claude`` CLI is unavailable.
"""
from __future__ import annotations
import asyncio
import logging
import re
import shutil
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from typing import Awaitable, Callable
from uuid import UUID, uuid4
from legal_mcp import config
from legal_mcp.services import chunker, db, embeddings, extractor
logger = logging.getLogger(__name__)
ProgressCb = Callable[[str, int, str], Awaitable[None]]
async def _noop_progress(_status: str, _percent: int, _msg: str) -> None:
return None
@dataclass(frozen=True)
class IntakeSpec:
"""Describes everything that varies between intake types."""
source_kind: str
id_field: str
staging_root: Path
staging_subdir: Callable[[dict], str]
validate: Callable[[dict], None]
enum_fields: dict[str, frozenset[str]]
derive: Callable[[dict], dict]
display_name_fallback: str
create_record: Callable[..., Awaitable[dict]]
def _coerce_date(value) -> date | None:
if value is None or value == "":
return None
if isinstance(value, date):
return value
if isinstance(value, str):
try:
return date.fromisoformat(value[:10])
except ValueError:
return None
return None
def _safe_filename(name: str) -> str:
base = Path(name).name
return re.sub(r"[^\w.\-+א-ת ]", "_", base) or f"upload-{uuid4().hex[:8]}"
def _stage_file(src_path: Path, root: Path, subdir: str) -> Path:
dest_dir = root / (subdir or "other")
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / f"{uuid4().hex[:8]}_{_safe_filename(src_path.name)}"
shutil.copy2(src_path, dest)
return dest
def _validate_enums(spec: IntakeSpec, inputs: dict) -> None:
for field_name, allowed in spec.enum_fields.items():
value = inputs.get(field_name, "") or ""
if value not in allowed:
raise ValueError(f"invalid {field_name}: {value!r}")
async def _embed_pages(case_law_id: UUID, pdf_path: Path, page_count: int) -> dict:
"""Render PDF pages → embed via voyage-multimodal → store. Non-fatal caller."""
thumb_dir = spec_thumb_dir(case_law_id)
rendered = await asyncio.to_thread(
extractor.render_pages_for_multimodal,
pdf_path, config.MULTIMODAL_DPI, config.MULTIMODAL_THUMB_DPI, thumb_dir,
)
images = [pil for pil, _ in rendered]
thumbs = [t for _, t in rendered]
img_embs = await embeddings.embed_images(images)
page_records = []
for i, (emb, thumb) in enumerate(zip(img_embs, thumbs)):
rel_thumb = None
if thumb is not None:
try:
rel_thumb = str(thumb.relative_to(config.DATA_DIR))
except ValueError:
rel_thumb = str(thumb)
page_records.append({
"page_number": i + 1, "embedding": emb, "image_thumbnail_path": rel_thumb,
})
stored = await db.store_precedent_image_embeddings(
case_law_id, page_records, model_name=config.MULTIMODAL_MODEL,
)
logger.info("Multimodal: stored %d page-image embeddings for case_law %s", stored, case_law_id)
return {"pages_embedded": stored}
def spec_thumb_dir(case_law_id: UUID) -> Path:
"""Thumbnails live under the precedent-library tree regardless of intake type."""
return Path(config.DATA_DIR) / "precedent-library" / "thumbnails" / str(case_law_id)
async def ingest_document(
spec: IntakeSpec,
*,
inputs: dict,
file_path: str | Path | None = None,
text: str | None = None,
document_id: UUID | None = None,
progress: ProgressCb | None = None,
) -> dict:
"""Run the canonical 12-step pipeline for one intake item.
``inputs`` carries the type-specific record fields (citation/case_number,
case_name, court, practice_area, etc.). ``spec`` decides how they are
validated, staged, derived, and which DB-create runs. Returns a dict with
at least: status, case_law_id, chunks.
"""
progress = progress or _noop_progress
# Step 1: input validation (type-specific) + enums (uniform mechanism).
if not file_path and text is None:
raise ValueError("either file_path or text is required")
spec.validate(inputs)
_validate_enums(spec, inputs)
# Step 2: field derivation (identity for external).
inputs = {**inputs, **spec.derive(inputs)}
# Steps 3-5: stage (if file) + extract + strip.
page_count = 0
page_offsets = None
staged: Path | None = None
if file_path:
src = Path(file_path)
if not src.is_file():
raise FileNotFoundError(f"file not found: {src}")
await progress("staging", 5, "מעתיק את הקובץ לאחסון")
staged = _stage_file(src, spec.staging_root, spec.staging_subdir(inputs))
await progress("extracting", 15, "מחלץ טקסט מהקובץ")
try:
raw_text, page_count, page_offsets = await extractor.extract_text(str(staged))
except Exception as e:
await progress("failed", 100, f"כשל בחילוץ טקסט: {e}")
raise
raw_text = (raw_text or "")
else:
raw_text = (text or "")
# Capture the Nevo מיני-רציו (editorial holdings summary) BEFORE stripping
# it out — it is a free professional gold-set for benchmarking halacha
# extraction (#86.3). Stored on the case_law row below once we have its id.
nevo_ratio = extractor.extract_nevo_ratio(raw_text)
raw_text = extractor.strip_nevo_preamble(raw_text).strip()
if not raw_text:
await progress("failed", 100, "לא נמצא טקסט בקובץ")
raise ValueError("no extractable text in file")
# Step 6: DB create (type-specific, routed — get case_law_id).
await progress("storing_metadata", 25, "שומר את הרשומה במסד הנתונים")
display_name = (inputs.get("case_name") or "").strip() or (
inputs.get(spec.display_name_fallback) or ""
).strip()
record = await spec.create_record(
full_text=raw_text,
case_name=display_name,
decision_date=_coerce_date(inputs.get("decision_date")),
document_id=document_id,
**{k: v for k, v in inputs.items()
if k not in {"case_name", "decision_date", "file_path", "text"}},
)
case_law_id = UUID(str(record["id"]))
# Persist the captured mini-ratio (best-effort; never block ingest on it).
if nevo_ratio:
try:
await db.update_case_law(case_law_id, nevo_ratio=nevo_ratio)
except Exception as e: # noqa: BLE001 — additive metadata, non-fatal
logger.warning("could not store nevo_ratio for %s: %s", case_law_id, e)
try:
stored_chunks = await _chunk_embed_store(case_law_id, raw_text, page_offsets, page_count, progress)
await db.mark_indexed(case_law_id)
# Step 9: multimodal — uniform: flag + PDF + page_count, NOT intake type.
if (config.MULTIMODAL_ENABLED and page_count > 0
and staged is not None and staged.suffix.lower() == ".pdf"):
try:
await progress("embedding_images", 70, f"מטמיע {page_count} עמודי תמונה (multimodal)")
await _embed_pages(case_law_id, staged, page_count)
except Exception as e:
logger.warning("Multimodal embedding failed (non-fatal): %s", e)
# Steps 10-12: queue BOTH extractions (GAP-02 fix) + statuses.
await db.set_case_law_extraction_status(case_law_id, "completed")
await db.set_case_law_halacha_status(case_law_id, "pending")
await db.request_metadata_extraction(case_law_id)
await db.request_halacha_extraction(case_law_id)
await db.recompute_searchable(case_law_id)
await progress("completed", 100,
f"נקלט: {stored_chunks} chunks. חילוץ הלכות ומטא-דאטה ממתינים בתור.")
return {
"status": "completed",
"case_law_id": str(case_law_id),
"chunks": stored_chunks,
"halachot": 0,
"halachot_pending": True,
"metadata_filled": [],
"pages": page_count,
}
except Exception as e:
logger.exception("ingest_document failed (%s): %s", spec.source_kind, e)
await db.set_case_law_extraction_status(case_law_id, "failed")
await progress("failed", 100, f"כשל בעיבוד: {e}")
raise
async def _chunk_embed_store(case_law_id, text, page_offsets, page_count, progress) -> int:
"""Steps 7-8: chunk (hierarchical/flat by flag) → embed children → store."""
if config.PARENT_DOC_RETRIEVAL_ENABLED:
await progress("chunking", 40, f"מחלק את הטקסט ל-chunks היררכיים ({page_count} עמ')")
h_chunks = chunker.chunk_document_hierarchical(text, page_offsets=page_offsets)
if not h_chunks:
return 0
children = [c for c in h_chunks if c.role == "child"]
parents = [c for c in h_chunks if c.role == "parent"]
await progress("embedding", 55, f"מייצר embeddings ל-{len(children)} children ({len(parents)} parents)")
child_vectors = await embeddings.embed_texts([c.content for c in children], input_type="document")
chunk_dicts: list[dict] = []
for p in parents:
chunk_dicts.append({
"role": "parent", "local_id": p.local_id, "parent_local_id": None,
"chunk_index": p.chunk_index, "content": p.content,
"section_type": p.section_type, "page_number": p.page_number, "embedding": None,
})
for c, v in zip(children, child_vectors):
chunk_dicts.append({
"role": "child", "local_id": c.local_id, "parent_local_id": c.parent_local_id,
"chunk_index": c.chunk_index, "content": c.content,
"section_type": c.section_type, "page_number": c.page_number, "embedding": v,
})
counts = await db.store_precedent_chunks_hierarchical(case_law_id, chunk_dicts)
return counts["children"]
else:
await progress("chunking", 40, f"מחלק את הטקסט ל-chunks ({page_count} עמ')")
chunks = chunker.chunk_document(text, page_offsets=page_offsets)
if not chunks:
return 0
await progress("embedding", 55, f"מייצר embeddings ל-{len(chunks)} chunks")
chunk_vectors = await embeddings.embed_texts([c.content for c in chunks], input_type="document")
chunk_dicts = [
{"chunk_index": c.chunk_index, "content": c.content,
"section_type": c.section_type, "page_number": c.page_number, "embedding": v}
for c, v in zip(chunks, chunk_vectors)
]
return await db.store_precedent_chunks(case_law_id, chunk_dicts)
async def reindex_case_law(
case_law_id: "UUID | str",
progress: ProgressCb | None = None,
) -> dict:
"""Re-chunk + re-embed an existing case_law row from its STORED full_text (GAP-09).
No re-extract / no re-OCR (uses the stored text — see feedback_no_reocr_retrofit)
and no LLM/CLI (only chunker + voyage embeddings), so it is safe to run anywhere.
Idempotent: store_precedent_chunks(_hierarchical) is DELETE-then-INSERT.
"""
progress = progress or _noop_progress
cid = case_law_id if isinstance(case_law_id, UUID) else UUID(str(case_law_id))
row = await db.get_case_law(cid)
if not row:
raise ValueError(f"case_law not found: {cid}")
text = (row.get("full_text") or "").strip()
if not text:
raise ValueError("case_law has no stored full_text to re-index")
stored = await _chunk_embed_store(cid, text, None, 0, progress)
await db.mark_indexed(cid)
await progress("completed", 100, f"הוטמע מחדש: {stored} chunks")
return {"status": "completed", "case_law_id": str(cid), "chunks": stored, "reindexed": True}

View File

@@ -16,21 +16,19 @@ Judicial decisions (Supreme Court, Administrative Court) stay in external_upload
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import shutil
from datetime import date
from pathlib import Path from pathlib import Path
from uuid import UUID, uuid4 from uuid import UUID
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import chunker, db, embeddings, extractor from legal_mcp.services import db, embeddings, ingest
from legal_mcp.services.practice_area import derive_proceeding_type from legal_mcp.services.practice_area import derive_proceeding_type
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
INTERNAL_DECISIONS_DIR = Path(config.DATA_DIR) / "internal-decisions" INTERNAL_DECISIONS_DIR = Path(config.DATA_DIR) / "internal-decisions"
_VALID_DISTRICTS = {"", "ירושלים", "מרכז", "תל אביב", "צפון", "דרום", "ארצי"} _VALID_PRACTICE_AREAS = frozenset({"", "rishuy_uvniya", "betterment_levy", "compensation_197"})
_VALID_DISTRICTS = frozenset({"", "ירושלים", "מרכז", "תל אביב", "צפון", "דרום", "ארצי"})
_COURT_TO_DISTRICT = [ _COURT_TO_DISTRICT = [
("ירושלים", "ירושלים"), ("ירושלים", "ירושלים"),
@@ -45,24 +43,6 @@ _COURT_TO_DISTRICT = [
] ]
def _coerce_date(value) -> date | None:
if value is None or value == "":
return None
if isinstance(value, date):
return value
if isinstance(value, str):
try:
return date.fromisoformat(value[:10])
except ValueError:
return None
return None
def _safe_filename(name: str) -> str:
base = Path(name).name
return re.sub(r"[^\w.\-+א-ת ]", "_", base) or f"internal-{uuid4().hex[:8]}"
def _district_from_court(court: str) -> str: def _district_from_court(court: str) -> str:
for keyword, district in _COURT_TO_DISTRICT: for keyword, district in _COURT_TO_DISTRICT:
if keyword in court: if keyword in court:
@@ -70,6 +50,51 @@ def _district_from_court(court: str) -> str:
return "" return ""
def _internal_validate(inputs: dict) -> None:
if not (inputs.get("case_number") or "").strip():
raise ValueError("case_number is required")
def _internal_derive(inputs: dict) -> dict:
district = (inputs.get("district") or "").strip() or _district_from_court(inputs.get("court") or "")
proc = (inputs.get("proceeding_type") or "").strip() or derive_proceeding_type(
appeal_subtype=inputs.get("appeal_subtype") or "", subject=inputs.get("case_name") or "",
)
return {"district": district, "proceeding_type": proc}
async def _create_internal_record(**kw) -> dict:
return await db.create_internal_committee_decision(
case_number=kw["case_number"].strip(),
case_name=kw["case_name"],
full_text=kw["full_text"],
court=(kw.get("court") or "").strip(),
decision_date=kw.get("decision_date"),
chair_name=(kw.get("chair_name") or "").strip(),
district=kw.get("district", ""),
practice_area=kw.get("practice_area", ""),
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),
document_id=kw.get("document_id"),
proceeding_type=kw.get("proceeding_type") or "ערר",
)
_INTERNAL_SPEC = ingest.IntakeSpec(
source_kind="internal_committee",
id_field="case_number",
staging_root=INTERNAL_DECISIONS_DIR,
staging_subdir=lambda inputs: (inputs.get("district") or "other"),
validate=_internal_validate,
enum_fields={"practice_area": _VALID_PRACTICE_AREAS, "district": _VALID_DISTRICTS},
derive=_internal_derive,
display_name_fallback="case_number",
create_record=_create_internal_record,
)
async def ingest_internal_decision( async def ingest_internal_decision(
*, *,
case_number: str, case_number: str,
@@ -86,141 +111,25 @@ async def ingest_internal_decision(
file_path: str | Path | None = None, file_path: str | Path | None = None,
text: str | None = None, text: str | None = None,
document_id: UUID | None = None, document_id: UUID | None = None,
queue_halachot: bool = True,
proceeding_type: str = "", proceeding_type: str = "",
) -> dict: ) -> dict:
"""Ingest an appeals-committee decision into the internal corpus. """Ingest one appeals-committee decision. Thin wrapper over the canonical pipeline."""
inputs = {
Either file_path or text must be provided. "case_number": case_number, "case_name": case_name, "court": court,
If district is empty, it is inferred from court. "decision_date": decision_date, "chair_name": chair_name, "district": district,
If proceeding_type is empty, it is derived from appeal_subtype/case_name. "practice_area": practice_area, "appeal_subtype": appeal_subtype,
Returns: {"status": "completed", "case_law_id": "...", "chunks": N} "subject_tags": subject_tags, "summary": summary, "is_binding": is_binding,
""" "proceeding_type": proceeding_type,
if not file_path and not text: }
raise ValueError("either file_path or text is required") out = await ingest.ingest_document(
if not case_number.strip(): _INTERNAL_SPEC, inputs=inputs, file_path=file_path, text=text,
raise ValueError("case_number is required")
resolved_district = district.strip() or _district_from_court(court)
resolved_proc = proceeding_type.strip() or derive_proceeding_type(
appeal_subtype=appeal_subtype, subject=case_name,
)
if file_path:
src = Path(file_path)
if not src.is_file():
raise FileNotFoundError(f"file not found: {src}")
dest_dir = INTERNAL_DECISIONS_DIR / (resolved_district or "other")
dest_dir.mkdir(parents=True, exist_ok=True)
staged = dest_dir / f"{uuid4().hex[:8]}_{_safe_filename(src.name)}"
shutil.copy2(src, staged)
raw_text, page_count, page_offsets = await extractor.extract_text(str(staged))
raw_text = extractor.strip_nevo_preamble(raw_text or "").strip()
if not raw_text:
raise ValueError("no extractable text in file")
else:
raw_text = (text or "").strip()
if not raw_text:
raise ValueError("text is empty")
page_count = 0
page_offsets = None
record = await db.create_internal_committee_decision(
case_number=case_number.strip(),
case_name=(case_name.strip() or case_number.strip()),
full_text=raw_text,
court=court.strip(),
decision_date=_coerce_date(decision_date),
chair_name=chair_name.strip(),
district=resolved_district,
practice_area=practice_area,
appeal_subtype=appeal_subtype.strip(),
subject_tags=list(subject_tags or []),
summary=summary.strip(),
is_binding=is_binding,
document_id=document_id, document_id=document_id,
proceeding_type=resolved_proc,
) )
case_law_id = UUID(str(record["id"])) return {"status": out["status"], "case_law_id": out["case_law_id"],
"chunks": out["chunks"], "halachot_pending": True}
try:
# Parent-doc retrieval (TaskMaster #48) — same gated branch as
# ingest_precedent. Internal committee decisions are typically
# longer than external court rulings (full transcript + ruling),
# so the parent-doc benefit is even larger here.
if config.PARENT_DOC_RETRIEVAL_ENABLED:
h_chunks = chunker.chunk_document_hierarchical(
raw_text, page_offsets=page_offsets,
)
if not h_chunks:
await db.set_case_law_extraction_status(case_law_id, "completed")
await db.set_case_law_halacha_status(case_law_id, "completed")
return {"status": "completed", "case_law_id": str(case_law_id), "chunks": 0}
children = [c for c in h_chunks if c.role == "child"]
parents = [c for c in h_chunks if c.role == "parent"]
child_vectors = await embeddings.embed_texts(
[c.content for c in children], input_type="document",
)
chunk_dicts: list[dict] = []
for p in parents:
chunk_dicts.append({
"role": "parent", "local_id": p.local_id, "parent_local_id": None,
"chunk_index": p.chunk_index, "content": p.content,
"section_type": p.section_type, "page_number": p.page_number,
"embedding": None,
})
for c, v in zip(children, child_vectors):
chunk_dicts.append({
"role": "child", "local_id": c.local_id,
"parent_local_id": c.parent_local_id,
"chunk_index": c.chunk_index, "content": c.content,
"section_type": c.section_type, "page_number": c.page_number,
"embedding": v,
})
counts = await db.store_precedent_chunks_hierarchical(
case_law_id, chunk_dicts,
)
stored = counts["children"]
else:
chunks = chunker.chunk_document(raw_text, page_offsets=page_offsets)
if not chunks:
await db.set_case_law_extraction_status(case_law_id, "completed")
await db.set_case_law_halacha_status(case_law_id, "completed")
return {"status": "completed", "case_law_id": str(case_law_id), "chunks": 0}
chunk_texts = [c.content for c in chunks]
chunk_vectors = await embeddings.embed_texts(chunk_texts, input_type="document")
chunk_dicts = [
{
"chunk_index": c.chunk_index,
"content": c.content,
"section_type": c.section_type,
"page_number": c.page_number,
"embedding": v,
}
for c, v in zip(chunks, chunk_vectors)
]
stored = await db.store_precedent_chunks(case_law_id, chunk_dicts)
await db.set_case_law_extraction_status(case_law_id, "completed")
await db.set_case_law_halacha_status(case_law_id, "pending")
if queue_halachot:
await db.request_halacha_extraction(case_law_id)
return {
"status": "completed",
"case_law_id": str(case_law_id),
"chunks": stored,
"halachot_pending": True,
}
except Exception:
logger.exception("ingest_internal_decision failed for %s", case_number)
await db.set_case_law_extraction_status(case_law_id, "failed")
raise
async def migrate_from_style_corpus(dry_run: bool = False, queue_halachot: bool = True) -> dict: async def migrate_from_style_corpus(dry_run: bool = False) -> dict:
"""Re-index all style_corpus entries as searchable internal committee decisions. """Re-index all style_corpus entries as searchable internal committee decisions.
Does NOT delete style_corpus rows — they remain for style analysis. Does NOT delete style_corpus rows — they remain for style analysis.
@@ -278,7 +187,6 @@ async def migrate_from_style_corpus(dry_run: bool = False, queue_halachot: bool
appeal_subtype=subtype, appeal_subtype=subtype,
subject_tags=subject_tags, subject_tags=subject_tags,
text=row["full_text"], text=row["full_text"],
queue_halachot=queue_halachot,
) )
results["ingested"] += 1 results["ingested"] += 1
logger.info("Migrated style_corpus entry: %s", case_number) logger.info("Migrated style_corpus entry: %s", case_number)

View File

@@ -51,26 +51,25 @@ def compute_diff_stats(draft_text: str, final_text: str) -> dict:
} }
LESSONS_PROMPT = """אתה מנתח שינויים בהחלטות משפטיות. קיבלת טיוטה (שנוצרה ע"י AI) וגרסה סופית (שעברה עריכת דפנה). LESSONS_PROMPT = """אתה מנתח את הפער בין טיוטה (AI) לגרסה סופית שדפנה תמיר חתמה, כדי ללמוד **איך דפנה כותבת ומנתחת** — לא את ההלכה הספציפית.
## הבחנה קריטית (INV-LRN5 — טוהר-הקול):
לכל שינוי קבע `domain`:
- **style_method** — *איך* דפנה כותבת/חושבת: ניסוח, קצב, מבנה, תנועות-הנמקה, ביטויי-מעבר, טון, סדר-טיפול. **זה מה שלומדים** (ניתן להכללה לכל תיק).
- **substance** — תוכן ספציפי-לתיק: הלכה, עובדה, תקדים, מספר. **לא לומדים** (לא ניתן לגרור לתיק אחר).
## משימה: ## משימה:
1. זהה את השינויים המהותיים (לא הקלדה/פורמט) 1. זהה שינויים מהותיים (לא הקלדה/פורמט/מספור-אוטומטי).
2. סווג כל שינוי: 2. לכל שינוי: `type` (expression_change / structure_change / content_addition / content_removal / tone_change / reasoning_move / error_fix) + `domain` (style_method / substance).
- expression_change — ביטוי שהוחלף (הצע כלקח לעתיד) 3. הסק לקח **מופשט** (על השיטה/הקול, לא על התוכן) — רק עבור style_method.
- structure_change — שינוי מבני (סדר, חלוקה)
- content_addition — תוכן שנוסף (מה חסר?)
- content_removal — תוכן שהוסר (מה מיותר?)
- tone_change — שינוי טון (רשמי יותר/פחות)
- error_fix — תיקון שגיאה עובדתית/משפטית
3. הסק לקחים שניתן להפעיל בהחלטות עתידיות
## פלט JSON: ## פלט JSON:
{ {
"changes": [ "changes": [
{"type": "...", "description": "תיאור השינוי", "draft_text": "...", "final_text": "...", "lesson": "לקח לעתיד"} {"type": "...", "domain": "style_method|substance", "block": "block-yod", "description": "...", "draft_text": "...", "final_text": "...", "lesson": "לקח מופשט (style_method בלבד)"}
], ],
"new_expressions": ["ביטוי חדש שדפנה הוסיפה"], "new_expressions": ["ביטוי-מעבר/נוסחה חדשים (style_method בלבד — לא הלכות)"],
"overall_assessment": "הערכה כללית (1-2 משפטים)" "overall_assessment": "1-2 משפטים"
} }
""" """
@@ -114,9 +113,22 @@ async def process_final_version(
if not decision: if not decision:
raise ValueError(f"No decision for case {case_id}") raise ValueError(f"No decision for case {case_id}")
# Get draft text (combine all blocks) # Prefer the immutable snapshot captured at mark-final (T5/INV-LRN4); fall back
# to the live blocks (which may have been edited after sign-off).
pool = await db.get_pool() pool = await db.get_pool()
pair_id = None
draft_text = ""
async with pool.acquire() as conn: async with pool.acquire() as conn:
pair = await conn.fetchrow(
"""SELECT id, draft_text FROM draft_final_pairs
WHERE case_id = $1 AND status = 'final_received'
ORDER BY created_at DESC LIMIT 1""",
case_id,
)
if pair:
pair_id = pair["id"]
draft_text = pair["draft_text"] or ""
if not draft_text:
rows = await conn.fetch( rows = await conn.fetch(
"""SELECT content FROM decision_blocks """SELECT content FROM decision_blocks
WHERE decision_id = $1 AND word_count > 0 WHERE decision_id = $1 AND word_count > 0
@@ -128,28 +140,26 @@ async def process_final_version(
if not draft_text: if not draft_text:
raise ValueError("No draft content to compare") raise ValueError("No draft content to compare")
# Compute stats # Compute stats (pure) + AI distillation (style/method vs substance)
diff_stats = compute_diff_stats(draft_text, final_text) diff_stats = compute_diff_stats(draft_text, final_text)
# Analyze changes with AI
analysis = await analyze_changes(draft_text, final_text) analysis = await analyze_changes(draft_text, final_text)
# Store new expressions as style patterns # INV-LRN1: do NOT auto-commit learnings into writer-consumed channels.
for expr in analysis.get("new_expressions", []): # The distillation is a PROPOSAL stored on the pair; the chair/curator approves
if expr and len(expr) > 3: # it (→ decision_lessons / appeal_type_rules, surfaced by T15) via the gate.
await db.upsert_style_pattern( # (Previously this auto-upserted every new_expression as a style_pattern
pattern_type="characteristic_phrase", # that both bypassed the gate and contaminated style with substance. Removed.)
pattern_text=expr, if pair_id is not None:
context="למד מגרסה סופית", await db.update_draft_final_pair(
UUID(str(pair_id)),
final_text=final_text,
diff_stats=diff_stats,
analysis=analysis,
status="analyzed",
) )
# Update decision status # Update decision + case status
await db.update_decision( await db.update_decision(UUID(decision["id"]), status="final")
UUID(decision["id"]),
status="final",
)
# Update case status
case = await db.get_case(case_id) case = await db.get_case(case_id)
if case: if case:
await db.update_case(case_id, status="final") await db.update_case(case_id, status="final")
@@ -157,6 +167,7 @@ async def process_final_version(
return { return {
"diff_stats": diff_stats, "diff_stats": diff_stats,
"analysis": analysis, "analysis": analysis,
"pair_id": str(pair_id) if pair_id else None,
"lessons_count": len(analysis.get("changes", [])), "lessons_count": len(analysis.get("changes", [])),
"new_expressions": len(analysis.get("new_expressions", [])), "new_expressions": len(analysis.get("new_expressions", [])),
} }

View File

@@ -7,8 +7,32 @@ Based on analysis of: Hecht 1180-1181 (rejection) and Beit HaKerem 1126/25+1141/
from __future__ import annotations from __future__ import annotations
# ── Valid outcome values ──────────────────────────────────────────── # ── Valid outcome values ────────────────────────────────────────────
# GAP-51 / INV-TOOL2: canonical = 3 real outcomes. `betterment_levy` is a
# practice_area (not an outcome) — its writing-guidance lives in
# PRACTICE_AREA_OVERRIDES below and is applied on top of the chosen outcome.
VALID_OUTCOMES = ("rejection", "partial_acceptance", "full_acceptance", "betterment_levy") VALID_OUTCOMES = ("rejection", "partial_acceptance", "full_acceptance")
# Hebrew display labels — SSoT (אנגלית ב-DB, עברית ב-UI). Replaces the inline
# maps that lived in block_writer.py and workflow.py.
OUTCOME_LABELS_HE = {
"rejection": "דחייה",
"partial_acceptance": "קבלה חלקית",
"full_acceptance": "קבלה מלאה",
}
# Backward-compat: legacy set_outcome vocabulary → canonical. Used by callers
# that may still pass the old values (rejected/accepted/partial).
LEGACY_OUTCOME_MAP = {
"rejected": "rejection",
"accepted": "full_acceptance",
"partial": "partial_acceptance",
}
def canonical_outcome(outcome: str) -> str:
"""Normalize any outcome string to the canonical vocabulary (GAP-51)."""
return LEGACY_OUTCOME_MAP.get(outcome, outcome)
# ── Golden Ratios (section % of total) ───────────────────────────── # ── Golden Ratios (section % of total) ─────────────────────────────
@@ -16,9 +40,25 @@ GOLDEN_RATIOS: dict[str, dict[str, tuple[int, int]]] = {
"rejection": {"background": (15, 25), "claims": (30, 40), "discussion": (37, 50), "summary": (2, 9)}, "rejection": {"background": (15, 25), "claims": (30, 40), "discussion": (37, 50), "summary": (2, 9)},
"full_acceptance": {"background": (30, 40), "claims": (20, 30), "discussion": (35, 45), "summary": (3, 5)}, "full_acceptance": {"background": (30, 40), "claims": (20, 30), "discussion": (35, 45), "summary": (3, 5)},
"partial_acceptance": {"background": (25, 35), "claims": (25, 30), "discussion": (40, 47), "summary": (2, 3)}, "partial_acceptance": {"background": (25, 35), "claims": (25, 30), "discussion": (40, 47), "summary": (2, 3)},
"betterment_levy": {"background": (6, 18), "claims": (13, 25), "discussion": (32, 48), "summary": (3, 4)},
} }
# ── Anti-patterns (what Dafna avoids) — detectable signals for style-distance (T7) ──
# Derived from daphna-voice-fingerprint.md §3 (corrected 2026-06-06). NOTE: a leading
# "N." per paragraph is NOT an anti-pattern — it is the REQUIRED signal the DOCX
# exporter converts to real Word auto-numbering (docx_exporter._ensure_decision_numbering).
# The real anti-patterns are mid-paragraph mini-lists, markdown, and bullets.
ANTI_PATTERNS: list[dict] = [
{"name": "inline_numbered_fragments",
"regex": r"\([0-9]\)[^\n]{0,200}\([0-9]\)",
"note": "פיצול טיעון לרשימת-מיני (1)...(2) בתוך פסקת-אנליזה"},
{"name": "markdown_headers",
"regex": r"(?m)^#{1,6}\s",
"note": "כותרות markdown — אינן בהחלטה הסופית"},
{"name": "bullet_lists",
"regex": r"(?m)^\s*[-*•]\s",
"note": "רשימות תבליטים באנליזה — דפנה כותבת נרטיב רציף"},
]
# ── Paragraph length guidance (word counts) ──────────────────────── # ── Paragraph length guidance (word counts) ────────────────────────
PARAGRAPH_LENGTHS = { PARAGRAPH_LENGTHS = {
@@ -71,16 +111,6 @@ OPENING_STRATEGIES = {
"ואז 'כל הנקודות לעיל עומדות לפנינו...' → מעבר לניתוח" "ואז 'כל הנקודות לעיל עומדות לפנינו...' → מעבר לניתוח"
), ),
}, },
"betterment_levy": {
"style": "direct_factual",
"paragraphs": (1, 3),
"description": (
"פתיחה ישירה ועובדתית: 'בפנינו ערר על דרישת תשלום היטל השבחה מיום [תאריך] "
"בסך של [סכום] ₪' → רקע קצר (נכס, תכנית משביחה, מימוש) → "
"תמצית טענות הצדדים (עוררים + משיבה בנפרד). "
"אין הקשר תכנוני רחב. הפתיחה = עובדות בלבד."
),
},
} }
# ── Summary strategies by outcome ────────────────────────────────── # ── Summary strategies by outcome ──────────────────────────────────
@@ -105,18 +135,6 @@ SUMMARY_STRATEGIES = {
"כל ההנמקה כבר בדיון — הסיכום = רק מה מתקבל, מה נדחה, ותנאים" "כל ההנמקה כבר בדיון — הסיכום = רק מה מתקבל, מה נדחה, ותנאים"
), ),
}, },
"betterment_levy": {
"heading": "various",
"format": "dry_operative",
"description": (
"סיום יבש ואופרטיבי. כותרת משתנה: 'סוף דבר' / 'לאור כל האמור לעיל' / ללא כותרת. "
"תוכן: 'הערר נדחה/מתקבל' + הוצאות ('כל צד ישא בהוצאותיו' / חיוב בסכום). "
"אם מתקבל: הוראות אופרטיביות (החזר, שומה מתוקנת, תנאים). "
"חתימה: 'ניתנה פה אחד היום, [תאריך עברי], [תאריך לועזי].' "
"לעיתים: 'התיק ייסגר.' / 'עומדת זכות ערר כדין.' "
"אין פסקה חמה. אין חזרה על נימוקים."
),
},
} }
# ── Discussion structure rules ───────────────────────────────────── # ── Discussion structure rules ─────────────────────────────────────
@@ -140,14 +158,6 @@ DISCUSSION_RULES: dict[str, list[str]] = {
"full_acceptance": [ "full_acceptance": [
"מבנה ישיר: נקודות עיקריות → ניתוח → מסקנה.", "מבנה ישיר: נקודות עיקריות → ניתוח → מסקנה.",
], ],
"betterment_levy": [
"פתיחת דיון: מסקנה מוקדמת ('לאחר שבחנו... מצאנו כי דין הערר להידחות/להתקבל').",
"תקן ביקורת: ציון רף ההתערבות בשומה מכרעת (בר\"ם 3644/13 גלר) — אבחנה בין שמאי למשפטי.",
"הצגת הלכה פסוקה: ציטוט ארוך מפס\"ד מרכזי → 'ברוח הדברים לעיל נבחן את טענות הצדדים'.",
"טיפול שיטתי: כל טענה/סוגיה בנפרד → ניתוח → מסקנת ביניים.",
"ביטויים: 'אין בידינו לקבל', 'לא מצאנו מקום להתערב', 'קביעה נכונה שאין מקום להתערב בה'.",
"'על מנת לא לצאת בחסר' — לנקודות obiter dicta בסוף הדיון.",
],
} }
# ── Citation technique ───────────────────────────────────────────── # ── Citation technique ─────────────────────────────────────────────
@@ -270,8 +280,49 @@ DECISION_TEMPLATES: dict[str, str] = {
ניתנה היום, {date} ניתנה היום, {date}
דפנה תמיר, יו"ר ועדת הערר דפנה תמיר, יו"ר ועדת הערר
""", """,
}
"betterment_levy": _HEADER + """## א. רקע עובדתי
# ── Practice-area writing overrides (GAP-51) ───────────────────────
# `betterment_levy` is a practice_area, NOT an outcome. A betterment-levy case
# still has a real outcome (rejection / partial / full), but its writing style
# is distinct (dry, factual, no warm closing). These overrides are layered on
# top of the chosen outcome's guidance by the accessors below.
PRACTICE_AREA_OVERRIDES: dict[str, dict] = {
"betterment_levy": {
"golden_ratios": {"background": (6, 18), "claims": (13, 25), "discussion": (32, 48), "summary": (3, 4)},
"opening_strategy": {
"style": "direct_factual",
"paragraphs": (1, 3),
"description": (
"פתיחה ישירה ועובדתית: 'בפנינו ערר על דרישת תשלום היטל השבחה מיום [תאריך] "
"בסך של [סכום] ₪' → רקע קצר (נכס, תכנית משביחה, מימוש) → "
"תמצית טענות הצדדים (עוררים + משיבה בנפרד). "
"אין הקשר תכנוני רחב. הפתיחה = עובדות בלבד."
),
},
"summary_strategy": {
"heading": "various",
"format": "dry_operative",
"description": (
"סיום יבש ואופרטיבי. כותרת משתנה: 'סוף דבר' / 'לאור כל האמור לעיל' / ללא כותרת. "
"תוכן: 'הערר נדחה/מתקבל' + הוצאות ('כל צד ישא בהוצאותיו' / חיוב בסכום). "
"אם מתקבל: הוראות אופרטיביות (החזר, שומה מתוקנת, תנאים). "
"חתימה: 'ניתנה פה אחד היום, [תאריך עברי], [תאריך לועזי].' "
"לעיתים: 'התיק ייסגר.' / 'עומדת זכות ערר כדין.' "
"אין פסקה חמה. אין חזרה על נימוקים."
),
},
"discussion_rules": [
"פתיחת דיון: מסקנה מוקדמת ('לאחר שבחנו... מצאנו כי דין הערר להידחות/להתקבל').",
"תקן ביקורת: ציון רף ההתערבות בשומה מכרעת (בר\"ם 3644/13 גלר) — אבחנה בין שמאי למשפטי.",
"הצגת הלכה פסוקה: ציטוט ארוך מפס\"ד מרכזי → 'ברוח הדברים לעיל נבחן את טענות הצדדים'.",
"טיפול שיטתי: כל טענה/סוגיה בנפרד → ניתוח → מסקנת ביניים.",
"ביטויים: 'אין בידינו לקבל', 'לא מצאנו מקום להתערב', 'קביעה נכונה שאין מקום להתערב בה'.",
"'על מנת לא לצאת בחסר' — לנקודות obiter dicta בסוף הדיון.",
],
"decision_template": _HEADER + """## א. רקע עובדתי
<!-- {ratios_background} --> <!-- {ratios_background} -->
[תיאור הרקע העובדתי של הערר] [תיאור הרקע העובדתי של הערר]
@@ -301,18 +352,31 @@ DECISION_TEMPLATES: dict[str, str] = {
ניתנה היום, {date} ניתנה היום, {date}
דפנה תמיר, יו"ר ועדת הערר דפנה תמיר, יו"ר ועדת הערר
""", """,
},
} }
# ── Helper function ──────────────────────────────────────────────── # ── Helper function ────────────────────────────────────────────────
def get_lessons_for_outcome(outcome: str) -> dict: def get_lessons_for_outcome(outcome: str, practice_area: str = "") -> dict:
"""Assemble all relevant lessons for a given expected outcome.""" """Assemble all relevant lessons for an outcome, with practice_area overrides.
GAP-51: ``betterment_levy`` is a practice_area — when given, its writing
overrides (golden ratios, opening/summary strategy, discussion rules) are
layered on top of the chosen outcome.
"""
outcome = canonical_outcome(outcome)
if outcome not in VALID_OUTCOMES: if outcome not in VALID_OUTCOMES:
return {"error": f"outcome must be one of: {', '.join(VALID_OUTCOMES)}"} return {"error": f"outcome must be one of: {', '.join(VALID_OUTCOMES)}"}
ratios = GOLDEN_RATIOS[outcome] override = PRACTICE_AREA_OVERRIDES.get(practice_area, {})
rules = DISCUSSION_RULES.get("universal", []) + DISCUSSION_RULES.get(outcome, []) ratios = override.get("golden_ratios") or GOLDEN_RATIOS[outcome]
opening = override.get("opening_strategy") or OPENING_STRATEGIES[outcome]
summary = override.get("summary_strategy") or SUMMARY_STRATEGIES[outcome]
rules = (
DISCUSSION_RULES.get("universal", [])
+ (override.get("discussion_rules") or DISCUSSION_RULES.get(outcome, []))
)
# Filter transition phrases: universal + outcome-specific # Filter transition phrases: universal + outcome-specific
phrases = [ phrases = [
@@ -322,11 +386,12 @@ def get_lessons_for_outcome(outcome: str) -> dict:
return { return {
"outcome": outcome, "outcome": outcome,
"practice_area": practice_area,
"golden_ratios": { "golden_ratios": {
k: f"{v[0]}-{v[1]}%" for k, v in ratios.items() k: f"{v[0]}-{v[1]}%" for k, v in ratios.items()
}, },
"opening_strategy": OPENING_STRATEGIES[outcome], "opening_strategy": opening,
"summary_strategy": SUMMARY_STRATEGIES[outcome], "summary_strategy": summary,
"discussion_rules": rules, "discussion_rules": rules,
"citation_guidance": CITATION_GUIDANCE, "citation_guidance": CITATION_GUIDANCE,
"transition_phrases": [ "transition_phrases": [
@@ -339,9 +404,11 @@ def get_lessons_for_outcome(outcome: str) -> dict:
} }
def format_ratios_comment(outcome: str, section: str) -> str: def format_ratios_comment(outcome: str, section: str, practice_area: str = "") -> str:
"""Format golden ratio as an HTML comment for templates.""" """Format golden ratio as an HTML comment for templates (practice_area-aware)."""
ratios = GOLDEN_RATIOS.get(outcome, {}) outcome = canonical_outcome(outcome)
override = PRACTICE_AREA_OVERRIDES.get(practice_area, {})
ratios = override.get("golden_ratios") or GOLDEN_RATIOS.get(outcome, {})
if section in ratios: if section in ratios:
lo, hi = ratios[section] lo, hi = ratios[section]
return f"יעד: {lo}-{hi}% מסך ההחלטה" return f"יעד: {lo}-{hi}% מסך ההחלטה"

View File

@@ -103,6 +103,51 @@ async def get_case_metrics(case_id: UUID) -> dict:
return metrics return metrics
async def halacha_backlog(conn) -> dict:
"""תור אישור-ההלכות (GAP-14 / INV-QA1 / G10) — נראות ה-backlog האנושי.
הלכות נכנסות כ-`pending_review` ובלתי-נראות לחיפוש עד אישור היו"ר; בלי ספירה
גלויה, אישור-חסר נשאר סמוי (10/19 התגלה במקרה). מקבל connection פתוח כדי
שאפשר יהיה לשלב בסנאפ-שוט קיים (get_dashboard, /api/system/diagnostics).
"""
rows = await conn.fetch(
"SELECT review_status, COUNT(*) AS n FROM halachot GROUP BY review_status"
)
counts = {r["review_status"]: r["n"] for r in rows}
oldest = await conn.fetchval(
"SELECT MIN(created_at) FROM halachot WHERE review_status = 'pending_review'"
)
# #84.7 — split the pending bucket: how many are genuine candidates (clean)
# vs flagged 'needs extraction fix', and the breakdown by flag, so the chair
# sees how much of the backlog is real review vs extraction noise.
pending_clean = await conn.fetchval(
"SELECT COUNT(*) FROM halachot WHERE review_status = 'pending_review' "
"AND COALESCE(array_length(quality_flags, 1), 0) = 0"
)
flag_rows = await conn.fetch(
"SELECT flag, COUNT(*) AS n FROM ("
" SELECT unnest(quality_flags) AS flag FROM halachot "
" WHERE review_status = 'pending_review'"
") t GROUP BY flag ORDER BY n DESC"
)
pending_total = counts.get("pending_review", 0)
reviewed = counts.get("approved", 0) + counts.get("rejected", 0) + counts.get("published", 0)
return {
"pending_review": pending_total,
"pending_clean": pending_clean, # real review candidates (#84.1)
"pending_flagged": pending_total - pending_clean, # needs-fix bucket
"approved": counts.get("approved", 0),
"rejected": counts.get("rejected", 0),
"deferred": counts.get("deferred", 0),
"published": counts.get("published", 0),
"total": sum(counts.values()),
"reviewed_total": reviewed,
"approve_ratio": round(counts.get("approved", 0) / reviewed, 3) if reviewed else None,
"pending_by_flag": {r["flag"]: r["n"] for r in flag_rows},
"oldest_pending_at": oldest.isoformat() if oldest else None,
}
async def get_dashboard() -> dict: async def get_dashboard() -> dict:
"""דשבורד כולל — סיכום מדדים על כל התיקים.""" """דשבורד כולל — סיכום מדדים על כל התיקים."""
pool = await db.get_pool() pool = await db.get_pool()
@@ -123,6 +168,15 @@ async def get_dashboard() -> dict:
total_corpus = await conn.fetchval("SELECT COUNT(*) FROM style_corpus") total_corpus = await conn.fetchval("SELECT COUNT(*) FROM style_corpus")
total_patterns = await conn.fetchval("SELECT COUNT(*) FROM style_patterns") total_patterns = await conn.fetchval("SELECT COUNT(*) FROM style_patterns")
total_case_law = await conn.fetchval("SELECT COUNT(*) FROM case_law") total_case_law = await conn.fetchval("SELECT COUNT(*) FROM case_law")
non_searchable_case_law = await conn.fetchval(
"SELECT COUNT(*) FROM case_law WHERE NOT searchable"
)
cases_with_stale_blocks = await conn.fetchval(
"SELECT COUNT(*) FROM cases WHERE blocks_stale"
)
stale_embedding_case_law = await conn.fetchval(
"SELECT COUNT(*) FROM case_law "
"WHERE coalesce(full_text,'') <> '' AND content_hash IS DISTINCT FROM indexed_hash")
# QA summary # QA summary
qa_total = await conn.fetchval("SELECT COUNT(DISTINCT case_id) FROM qa_results") qa_total = await conn.fetchval("SELECT COUNT(DISTINCT case_id) FROM qa_results")
@@ -143,6 +197,9 @@ async def get_dashboard() -> dict:
"SELECT AVG(total_words) FROM decisions WHERE total_words > 0" "SELECT AVG(total_words) FROM decisions WHERE total_words > 0"
) )
# Halacha review backlog (GAP-14 / INV-QA1 / G10)
backlog = await halacha_backlog(conn)
return { return {
"summary": { "summary": {
"total_cases": total_cases, "total_cases": total_cases,
@@ -154,8 +211,12 @@ async def get_dashboard() -> dict:
"style_corpus": total_corpus, "style_corpus": total_corpus,
"style_patterns": total_patterns, "style_patterns": total_patterns,
"case_law_entries": total_case_law, "case_law_entries": total_case_law,
"non_searchable_case_law": non_searchable_case_law,
"cases_with_stale_blocks": cases_with_stale_blocks,
"stale_embedding_case_law": stale_embedding_case_law,
}, },
"cases_by_status": cases_by_status, "cases_by_status": cases_by_status,
"halacha_backlog": backlog,
"qa": { "qa": {
"cases_validated": qa_total, "cases_validated": qa_total,
"cases_passed": qa_passed, "cases_passed": qa_passed,

View File

@@ -15,15 +15,12 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import re
import shutil
from datetime import date
from pathlib import Path from pathlib import Path
from typing import Awaitable, Callable from typing import Awaitable, Callable
from uuid import UUID, uuid4 from uuid import UUID
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import chunker, db, embeddings, extractor, hybrid_search, rerank # noqa: F401 from legal_mcp.services import db, embeddings, hybrid_search, ingest # noqa: F401
# Note: halacha_extractor and precedent_metadata_extractor are NOT imported # Note: halacha_extractor and precedent_metadata_extractor are NOT imported
# at module load. They are imported lazily inside the dedicated re-extract # at module load. They are imported lazily inside the dedicated re-extract
@@ -40,8 +37,8 @@ ProgressCb = Callable[[str, int, str], Awaitable[None]]
PRECEDENT_LIBRARY_DIR = Path(config.DATA_DIR) / "precedent-library" PRECEDENT_LIBRARY_DIR = Path(config.DATA_DIR) / "precedent-library"
_VALID_PRACTICE_AREAS = {"", "rishuy_uvniya", "betterment_levy", "compensation_197"} _VALID_PRACTICE_AREAS = frozenset({"", "rishuy_uvniya", "betterment_levy", "compensation_197"})
_VALID_SOURCE_TYPES = {"", "court_ruling", "appeals_committee"} _VALID_SOURCE_TYPES = frozenset({"", "court_ruling", "appeals_committee"})
_VALID_PRECEDENT_LEVELS = { _VALID_PRECEDENT_LEVELS = {
"", "עליון", "מנהלי", "ועדת_ערר_ארצית", "ועדת_ערר_מחוזית", "", "עליון", "מנהלי", "ועדת_ערר_ארצית", "ועדת_ערר_מחוזית",
"supreme", "administrative", "national_appeals_committee", "district_appeals_committee", "supreme", "administrative", "national_appeals_committee", "district_appeals_committee",
@@ -52,37 +49,54 @@ async def _noop_progress(_status: str, _percent: int, _msg: str) -> None:
return None return None
def _safe_filename(name: str) -> str: def _external_validate(inputs: dict) -> None:
"""Strip path separators and unsafe chars from a user-provided name.""" citation = (inputs.get("citation") or "").strip()
base = Path(name).name if not citation:
return re.sub(r"[^\w.\-+א-ת ]", "_", base) or f"upload-{uuid4().hex[:8]}" raise ValueError("citation is required")
if citation.startswith(("ערר ", "ערר(", 'בל"מ ', 'בל"מ(', "ARAR ")):
raise ValueError(
"ציטוט שמתחיל ב-'ערר' או 'בל\"מ' הוא החלטת ועדת ערר. "
"השתמש ב-internal_decision_upload (דורש chair_name + district), "
"לא ב-precedent_library_upload."
)
def _stage_file(src_path: Path, source_type: str) -> Path: def _external_staging_subdir(inputs: dict) -> str:
"""Copy the uploaded file into data/precedent-library/<source_type>/. st = inputs.get("source_type") or ""
return st if st in {"court_ruling", "appeals_committee"} else "other"
Returns the destination path. Source file is not deleted (caller decides).
"""
sub = source_type if source_type in {"court_ruling", "appeals_committee"} else "other"
dest_dir = PRECEDENT_LIBRARY_DIR / sub
dest_dir.mkdir(parents=True, exist_ok=True)
safe_name = _safe_filename(src_path.name)
dest = dest_dir / f"{uuid4().hex[:8]}_{safe_name}"
shutil.copy2(src_path, dest)
return dest
def _coerce_date(value) -> date | None: async def _create_external_record(**kw) -> dict:
if value is None or value == "": """Adapter: maps canonical inputs (citation) to create_external_case_law(case_number)."""
return None return await db.create_external_case_law(
if isinstance(value, date): case_number=kw["citation"].strip(),
return value case_name=kw["case_name"],
if isinstance(value, str): full_text=kw["full_text"],
try: court=(kw.get("court") or "").strip(),
return date.fromisoformat(value[:10]) decision_date=kw.get("decision_date"),
except ValueError: practice_area=kw.get("practice_area", ""),
return None appeal_subtype=(kw.get("appeal_subtype") or "").strip(),
return None subject_tags=list(kw.get("subject_tags") or []),
summary=(kw.get("summary") or "").strip(),
headnote=(kw.get("headnote") or "").strip(),
source_type=kw.get("source_type", ""),
precedent_level=kw.get("precedent_level", ""),
is_binding=kw.get("is_binding", True),
document_id=kw.get("document_id"),
)
_EXTERNAL_SPEC = ingest.IntakeSpec(
source_kind="external_upload",
id_field="citation",
staging_root=PRECEDENT_LIBRARY_DIR,
staging_subdir=_external_staging_subdir,
validate=_external_validate,
enum_fields={"practice_area": _VALID_PRACTICE_AREAS, "source_type": _VALID_SOURCE_TYPES},
derive=lambda inputs: {},
display_name_fallback="citation",
create_record=_create_external_record,
)
async def ingest_precedent( async def ingest_precedent(
@@ -101,220 +115,20 @@ async def ingest_precedent(
headnote: str = "", headnote: str = "",
summary: str = "", summary: str = "",
document_id: UUID | None = None, document_id: UUID | None = None,
progress: ProgressCb | None = None, progress: ingest.ProgressCb | None = None,
) -> dict: ) -> dict:
"""Ingest a single uploaded precedent through the full pipeline. """Ingest one external precedent. Thin wrapper over the canonical pipeline."""
inputs = {
Required: file_path + citation. Everything else has a sensible default. "citation": citation, "case_name": case_name, "court": court,
"decision_date": decision_date, "source_type": source_type,
Returns: "precedent_level": precedent_level, "practice_area": practice_area,
``{"status": "...", "case_law_id": "...", "chunks": N, "halachot": M}`` "appeal_subtype": appeal_subtype, "subject_tags": subject_tags,
""" "is_binding": is_binding, "headnote": headnote, "summary": summary,
progress = progress or _noop_progress
src = Path(file_path)
if not src.is_file():
raise FileNotFoundError(f"file not found: {src}")
if not citation.strip():
raise ValueError("citation is required")
# Citation guard at service level (catches both MCP and HTTP API paths).
# Appeals-committee decisions must go through ingest_internal_decision
# which records chair_name+district. The MCP wrapper has the same guard
# for an earlier, friendlier error message — but this is the source of
# truth. See TaskMaster #30(ב) and DB constraint case_law_external_arar_check.
_norm = citation.strip()
if _norm.startswith(("ערר ", "ערר(", "בל\"מ ", "בל\"מ(", "ARAR ")):
raise ValueError(
"ציטוט שמתחיל ב-'ערר' או 'בל\"מ' הוא החלטת ועדת ערר. "
"השתמש ב-internal_decision_upload (דורש chair_name + district), "
"לא ב-precedent_library_upload."
)
if practice_area not in _VALID_PRACTICE_AREAS:
raise ValueError(f"invalid practice_area: {practice_area!r}")
if source_type not in _VALID_SOURCE_TYPES:
raise ValueError(f"invalid source_type: {source_type!r}")
await progress("staging", 5, "מעתיק את הקובץ לאחסון")
staged = _stage_file(src, source_type)
await progress("extracting", 15, "מחלץ טקסט מהקובץ")
try:
text, page_count, page_offsets = await extractor.extract_text(str(staged))
except Exception as e:
await progress("failed", 100, f"כשל בחילוץ טקסט: {e}")
raise
text = (text or "").strip()
if not text:
await progress("failed", 100, "לא נמצא טקסט בקובץ")
raise ValueError("no extractable text in file")
# Strip any Nevo preamble that might wrap court rulings downloaded from Nevo.
text = extractor.strip_nevo_preamble(text)
await progress("storing_metadata", 25, "שומר את הפסיקה במסד הנתונים")
record = await db.create_external_case_law(
case_number=citation.strip(),
case_name=case_name.strip() or citation.strip(),
full_text=text,
court=court.strip(),
decision_date=_coerce_date(decision_date),
practice_area=practice_area,
appeal_subtype=appeal_subtype.strip(),
subject_tags=list(subject_tags or []),
summary=summary.strip(),
headnote=headnote.strip(),
source_type=source_type,
precedent_level=precedent_level,
is_binding=is_binding,
document_id=document_id,
)
case_law_id = UUID(str(record["id"]))
try:
# Parent-doc retrieval (TaskMaster #48): when enabled, emit
# two tiers (parents + children). Only children are embedded
# and indexed; parents carry retrieval context. When disabled,
# fall back to legacy single-tier chunking — identical
# behaviour to pre-V17.
if config.PARENT_DOC_RETRIEVAL_ENABLED:
await progress(
"chunking", 40,
f"מחלק את הטקסט ל-chunks היררכיים ({page_count} עמ')",
)
h_chunks = chunker.chunk_document_hierarchical(
text, page_offsets=page_offsets,
)
if not h_chunks:
await db.set_case_law_extraction_status(case_law_id, "completed")
await db.set_case_law_halacha_status(case_law_id, "completed")
await progress("completed", 100, "אין טקסט לעיבוד")
return {
"status": "completed",
"case_law_id": str(case_law_id),
"chunks": 0,
"halachot": 0,
} }
return await ingest.ingest_document(
children = [c for c in h_chunks if c.role == "child"] _EXTERNAL_SPEC, inputs=inputs, file_path=file_path,
parents = [c for c in h_chunks if c.role == "parent"] document_id=document_id, progress=progress,
await progress(
"embedding", 55,
f"מייצר embeddings ל-{len(children)} children "
f"({len(parents)} parents)",
) )
child_texts = [c.content for c in children]
child_vectors = await embeddings.embed_texts(
child_texts, input_type="document",
)
# Build flat dict list for the two-pass writer.
chunk_dicts: list[dict] = []
for p in parents:
chunk_dicts.append({
"role": "parent",
"local_id": p.local_id,
"parent_local_id": None,
"chunk_index": p.chunk_index,
"content": p.content,
"section_type": p.section_type,
"page_number": p.page_number,
"embedding": None,
})
for c, v in zip(children, child_vectors):
chunk_dicts.append({
"role": "child",
"local_id": c.local_id,
"parent_local_id": c.parent_local_id,
"chunk_index": c.chunk_index,
"content": c.content,
"section_type": c.section_type,
"page_number": c.page_number,
"embedding": v,
})
counts = await db.store_precedent_chunks_hierarchical(
case_law_id, chunk_dicts,
)
stored_chunks = counts["children"]
else:
await progress(
"chunking", 40, f"מחלק את הטקסט ל-chunks ({page_count} עמ')",
)
chunks = chunker.chunk_document(text, page_offsets=page_offsets)
if not chunks:
await db.set_case_law_extraction_status(case_law_id, "completed")
await db.set_case_law_halacha_status(case_law_id, "completed")
await progress("completed", 100, "אין טקסט לעיבוד")
return {
"status": "completed",
"case_law_id": str(case_law_id),
"chunks": 0,
"halachot": 0,
}
await progress("embedding", 55, f"מייצר embeddings ל-{len(chunks)} chunks")
chunk_texts = [c.content for c in chunks]
chunk_vectors = await embeddings.embed_texts(chunk_texts, input_type="document")
chunk_dicts = [
{
"chunk_index": c.chunk_index,
"content": c.content,
"section_type": c.section_type,
"page_number": c.page_number,
"embedding": v,
}
for c, v in zip(chunks, chunk_vectors)
]
stored_chunks = await db.store_precedent_chunks(case_law_id, chunk_dicts)
# Multimodal page-image embeddings (V9). Gated by feature flag.
# Non-fatal: text path already succeeded. Only PDFs.
if config.MULTIMODAL_ENABLED and page_count > 0 and staged.suffix.lower() == ".pdf":
try:
await progress(
"embedding_images", 70,
f"מטמיע {page_count} עמודי תמונה (multimodal)",
)
await _embed_precedent_pages(case_law_id, staged, page_count)
except Exception as e:
logger.warning("Precedent multimodal embedding failed (non-fatal): %s", e)
# Pipeline split: the container does the non-LLM half (extract +
# chunk + embed + store). LLM-driven extraction (metadata, halachot)
# runs separately via the MCP tool `precedent_process_pending` from
# local Claude Code, where `claude` CLI is available.
#
# We auto-queue both extractions so the chair doesn't need to click
# any button — the moment they (or me) run `precedent_process_pending`
# in chat, both kinds get processed.
await db.set_case_law_extraction_status(case_law_id, "completed")
await db.set_case_law_halacha_status(case_law_id, "pending")
await db.request_metadata_extraction(case_law_id)
await db.request_halacha_extraction(case_law_id)
await progress(
"completed",
100,
f"הוכנס לספרייה: {stored_chunks} chunks. "
f"חילוץ הלכות ומטא-דאטה ממתינים בתור — "
f"להפעיל מ-Claude Code: precedent_process_pending.",
)
return {
"status": "completed",
"case_law_id": str(case_law_id),
"chunks": stored_chunks,
"halachot": 0,
"halachot_pending": True,
"metadata_filled": [],
"pages": page_count,
}
except Exception as e:
logger.exception("precedent_library.ingest_precedent failed: %s", e)
await db.set_case_law_extraction_status(case_law_id, "failed")
await progress("failed", 100, f"כשל בעיבוד: {e}")
raise
async def reextract_halachot( async def reextract_halachot(
@@ -342,7 +156,10 @@ async def reextract_halachot(
# bad data. See note in db.request_metadata_extraction. # bad data. See note in db.request_metadata_extraction.
await progress("extracting_halachot", 50, "מחלץ הלכות מחדש") await progress("extracting_halachot", 50, "מחלץ הלכות מחדש")
result = await halacha_extractor.extract(case_law_id) # Explicit re-extraction = clean slate (force): wipe prior halachot +
# per-chunk checkpoints and redo all. (Queue draining / resume uses the
# default force=False so an interrupted run continues where it stopped.)
result = await halacha_extractor.extract(case_law_id, force=True)
# Clear the queue timestamp on completion so the UI badge / worker queue # Clear the queue timestamp on completion so the UI badge / worker queue
# don't keep showing this row. The queue worker (process_pending_extractions) # don't keep showing this row. The queue worker (process_pending_extractions)
# already does this; mirror it here so per-record extraction drains too. # already does this; mirror it here so per-record extraction drains too.
@@ -402,7 +219,12 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) -
async def _run_once(cid: UUID) -> dict: async def _run_once(cid: UUID) -> dict:
if kind == "metadata": if kind == "metadata":
return await precedent_metadata_extractor.extract_and_apply(cid) return await precedent_metadata_extractor.extract_and_apply(cid)
return await halacha_extractor.extract(cid) # Bulk queue-drain → lighter effort (config.HALACHA_BULK_EXTRACT_EFFORT,
# default 'high') to cut wall-clock at scale. Resume (force=False) so an
# interrupted drain continues per-chunk. Single re-extract stays xhigh.
return await halacha_extractor.extract(
cid, effort=config.HALACHA_BULK_EXTRACT_EFFORT,
)
results: list[dict] = [] results: list[dict] = []
processed = 0 processed = 0
@@ -413,6 +235,12 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) -
attempts = 0 attempts = 0
result: dict = {} result: dict = {}
try: try:
# Flip to 'processing' so the UI badge shows live progress while
# this row is being worked (metadata has no per-chunk status of
# its own — this is the only signal). Halacha already sets its own
# 'processing' inside the extractor.
if kind == "metadata":
await db.set_case_law_metadata_status(cid, "processing")
result = await _run_once(cid) result = await _run_once(cid)
# Retry only on systematic extraction failure (rate-limit storm). # Retry only on systematic extraction failure (rate-limit storm).
# Don't retry on 'no_halachot' — that means Claude looked and # Don't retry on 'no_halachot' — that means Claude looked and
@@ -437,9 +265,15 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) -
# Finalise: success or terminal failure both clear the request # Finalise: success or terminal failure both clear the request
# so the queue moves on. (Use 'failed' DB state for terminal # so the queue moves on. (Use 'failed' DB state for terminal
# extraction_failed so the UI shows the warning chip.) # extraction_failed so the UI shows the warning chip.)
if kind == "halacha" and result.get("status") == "extraction_failed": if kind == "halacha":
if result.get("status") == "extraction_failed":
await db.set_case_law_halacha_status(cid, "failed") await db.set_case_law_halacha_status(cid, "failed")
await db.clear_extraction_request(cid, kind=kind) await db.clear_extraction_request(cid, kind=kind)
else:
# metadata — set terminal 'completed' status (also clears the
# request timestamp) so the UI badge settles instead of
# lingering on 'processing'.
await db.set_case_law_metadata_status(cid, "completed")
processed += 1 processed += 1
results.append({ results.append({
"case_law_id": str(cid), "case_law_id": str(cid),
@@ -451,6 +285,15 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) -
}) })
except Exception as e: except Exception as e:
logger.exception("process_pending_extractions failed for %s: %s", cid, e) logger.exception("process_pending_extractions failed for %s: %s", cid, e)
# Don't clear the request — it stays for the next run. But for
# metadata, revert the badge from 'processing' back to 'pending'
# (the timestamp is preserved) so the row shows "בתור" rather than
# a stuck "מחלץ" until the retry picks it up.
if kind == "metadata":
try:
await db.set_case_law_metadata_status(cid, "pending")
except Exception:
logger.exception("failed to revert metadata status for %s", cid)
results.append({ results.append({
"case_law_id": str(cid), "case_law_id": str(cid),
"case_number": row.get("case_number", ""), "case_number": row.get("case_number", ""),
@@ -458,7 +301,6 @@ async def process_pending_extractions(kind: str = "metadata", limit: int = 20) -
"error": str(e), "error": str(e),
"retry_attempts": attempts, "retry_attempts": attempts,
}) })
# Don't clear the request — it stays for the next run.
return { return {
"status": "completed", "status": "completed",
@@ -492,12 +334,18 @@ async def reextract_metadata(
raise ValueError("precedent not found") raise ValueError("precedent not found")
# See note in db.request_metadata_extraction — opened to all source kinds. # See note in db.request_metadata_extraction — opened to all source kinds.
# Mark 'processing' so a concurrent UI poll shows the live badge.
await db.set_case_law_metadata_status(case_law_id, "processing")
await progress("extracting_metadata", 40, "מחלץ מטא-דאטה (תקציר, תגיות)") await progress("extracting_metadata", 40, "מחלץ מטא-דאטה (תקציר, תגיות)")
result = await precedent_metadata_extractor.extract_and_apply(case_law_id) result = await precedent_metadata_extractor.extract_and_apply(case_law_id)
# Clear the queue timestamp so the UI / worker stop showing this row. # Settle to terminal 'completed' (also NULLs the queue timestamp) so the
# See note in reextract_halachot. # UI / worker stop showing this row. See note in reextract_halachot.
if result.get("status") in ("completed", "no_changes"): if result.get("status") in ("completed", "no_changes"):
await db.clear_extraction_request(case_law_id, kind="metadata") await db.set_case_law_metadata_status(case_law_id, "completed")
else:
# e.g. 'no_metadata' (no full_text) — don't leave the badge stuck on
# 'processing'; revert to 'pending' (preserves any queue timestamp).
await db.set_case_law_metadata_status(case_law_id, "pending")
fields = result.get("fields") or [] fields = result.get("fields") or []
msg = ( msg = (
f"מולאו {len(fields)} שדות: {', '.join(fields)}" f"מולאו {len(fields)} שדות: {', '.join(fields)}"
@@ -586,48 +434,3 @@ async def search_library(
subject_tag=subject_tag, subject_tag=subject_tag,
include_halachot=include_halachot, include_halachot=include_halachot,
) )
async def _embed_precedent_pages(
case_law_id: UUID,
pdf_path: Path,
page_count: int,
) -> dict:
"""Render precedent PDF pages → embed via voyage-multimodal → store.
Thumbnails go to
``data/precedent-library/thumbnails/{case_law_id}/p{N:03d}.jpg``.
"""
thumb_dir = PRECEDENT_LIBRARY_DIR / "thumbnails" / str(case_law_id)
rendered = await asyncio.to_thread(
extractor.render_pages_for_multimodal,
pdf_path,
config.MULTIMODAL_DPI,
config.MULTIMODAL_THUMB_DPI,
thumb_dir,
)
images = [pil for pil, _ in rendered]
thumbs = [t for _, t in rendered]
img_embs = await embeddings.embed_images(images)
page_records = []
for i, (emb, thumb) in enumerate(zip(img_embs, thumbs)):
rel_thumb = None
if thumb is not None:
try:
rel_thumb = str(thumb.relative_to(config.DATA_DIR))
except ValueError:
rel_thumb = str(thumb)
page_records.append({
"page_number": i + 1,
"embedding": emb,
"image_thumbnail_path": rel_thumb,
})
stored = await db.store_precedent_image_embeddings(
case_law_id, page_records, model_name=config.MULTIMODAL_MODEL,
)
logger.info(
"Multimodal: stored %d page-image embeddings for case_law %s",
stored, case_law_id,
)
return {"pages_embedded": stored}

View File

@@ -368,6 +368,8 @@ async def extract_and_apply(
if not suggested: if not suggested:
return {"status": "no_metadata", "fields": []} return {"status": "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)
if result["updated"]:
await db.recompute_searchable(case_law_id)
return { return {
"status": "completed" if result["updated"] else "no_changes", "status": "completed" if result["updated"] else "no_changes",
"fields": result["fields"], "fields": result["fields"],

View File

@@ -104,7 +104,7 @@ CLAIMS_CHECK_PROMPT = """אתה בודק איכות החלטות משפטיות.
""" """
async def check_claims_coverage(blocks: list[dict], claims: list[dict]) -> dict: async def check_claims_coverage(blocks: list[dict], claims: list[dict], outcome: str = "") -> dict:
"""בדיקה סמנטית (Claude) שכל טענה נענתה בדיון.""" """בדיקה סמנטית (Claude) שכל טענה נענתה בדיון."""
yod = next((b for b in blocks if b["block_id"] == "block-yod"), None) yod = next((b for b in blocks if b["block_id"] == "block-yod"), None)
if not yod or not yod.get("content"): if not yod or not yod.get("content"):
@@ -114,16 +114,26 @@ async def check_claims_coverage(blocks: list[dict], claims: list[dict]) -> dict:
if not claims: if not claims:
return {"name": "claims_coverage", "passed": True, "errors": [], "severity": "critical"} return {"name": "claims_coverage", "passed": True, "errors": [], "severity": "critical"}
# Filter: only APPELLANT claims from original pleadings. # #87/GAP-87 — only the appellant's claims from the APPEAL PLEADING itself
# Committee/permit_applicant claims are defensive positions, not claims # must be addressed. claim_type: 'claim'=כתב ערר (mandatory), 'response'=כתב
# that need to be "addressed" in the discussion. # תשובה, 'reply'=תגובה/השלמת-טיעון/תכתובת (supplementary correspondence — NOT
# a standalone duty to answer, especially on full acceptance). Counting reply/
# correspondence claims as "unanswered" produced false QA fails (1033-25).
source_claims = [ source_claims = [
c for c in claims c for c in claims
if c.get("source_document", "") != "block-zayin" if c.get("source_document", "") != "block-zayin"
and c.get("claim_type") == "claim"
and c.get("party_role") == "appellant"
]
if not source_claims:
# Fallback: appellant/respondent pleadings, excluding supplementary replies.
source_claims = [
c for c in claims
if c.get("source_document", "") != "block-zayin"
and c.get("claim_type") != "reply"
and c.get("party_role") in ("appellant", "respondent") and c.get("party_role") in ("appellant", "respondent")
] ]
if not source_claims: if not source_claims:
# Fallback: all non-block-zayin claims
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"]
if not source_claims: if not source_claims:
source_claims = claims source_claims = claims
@@ -165,9 +175,14 @@ async def check_claims_coverage(blocks: list[dict], claims: list[dict]) -> dict:
total = len(source_claims) total = len(source_claims)
covered = len(addressed) + len(partial) covered = len(addressed) + len(partial)
# On full acceptance the appellant prevailed in full — not every sub-claim
# needs individual treatment (the chair noted this for correspondence claims,
# 1033-25). Relax the missing-tolerance accordingly.
allowed_missing_ratio = 0.4 if outcome == "full_acceptance" else 0.2
return { return {
"name": "claims_coverage", "name": "claims_coverage",
"passed": len(missing) <= total * 0.2, # Allow up to 20% missing "passed": len(missing) <= total * allowed_missing_ratio,
"errors": errors, "errors": errors,
"severity": "critical", "severity": "critical",
"details": f"{covered}/{total} טענות נענו ({covered/total*100:.0f}%), {len(partial)} חלקית, {len(missing)} חסרות", "details": f"{covered}/{total} טענות נענו ({covered/total*100:.0f}%), {len(partial)} חלקית, {len(missing)} חסרות",
@@ -287,6 +302,50 @@ def check_sequential_numbering(blocks: list[dict]) -> dict:
} }
async def check_citation_resolution(case_id: UUID, decision_id=None) -> dict:
"""GAP-20/INV-AUD3: every cited case_law_id must resolve to the corpus.
Reads case_law_ids from the decision's write_block audit provenance and
verifies each resolves. Unresolvable → NON-BLOCKING warning + audit event.
"""
from legal_mcp.services import audit
rows = await audit.get_audit_log(case_id=case_id, action="write_block", limit=200)
ids = set()
for r in rows:
details = r.get("details") or {}
if isinstance(details, str):
try:
details = json.loads(details)
except (ValueError, TypeError):
details = {}
for raw in (details.get("sources") or {}).get("case_law_ids", []):
try:
ids.add(UUID(str(raw)))
except (ValueError, TypeError):
pass
if not ids:
return {"name": "citation_resolution", "passed": True, "errors": [], "severity": "warning"}
res = await db.resolve_citation_case_law_ids(list(ids))
if not res["unresolved"]:
return {"name": "citation_resolution", "passed": True, "errors": [], "severity": "warning"}
await audit.log_action_safe(
"citation_unresolved", case_id=case_id,
details={"unresolved": [str(x) for x in res["unresolved"]]},
)
return {
"name": "citation_resolution",
"passed": False,
"severity": "warning",
"errors": [
f"{len(res['unresolved'])} ציטוטים אינם פתירים לקורפוס — דורש אימות יו\"ר",
],
}
# ── Main validation ─────────────────────────────────────────────── # ── Main validation ───────────────────────────────────────────────
async def validate_decision(case_id: UUID) -> dict: async def validate_decision(case_id: UUID) -> dict:
@@ -317,8 +376,10 @@ async def validate_decision(case_id: UUID) -> dict:
# Get claims # Get claims
claims = await db.get_claims(case_id) claims = await db.get_claims(case_id)
# Determine appeal type # Determine appeal type + outcome (outcome relaxes claims coverage on full acceptance — #87)
appeal_type = case.get("appeal_type", "licensing") appeal_type = case.get("appeal_type", "licensing")
from legal_mcp.services.lessons import canonical_outcome
outcome = canonical_outcome(decision.get("outcome", "") or "")
# Run all checks # Run all checks
# Run sync checks # Run sync checks
@@ -326,7 +387,7 @@ async def validate_decision(case_id: UUID) -> dict:
check_neutral_background(blocks), check_neutral_background(blocks),
] ]
# Async check: claims coverage with Claude # Async check: claims coverage with Claude
results.append(await check_claims_coverage(blocks, claims)) results.append(await check_claims_coverage(blocks, claims, outcome))
# More sync checks # More sync checks
results.extend([ results.extend([
check_weight_compliance(blocks, appeal_type), check_weight_compliance(blocks, appeal_type),
@@ -334,6 +395,8 @@ async def validate_decision(case_id: UUID) -> dict:
check_no_duplication(blocks), check_no_duplication(blocks),
check_sequential_numbering(blocks), check_sequential_numbering(blocks),
]) ])
# Async, non-blocking warning: citation→corpus resolution (GAP-20/INV-AUD3)
results.append(await check_citation_resolution(case_id, decision["id"]))
critical_failures = sum(1 for r in results if not r["passed"] and r["severity"] == "critical") critical_failures = sum(1 for r in results if not r["passed"] and r["severity"] == "critical")
all_passed = all(r["passed"] for r in results) all_passed = all(r["passed"] for r in results)

View File

@@ -109,26 +109,30 @@ SYNTHESIS_PROMPT = """\
""" """
async def analyze_corpus(appeal_subtype: str = "") -> dict: async def analyze_corpus(appeal_subtype: str = "", limit: int = 0) -> dict:
"""Analyze the style corpus and extract/update patterns. """Analyze the style corpus and extract/update patterns.
Args: Args:
appeal_subtype: filter by appeal subtype (e.g. 'betterment_levy', 'building_permit'). appeal_subtype: filter by appeal subtype (e.g. 'betterment_levy', 'building_permit').
Empty string = all decisions. Empty string = all decisions.
limit: max decisions to analyze. 0 = ALL (T8 — full 48/48 coverage feeds the
author-features the writer's profile relies on; the old LIMIT 20 silently
dropped a third of Dafna's corpus).
Returns summary of patterns found. Returns summary of patterns found.
""" """
pool = await db.get_pool() pool = await db.get_pool()
lim_sql = f" LIMIT {int(limit)}" if limit and limit > 0 else ""
async with pool.acquire() as conn: async with pool.acquire() as conn:
if appeal_subtype: if appeal_subtype:
rows = await conn.fetch( rows = await conn.fetch(
"SELECT full_text, decision_number FROM style_corpus " "SELECT full_text, decision_number FROM style_corpus "
"WHERE appeal_subtype = $1 ORDER BY decision_date DESC LIMIT 20", "WHERE appeal_subtype = $1 ORDER BY decision_date DESC" + lim_sql,
appeal_subtype, appeal_subtype,
) )
else: else:
rows = await conn.fetch( rows = await conn.fetch(
"SELECT full_text, decision_number FROM style_corpus ORDER BY decision_date DESC LIMIT 20" "SELECT full_text, decision_number FROM style_corpus ORDER BY decision_date DESC" + lim_sql
) )
if not rows: if not rows:

View File

@@ -0,0 +1,182 @@
"""מדד מרחק-סגנון (T7) — האם הטיוטות מתכנסות לדפנה לאורך זמן.
שלושה רכיבים, כולם ללא LLM (דטרמיניסטי, זול):
1. golden_ratio_adherence — סטיית אחוזי-הסעיפים מ-GOLDEN_RATIOS לפי תוצאה.
2. anti_pattern_hits — ספירת אנטי-דפוסים (מ-lessons.ANTI_PATTERNS) בטקסט הטיוטה.
3. draft_to_final_diff — change_percent מ-draft_final_pairs (ככל שיורד → מתכנס).
זהו מטא-אות על בריאות-הלמידה (INV-LRN4) — נצרך ע"י לוח-מחוונים / QA, לא ע"י הכותב.
"""
from __future__ import annotations
import logging
import re
from uuid import UUID
from legal_mcp.services import db
from legal_mcp.services.lessons import ANTI_PATTERNS, GOLDEN_RATIOS, canonical_outcome
logger = logging.getLogger(__name__)
# block_id → golden-ratio section
_BLOCK_TO_SECTION = {
"block-vav": "background",
"block-zayin": "claims",
"block-yod": "discussion",
"block-yod-alef": "summary",
}
# chunker section_type → golden-ratio section (for corpus measurement, T10)
_CHUNK_SECTION_TO_GOLDEN = {
"facts": "background", "intro": "background",
"appellant_claims": "claims", "respondent_claims": "claims",
"legal_analysis": "discussion",
"conclusion": "summary", "ruling": "summary",
}
_CORPUS_RATIOS_CACHE: dict | None = None
async def measure_corpus_ratios() -> dict:
"""Measure ACTUAL section %-of-total from Dafna's style_corpus, averaged per
outcome — the empirical counterpart to lessons.GOLDEN_RATIOS (T10). Splits each
decision via chunker (accurate, not the filtered exemplars). Cached for the
process. Returns {outcome: {"n": int, "sections": {sec: pct}}}."""
global _CORPUS_RATIOS_CACHE
if _CORPUS_RATIOS_CACHE is not None:
return _CORPUS_RATIOS_CACHE
from legal_mcp.services.chunker import _split_into_sections
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT full_text, outcome FROM style_corpus WHERE full_text <> ''")
# Per-outcome AND an "_all" aggregate. style_corpus.outcome is currently
# unpopulated for the imported corpus, so per-outcome may be empty — "_all"
# is the meaningful signal today, and per-outcome becomes live once outcomes
# are backfilled. No silent loss: callers see which buckets have data via n.
by_outcome: dict[str, list[dict]] = {}
for r in rows:
sect_words: dict[str, int] = {}
for stype, stext in _split_into_sections(r["full_text"]):
g = _CHUNK_SECTION_TO_GOLDEN.get(stype)
if g:
sect_words[g] = sect_words.get(g, 0) + len(stext.split())
total = sum(sect_words.values())
if total < 100: # sections didn't parse — skip
continue
pct = {s: w / total * 100 for s, w in sect_words.items()}
by_outcome.setdefault("_all", []).append(pct)
outcome = canonical_outcome(r["outcome"] or "")
if outcome:
by_outcome.setdefault(outcome, []).append(pct)
result: dict = {}
for outcome, decs in by_outcome.items():
avg = {}
for sec in ("background", "claims", "discussion", "summary"):
vals = [d.get(sec, 0.0) for d in decs]
if vals:
avg[sec] = round(sum(vals) / len(vals), 1)
result[outcome] = {"n": len(decs), "sections": avg}
_CORPUS_RATIOS_CACHE = result
return result
def count_anti_patterns(text: str) -> dict:
"""Count each anti-pattern occurrence in text. Lower = closer to Dafna."""
hits = {}
total = 0
for ap in ANTI_PATTERNS:
n = len(re.findall(ap["regex"], text or ""))
if n:
hits[ap["name"]] = {"count": n, "note": ap["note"]}
total += n
return {"total": total, "by_pattern": hits}
def golden_ratio_adherence(block_word_counts: dict[str, int], outcome: str) -> dict:
"""% of total per section vs GOLDEN_RATIOS target range. deviation=0 ⇒ within range."""
outcome = canonical_outcome(outcome)
targets = GOLDEN_RATIOS.get(outcome)
total = sum(block_word_counts.values())
if not targets or total == 0:
return {"outcome": outcome, "total_words": total, "sections": {}, "max_deviation": None}
sections = {}
max_dev = 0.0
for block_id, section in _BLOCK_TO_SECTION.items():
if section not in targets:
continue
pct = round(block_word_counts.get(block_id, 0) / total * 100, 1)
lo, hi = targets[section]
if pct < lo:
dev = round(lo - pct, 1)
elif pct > hi:
dev = round(pct - hi, 1)
else:
dev = 0.0
max_dev = max(max_dev, dev)
sections[section] = {"actual_pct": pct, "target": [lo, hi], "deviation_pp": dev}
return {"outcome": outcome, "total_words": total, "sections": sections, "max_deviation": max_dev}
async def style_distance(case_number: str) -> dict:
"""Assemble the 3 style-distance components for one case (T7)."""
case = await db.get_case_by_number(case_number)
if not case:
return {"error": f"case {case_number} not found"}
case_id = UUID(case["id"])
decision = await db.get_decision_by_case(case_id)
outcome = (decision or {}).get("outcome", "rejection")
pool = await db.get_pool()
async with pool.acquire() as conn:
block_rows = []
draft_text = ""
if decision:
block_rows = await conn.fetch(
"SELECT block_id, content, word_count FROM decision_blocks "
"WHERE decision_id = $1 ORDER BY block_index",
UUID(decision["id"]),
)
draft_text = "\n\n".join(b["content"] for b in block_rows if b["content"])
pair = await conn.fetchrow(
"SELECT draft_text, diff_stats, status FROM draft_final_pairs "
"WHERE case_id = $1 ORDER BY created_at DESC LIMIT 1",
case_id,
)
# Prefer the immutable snapshot's draft text when present.
if pair and pair["draft_text"]:
draft_text = pair["draft_text"]
word_counts = {b["block_id"]: (b["word_count"] or 0) for b in block_rows}
ratios = golden_ratio_adherence(word_counts, outcome)
anti = count_anti_patterns(draft_text)
diff = None
if pair and pair["diff_stats"]:
raw = pair["diff_stats"]
if isinstance(raw, str):
import json
try:
raw = json.loads(raw)
except (json.JSONDecodeError, TypeError):
raw = None
diff = raw
return {
"case_number": case_number,
"outcome": canonical_outcome(outcome),
"golden_ratio_adherence": ratios,
"anti_pattern_hits": anti,
"draft_to_final_diff": diff,
"pair_status": pair["status"] if pair else None,
"summary": {
"ratio_max_deviation_pp": ratios.get("max_deviation"),
"anti_pattern_total": anti["total"],
"change_percent": (diff or {}).get("change_percent") if diff else None,
},
}

View File

@@ -14,6 +14,7 @@ import httpx
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import audit, db, extractor, git_sync, practice_area as pa from legal_mcp.services import audit, db, extractor, git_sync, practice_area as pa
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -153,6 +154,13 @@ async def case_create(
ריק = יוסק אוטומטית ממספר התיק ריק = יוסק אוטומטית ממספר התיק
proceeding_type: 'ערר' / 'בל"מ'. ריק = יוסק מ-appeal_subtype/subject. proceeding_type: 'ערר' / 'בל"מ'. ריק = יוסק מ-appeal_subtype/subject.
""" """
# INV-TOOL3 / GAP-52: idempotent on case_number (already UNIQUE in schema).
# Re-creating an existing case returns it instead of raising a unique-violation.
_existing = await db.get_case_by_number(case_number)
if _existing:
_existing["idempotent_existing"] = True
return ok(_existing)
from datetime import date as date_type from datetime import date as date_type
h_date = None h_date = None
@@ -250,7 +258,7 @@ async def case_create(
# silently producing a case with no remote. # silently producing a case with no remote.
case["gitea"] = await _setup_gitea_remote(case_number, title, case_dir) case["gitea"] = await _setup_gitea_remote(case_number, title, case_dir)
return json.dumps(case, default=str, ensure_ascii=False, indent=2) return ok(case)
async def case_list(status: str = "", limit: int = 50) -> str: async def case_list(status: str = "", limit: int = 50) -> str:
@@ -265,8 +273,8 @@ async def case_list(status: str = "", limit: int = 50) -> str:
""" """
cases = await db.list_cases(status=status or None, limit=limit) cases = await db.list_cases(status=status or None, limit=limit)
if not cases: if not cases:
return "אין תיקים." return empty("אין תיקים.")
return json.dumps(cases, default=str, ensure_ascii=False, indent=2) return ok(cases)
async def case_get(case_number: str) -> str: async def case_get(case_number: str) -> str:
@@ -277,11 +285,11 @@ async def case_get(case_number: str) -> str:
""" """
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
docs = await db.list_documents(UUID(case["id"])) docs = await db.list_documents(UUID(case["id"]))
case["documents"] = docs case["documents"] = docs
return json.dumps(case, default=str, ensure_ascii=False, indent=2) return ok(case)
async def case_update( async def case_update(
@@ -331,7 +339,7 @@ async def case_update(
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
fields = {} fields = {}
if status: if status:
@@ -388,7 +396,7 @@ async def case_update(
except Exception: except Exception:
pass # git not available — non-critical pass # git not available — non-critical
return json.dumps(updated, default=str, ensure_ascii=False, indent=2) return ok(updated)
async def case_delete(case_number: str, remove_files: bool = False) -> str: async def case_delete(case_number: str, remove_files: bool = False) -> str:
@@ -401,28 +409,25 @@ async def case_delete(case_number: str, remove_files: bool = False) -> str:
""" """
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return json.dumps( return err(f"תיק {case_number} לא נמצא.")
{"deleted": False, "reason": f"תיק {case_number} לא נמצא."},
ensure_ascii=False,
)
case_id = UUID(case["id"]) case_id = UUID(case["id"])
ok = await db.delete_case(case_id) deleted = await db.delete_case(case_id)
result = { result = {
"deleted": ok, "deleted": deleted,
"case_number": case_number, "case_number": case_number,
"case_id": str(case_id), "case_id": str(case_id),
"removed_files": False, "removed_files": False,
} }
if ok and remove_files: if deleted and remove_files:
case_dir = config.find_case_dir(case_number) case_dir = config.find_case_dir(case_number)
if case_dir.exists(): if case_dir.exists():
shutil.rmtree(case_dir, ignore_errors=True) shutil.rmtree(case_dir, ignore_errors=True)
result["removed_files"] = True result["removed_files"] = True
return json.dumps(result, ensure_ascii=False, indent=2) return ok(result)
async def case_get_final_text(case_number: str, max_chars: int = 0) -> str: async def case_get_final_text(case_number: str, max_chars: int = 0) -> str:
@@ -449,27 +454,24 @@ async def case_get_final_text(case_number: str, max_chars: int = 0) -> str:
break break
if final_path is None: if final_path is None:
return json.dumps({ return err(
"status": "not_found", "ההחלטה הסופית עדיין לא סומנה כ'סופית' ב-UI. "
"דפנה צריכה ללחוץ 'סמן כסופי' על קובץ הטיוטה הנכון.",
data={
"case_number": case_number, "case_number": case_number,
"expected_path": str(exports_dir / f"{final_stem}.docx"), "expected_path": str(exports_dir / f"{final_stem}.docx"),
"tried_extensions": [".docx", ".pdf", ".doc", ".rtf", ".txt", ".md"], "tried_extensions": [".docx", ".pdf", ".doc", ".rtf", ".txt", ".md"],
"hint": ( },
"ההחלטה הסופית עדיין לא סומנה כ'סופית' ב-UI. " )
"דפנה צריכה ללחוץ 'סמן כסופי' על קובץ הטיוטה הנכון."
),
}, ensure_ascii=False, indent=2)
try: try:
text, page_count, _ = await extractor.extract_text(str(final_path)) text, page_count, _ = await extractor.extract_text(str(final_path))
except Exception as e: except Exception as e:
logger.exception("case_get_final_text: extraction failed for %s", case_number) logger.exception("case_get_final_text: extraction failed for %s", case_number)
return json.dumps({ return err(
"status": "error", f"חילוץ הטקסט נכשל: {e}",
"case_number": case_number, data={"case_number": case_number, "file_path": str(final_path)},
"file_path": str(final_path), )
"error": str(e),
}, ensure_ascii=False, indent=2)
text = text or "" text = text or ""
truncated = False truncated = False
@@ -477,12 +479,11 @@ async def case_get_final_text(case_number: str, max_chars: int = 0) -> str:
text = text[:max_chars] text = text[:max_chars]
truncated = True truncated = True
return json.dumps({ return ok({
"status": "ok",
"case_number": case_number, "case_number": case_number,
"file_path": str(final_path), "file_path": str(final_path),
"text_length": len(text), "text_length": len(text),
"page_count": page_count, "page_count": page_count,
"truncated": truncated, "truncated": truncated,
"text": text, "text": text,
}, ensure_ascii=False, indent=2) })

View File

@@ -23,18 +23,10 @@ missing decision so that newer rows now link to it).
from __future__ import annotations from __future__ import annotations
import json
from uuid import UUID from uuid import UUID
from legal_mcp.services import citation_extractor from legal_mcp.services import citation_extractor
from legal_mcp.tools.envelope import err as _err, ok as _ok # GAP-48: SSoT envelope
def _ok(payload) -> str:
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
def _err(msg: str) -> str:
return json.dumps({"error": msg}, ensure_ascii=False)
async def extract_internal_citations( async def extract_internal_citations(

View File

@@ -2,13 +2,15 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import shutil import shutil
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import db, git_sync, processor from legal_mcp.services import audit, db, git_sync, processor
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
async def document_upload( async def document_upload(
@@ -27,16 +29,27 @@ async def document_upload(
""" """
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
source = Path(file_path) source = Path(file_path)
if not source.exists(): if not source.exists():
return f"קובץ לא נמצא: {file_path}" return err(f"קובץ לא נמצא: {file_path}")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
if not title: if not title:
title = source.stem title = source.stem
# INV-TOOL3 / GAP-52: idempotent on (case_id, file content hash). Re-uploading
# the same bytes returns the existing document and skips re-copy + re-OCR +
# re-embed (the expensive part).
content_hash = hashlib.sha256(source.read_bytes()).hexdigest()
existing_doc = await db.get_document_by_hash(case_id, content_hash)
if existing_doc:
return ok({
"document": existing_doc,
"idempotent_existing": True,
}, message=f"הקובץ כבר הועלה לתיק {case_number} (זהה ב-hash) — מוחזר הקיים, ללא עיבוד מחדש.")
# Copy file to case directory # Copy file to case directory
case_dir = config.find_case_dir(case_number) / "documents" / "originals" case_dir = config.find_case_dir(case_number) / "documents" / "originals"
case_dir.mkdir(parents=True, exist_ok=True) case_dir.mkdir(parents=True, exist_ok=True)
@@ -52,6 +65,7 @@ async def document_upload(
doc_type=initial_doc_type, doc_type=initial_doc_type,
title=title, title=title,
file_path=str(dest), file_path=str(dest),
content_hash=content_hash,
) )
# Process document (extract → classify → chunk → embed → store) # Process document (extract → classify → chunk → embed → store)
@@ -87,10 +101,14 @@ async def document_upload(
except Exception: except Exception:
pass # git not available in container — non-critical pass # git not available in container — non-critical
return json.dumps({ await audit.log_action_safe(
"document_upload", case_id=case_id, document_id=UUID(doc["id"]),
details={"title": title, "doc_type": actual_doc_type},
)
return ok({
"document": doc, "document": doc,
"processing": result, "processing": result,
}, default=str, ensure_ascii=False, indent=2) })
async def document_upload_training( async def document_upload_training(
@@ -120,7 +138,7 @@ async def document_upload_training(
source = Path(file_path) source = Path(file_path)
if not source.exists(): if not source.exists():
return f"קובץ לא נמצא: {file_path}" return err(f"קובץ לא נמצא: {file_path}")
if not title: if not title:
title = source.stem title = source.stem
@@ -195,13 +213,13 @@ async def document_upload_training(
] ]
await db.store_chunks(doc_id, None, chunk_dicts) await db.store_chunks(doc_id, None, chunk_dicts)
return json.dumps({ return ok({
"corpus_id": str(corpus_id), "corpus_id": str(corpus_id),
"title": title, "title": title,
"pages": page_count, "pages": page_count,
"text_length": len(text), "text_length": len(text),
"chunks": len(chunks) if chunks else 0, "chunks": len(chunks) if chunks else 0,
}, default=str, ensure_ascii=False, indent=2) })
async def document_get_text(case_number: str, doc_title: str = "") -> str: async def document_get_text(case_number: str, doc_title: str = "") -> str:
@@ -213,16 +231,16 @@ async def document_get_text(case_number: str, doc_title: str = "") -> str:
""" """
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
docs = await db.list_documents(UUID(case["id"])) docs = await db.list_documents(UUID(case["id"]))
if not docs: if not docs:
return f"אין מסמכים בתיק {case_number}." return empty(f"אין מסמכים בתיק {case_number}.")
if doc_title: if doc_title:
docs = [d for d in docs if doc_title.lower() in d["title"].lower()] docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
if not docs: if not docs:
return f"מסמך '{doc_title}' לא נמצא בתיק." return err(f"מסמך '{doc_title}' לא נמצא בתיק.")
results = [] results = []
for doc in docs: for doc in docs:
@@ -233,7 +251,7 @@ async def document_get_text(case_number: str, doc_title: str = "") -> str:
"text": text[:10000] if text else "(ללא טקסט)", "text": text[:10000] if text else "(ללא טקסט)",
}) })
return json.dumps(results, ensure_ascii=False, indent=2) return ok(results)
async def document_list(case_number: str) -> str: async def document_list(case_number: str) -> str:
@@ -244,13 +262,13 @@ async def document_list(case_number: str) -> str:
""" """
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
docs = await db.list_documents(UUID(case["id"])) docs = await db.list_documents(UUID(case["id"]))
if not docs: if not docs:
return f"אין מסמכים בתיק {case_number}." return empty(f"אין מסמכים בתיק {case_number}.")
return json.dumps(docs, default=str, ensure_ascii=False, indent=2) return ok(docs)
async def extract_references( async def extract_references(
@@ -267,12 +285,12 @@ async def extract_references(
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
docs = await db.list_documents(case_id) docs = await db.list_documents(case_id)
if not docs: if not docs:
return f"אין מסמכים בתיק {case_number}." return empty(f"אין מסמכים בתיק {case_number}.")
if doc_title: if doc_title:
docs = [d for d in docs if doc_title.lower() in d["title"].lower()] docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
@@ -294,7 +312,7 @@ async def extract_references(
"legislation": refs["legislation"], "legislation": refs["legislation"],
}) })
return json.dumps(results, default=str, ensure_ascii=False, indent=2) return ok(results)
async def extract_claims( async def extract_claims(
@@ -313,12 +331,12 @@ async def extract_claims(
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
docs = await db.list_documents(case_id) docs = await db.list_documents(case_id)
if not docs: if not docs:
return f"אין מסמכים בתיק {case_number}." return empty(f"אין מסמכים בתיק {case_number}.")
# Filter to claims documents (appeal, response) or specific doc # Filter to claims documents (appeal, response) or specific doc
if doc_title: if doc_title:
@@ -327,7 +345,7 @@ async def extract_claims(
docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection")] docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection")]
if not docs: if not docs:
return "לא נמצאו כתבי טענות בתיק." return empty("לא נמצאו כתבי טענות בתיק.")
results = [] results = []
for doc in docs: for doc in docs:
@@ -344,7 +362,11 @@ async def extract_claims(
) )
results.append(result) results.append(result)
return json.dumps(results, default=str, ensure_ascii=False, indent=2) await audit.log_action_safe(
"extract_claims", case_id=case_id,
details={"docs_processed": len(docs), "results": len(results)},
)
return ok(results)
async def get_claims(case_number: str, party_role: str = "") -> str: async def get_claims(case_number: str, party_role: str = "") -> str:
@@ -356,7 +378,7 @@ async def get_claims(case_number: str, party_role: str = "") -> str:
""" """
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
claims = await db.get_claims( claims = await db.get_claims(
UUID(case["id"]), UUID(case["id"]),
@@ -364,7 +386,7 @@ async def get_claims(case_number: str, party_role: str = "") -> str:
) )
if not claims: if not claims:
return f"אין טענות בתיק {case_number}." return empty(f"אין טענות בתיק {case_number}.")
# Format for display # Format for display
role_hebrew = { role_hebrew = {
@@ -382,7 +404,7 @@ async def get_claims(case_number: str, party_role: str = "") -> str:
"source": c.get("source_document", ""), "source": c.get("source_document", ""),
}) })
return json.dumps(formatted, default=str, ensure_ascii=False, indent=2) return ok(formatted)
# Whitelist of doc_type values; mirrors web/app.py:DOC_TYPE_NAMES. # Whitelist of doc_type values; mirrors web/app.py:DOC_TYPE_NAMES.
@@ -417,37 +439,26 @@ async def document_update(
""" """
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return json.dumps({"status": "error", return err(f"תיק {case_number} לא נמצא.")
"message": f"תיק {case_number} לא נמצא."},
ensure_ascii=False, indent=2)
try: try:
doc_uuid = UUID(doc_id) doc_uuid = UUID(doc_id)
except ValueError: except ValueError:
return json.dumps({"status": "error", return err(f"doc_id לא תקין: {doc_id}")
"message": f"doc_id לא תקין: {doc_id}"},
ensure_ascii=False, indent=2)
doc = await db.get_document(doc_uuid) doc = await db.get_document(doc_uuid)
if not doc: if not doc:
return json.dumps({"status": "error", return err(f"מסמך {doc_id} לא נמצא.")
"message": f"מסמך {doc_id} לא נמצא."},
ensure_ascii=False, indent=2)
if doc.get("case_id") != case["id"]: if doc.get("case_id") != case["id"]:
return json.dumps({"status": "error", return err(f"מסמך {doc_id} לא שייך לתיק {case_number}.")
"message": f"מסמך {doc_id} לא שייך לתיק {case_number}."},
ensure_ascii=False, indent=2)
updates: dict = {} updates: dict = {}
if doc_type: if doc_type:
if doc_type not in ALLOWED_DOC_TYPES: if doc_type not in ALLOWED_DOC_TYPES:
return json.dumps({ return err(f"doc_type לא תקין: {doc_type}",
"status": "error", data={"allowed": sorted(ALLOWED_DOC_TYPES)})
"message": f"doc_type לא תקין: {doc_type}",
"allowed": sorted(ALLOWED_DOC_TYPES),
}, ensure_ascii=False, indent=2)
updates["doc_type"] = doc_type updates["doc_type"] = doc_type
# appraiser_side is optional. The MCP tool can't distinguish "skip" from # appraiser_side is optional. The MCP tool can't distinguish "skip" from
@@ -455,11 +466,8 @@ async def document_update(
# To clear, the operator must edit metadata directly (rare). # To clear, the operator must edit metadata directly (rare).
if appraiser_side: if appraiser_side:
if appraiser_side not in ALLOWED_APPRAISER_SIDES: if appraiser_side not in ALLOWED_APPRAISER_SIDES:
return json.dumps({ return err(f"appraiser_side לא תקין: {appraiser_side}",
"status": "error", data={"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s)})
"message": f"appraiser_side לא תקין: {appraiser_side}",
"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s),
}, ensure_ascii=False, indent=2)
metadata = doc.get("metadata") or {} metadata = doc.get("metadata") or {}
if isinstance(metadata, str): if isinstance(metadata, str):
metadata = json.loads(metadata) metadata = json.loads(metadata)
@@ -467,14 +475,12 @@ async def document_update(
updates["metadata"] = metadata updates["metadata"] = metadata
if not updates: if not updates:
return json.dumps({"status": "noop", "message": "אין שינוי לבצע."}, return ok({"noop": True}, message="אין שינוי לבצע.")
ensure_ascii=False, indent=2)
await db.update_document(doc_uuid, **updates) await db.update_document(doc_uuid, **updates)
fresh = await db.get_document(doc_uuid) fresh = await db.get_document(doc_uuid)
return json.dumps({ return ok({
"status": "completed",
"doc_id": doc_id, "doc_id": doc_id,
"doc_type": fresh.get("doc_type"), "doc_type": fresh.get("doc_type"),
"metadata": fresh.get("metadata"), "metadata": fresh.get("metadata"),
}, default=str, ensure_ascii=False, indent=2) })

View File

@@ -7,7 +7,7 @@ from pathlib import Path
from uuid import UUID from uuid import UUID
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import db, embeddings, git_sync, research_md from legal_mcp.services import audit, db, embeddings, git_sync, research_md
from legal_mcp.services.lessons import ( from legal_mcp.services.lessons import (
CITATION_GUIDANCE, CITATION_GUIDANCE,
DECISION_TEMPLATES, DECISION_TEMPLATES,
@@ -15,12 +15,15 @@ from legal_mcp.services.lessons import (
GOLDEN_RATIOS, GOLDEN_RATIOS,
OPENING_STRATEGIES, OPENING_STRATEGIES,
PARAGRAPH_LENGTHS, PARAGRAPH_LENGTHS,
PRACTICE_AREA_OVERRIDES,
SUMMARY_STRATEGIES, SUMMARY_STRATEGIES,
TRANSITION_PHRASES, TRANSITION_PHRASES,
VALID_OUTCOMES, VALID_OUTCOMES,
canonical_outcome,
format_ratios_comment, format_ratios_comment,
get_lessons_for_outcome, get_lessons_for_outcome,
) )
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
# Fallback template for cases without expected_outcome # Fallback template for cases without expected_outcome
DECISION_TEMPLATE = """# החלטה DECISION_TEMPLATE = """# החלטה
@@ -156,8 +159,52 @@ async def get_style_guide() -> str:
f"| {r['discussion'][0]}-{r['discussion'][1]}% " f"| {r['discussion'][0]}-{r['discussion'][1]}% "
f"| {r['summary'][0]}-{r['summary'][1]}% |\n" f"| {r['summary'][0]}-{r['summary'][1]}% |\n"
) )
# GAP-51: betterment_levy is a practice_area override (applied on top of the outcome), not an outcome.
_bl = PRACTICE_AREA_OVERRIDES["betterment_levy"]["golden_ratios"]
result += (
f"| {outcome_labels['betterment_levy']} (override לפי תחום) "
f"| {_bl['background'][0]}-{_bl['background'][1]}% "
f"| {_bl['claims'][0]}-{_bl['claims'][1]}% "
f"| {_bl['discussion'][0]}-{_bl['discussion'][1]}% "
f"| {_bl['summary'][0]}-{_bl['summary'][1]}% |\n"
)
result += "\n" result += "\n"
# T10 — measured-from-corpus ratios alongside the targets, ⚠️ flags a gap
# (actual average outside the target range → revisit the target or the corpus).
try:
from legal_mcp.services.style_distance import measure_corpus_ratios
measured = await measure_corpus_ratios()
if measured:
result += "### נמדד מהקורפוס בפועל (ממוצע) — ⚠️ = פער מהיעד\n\n"
result += "| קבוצה | רקע | טענות | דיון | סיכום |\n|---|------|-------|------|-------|\n"
# Per-outcome rows (flagged vs that outcome's target), when outcomes exist.
for outcome in VALID_OUTCOMES:
m = measured.get(outcome)
if not m:
continue
tgt = GOLDEN_RATIOS[outcome]
cells = []
for sec in ("background", "claims", "discussion", "summary"):
val = m["sections"].get(sec)
if val is None:
cells.append("")
continue
lo, hi = tgt[sec]
cells.append(f"{val}%" + ("" if lo <= val <= hi else " ⚠️"))
result += f"| {outcome_labels[outcome]} (n={m['n']}) | " + " | ".join(cells) + " |\n"
# "_all" aggregate — the meaningful row today (corpus outcome unpopulated);
# shown informationally (no single target to flag against).
allm = measured.get("_all")
if allm:
cells = [f"{allm['sections'].get(s, '')}%" if allm['sections'].get(s) is not None else ""
for s in ("background", "claims", "discussion", "summary")]
result += f"| כל ההחלטות (n={allm['n']}) | " + " | ".join(cells) + " |\n"
result += ("\n_⚠ = הממוצע בפועל חורג מטווח-היעד; שקול לעדכן יעד ב-/methodology או לבדוק את הקורפוס. "
"פיצול לפי-תוצאה יופיע כש-`style_corpus.outcome` יאוכלס._\n\n")
except Exception as e: # surfaced, not swallowed
result += f"_מדידת יחסי-זהב מהקורפוס נכשלה: {e}_\n\n"
# Opening and summary strategies # Opening and summary strategies
result += "## אסטרטגיות פתיחה וסיכום לפי תוצאה\n\n" result += "## אסטרטגיות פתיחה וסיכום לפי תוצאה\n\n"
for outcome in VALID_OUTCOMES: for outcome in VALID_OUTCOMES:
@@ -167,7 +214,14 @@ async def get_style_guide() -> str:
result += f"- **פתיחה:** {opening['description']} ({opening['paragraphs'][0]}-{opening['paragraphs'][1]} פסקאות)\n" result += f"- **פתיחה:** {opening['description']} ({opening['paragraphs'][0]}-{opening['paragraphs'][1]} פסקאות)\n"
result += f"- **סיכום ({summary['heading']}):** {summary['description']}\n\n" result += f"- **סיכום ({summary['heading']}):** {summary['description']}\n\n"
return result # GAP-51: betterment_levy override (practice_area, applied over the outcome)
_bo = PRACTICE_AREA_OVERRIDES["betterment_levy"]
_op, _sm = _bo["opening_strategy"], _bo["summary_strategy"]
result += f"### {outcome_labels['betterment_levy']} (override לפי תחום)\n"
result += f"- **פתיחה:** {_op['description']} ({_op['paragraphs'][0]}-{_op['paragraphs'][1]} פסקאות)\n"
result += f"- **סיכום ({_sm['heading']}):** {_sm['description']}\n\n"
return ok(result)
async def draft_section( async def draft_section(
@@ -175,16 +229,24 @@ async def draft_section(
section: str, section: str,
instructions: str = "", instructions: str = "",
) -> str: ) -> str:
"""הרכבת הקשר מלא לניסוח סעיף בהחלטה - כולל עובדות מהמסמכים, תקדימים רלוונטיים ודפוסי סגנון. """DEPRECATED (GAP-50/INV-TOOL2): העדף את get_block_context — הקשר לפי-בלוק,
התואם לארכיטקטורת 12-הבלוקים הקנונית. כלי זה מרכיב הקשר לפי "סעיף"
(granularity ישן וחופף ל-get_block_context) ונשמר זמנית לתאימות-לאחור.
הרכבת הקשר מלא לניסוח סעיף בהחלטה - כולל עובדות מהמסמכים, תקדימים רלוונטיים ודפוסי סגנון.
Args: Args:
case_number: מספר תיק הערר case_number: מספר תיק הערר
section: סוג הסעיף (facts, appellant_claims, respondent_claims, legal_analysis, conclusion, ruling) section: סוג הסעיף (facts, appellant_claims, respondent_claims, legal_analysis, conclusion, ruling)
instructions: הנחיות נוספות לניסוח instructions: הנחיות נוספות לניסוח
כל קטע ב-case_documents/precedents מלווה ב-provenance: document_id, page
(מספר עמוד במסמך-המקור, אם קיים) ו-score — כדי שניתן יהיה לעקוב אחורה
אל המקור ולצטטו, ולא לסמוך על התוכן ללא מקור.
""" """
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
expected_outcome = case.get("expected_outcome", "") expected_outcome = case.get("expected_outcome", "")
@@ -227,10 +289,16 @@ async def draft_section(
}, },
"section": section, "section": section,
"instructions": instructions, "instructions": instructions,
# GAP-47 (INV-TOOL4/G9): surface provenance — document_id + page —
# so the writer can cite chunks back to their source (already fetched
# by search_similar, was previously discarded).
"case_documents": [ "case_documents": [
{ {
"document": c["document_title"], "document": c["document_title"],
"document_id": str(c["document_id"]),
"page": c.get("page_number"),
"section_type": c["section_type"], "section_type": c["section_type"],
"score": round(c.get("score", 0.0), 4),
"content": c["content"], "content": c["content"],
} }
for c in case_chunks for c in case_chunks
@@ -239,6 +307,9 @@ async def draft_section(
{ {
"case_number": c["case_number"], "case_number": c["case_number"],
"document": c["document_title"], "document": c["document_title"],
"document_id": str(c["document_id"]),
"page": c.get("page_number"),
"score": round(c.get("score", 0.0), 4),
"content": c["content"][:500], "content": c["content"][:500],
} }
for c in precedent_chunks[:3] for c in precedent_chunks[:3]
@@ -278,7 +349,7 @@ async def draft_section(
context["drafting_guidance"] = guidance context["drafting_guidance"] = guidance
return json.dumps(context, ensure_ascii=False, indent=2) return ok(context)
async def get_chair_directions(case_number: str) -> str: async def get_chair_directions(case_number: str) -> str:
@@ -304,7 +375,7 @@ async def get_chair_directions(case_number: str) -> str:
case_dir = config.find_case_dir(case_number) case_dir = config.find_case_dir(case_number)
file_path = case_dir / "documents" / "research" / "analysis-and-research.md" file_path = case_dir / "documents" / "research" / "analysis-and-research.md"
result = research_md.extract_chair_directions(file_path) result = research_md.extract_chair_directions(file_path)
return json.dumps(result, ensure_ascii=False, indent=2) return ok(result)
async def get_decision_template(case_number: str) -> str: async def get_decision_template(case_number: str) -> str:
@@ -317,9 +388,11 @@ async def get_decision_template(case_number: str) -> str:
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
expected_outcome = case.get("expected_outcome", "") # GAP-51: canonicalize outcome + apply betterment_levy practice_area override.
expected_outcome = canonical_outcome(case.get("expected_outcome", ""))
practice_area = case.get("practice_area", "")
format_args = dict( format_args = dict(
case_number=case["case_number"], case_number=case["case_number"],
@@ -332,23 +405,28 @@ async def get_decision_template(case_number: str) -> str:
# Use outcome-specific template if available # Use outcome-specific template if available
if expected_outcome and expected_outcome in DECISION_TEMPLATES: if expected_outcome and expected_outcome in DECISION_TEMPLATES:
# Add ratio comments # Add ratio comments (practice_area-aware)
format_args["ratios_background"] = format_ratios_comment(expected_outcome, "background") format_args["ratios_background"] = format_ratios_comment(expected_outcome, "background", practice_area)
format_args["ratios_claims"] = format_ratios_comment(expected_outcome, "claims") format_args["ratios_claims"] = format_ratios_comment(expected_outcome, "claims", practice_area)
format_args["ratios_discussion"] = format_ratios_comment(expected_outcome, "discussion") format_args["ratios_discussion"] = format_ratios_comment(expected_outcome, "discussion", practice_area)
format_args["ratios_summary"] = format_ratios_comment(expected_outcome, "summary") format_args["ratios_summary"] = format_ratios_comment(expected_outcome, "summary", practice_area)
template = DECISION_TEMPLATES[expected_outcome].format(**format_args) # betterment_levy practice_area supplies its own template; else use the outcome's.
override = PRACTICE_AREA_OVERRIDES.get(practice_area, {})
template_src = override.get("decision_template") or DECISION_TEMPLATES[expected_outcome]
template = template_src.format(**format_args)
# Add guidance header # Add guidance header (override-aware via get_lessons_for_outcome)
opening = OPENING_STRATEGIES[expected_outcome] lessons_o = get_lessons_for_outcome(expected_outcome, practice_area)
summary = SUMMARY_STRATEGIES[expected_outcome] opening = lessons_o["opening_strategy"]
summary = lessons_o["summary_strategy"]
header = ( header = (
f"<!-- תבנית מותאמת ל: {expected_outcome} -->\n" f"<!-- תבנית מותאמת ל: {expected_outcome}"
f"{' / ' + practice_area if practice_area in PRACTICE_AREA_OVERRIDES else ''} -->\n"
f"<!-- פתיחת דיון: {opening['description']} -->\n" f"<!-- פתיחת דיון: {opening['description']} -->\n"
f"<!-- סיכום: {summary['description']} -->\n\n" f"<!-- סיכום: {summary['description']} -->\n\n"
) )
return header + template return ok(header + template)
else: else:
# Fallback to generic template # Fallback to generic template
template = DECISION_TEMPLATE.format(**format_args) template = DECISION_TEMPLATE.format(**format_args)
@@ -357,7 +435,7 @@ async def get_decision_template(case_number: str) -> str:
"<!-- לא הוגדרה תוצאה צפויה. הגדר expected_outcome בתיק לקבלת תבנית מותאמת. -->\n" "<!-- לא הוגדרה תוצאה צפויה. הגדר expected_outcome בתיק לקבלת תבנית מותאמת. -->\n"
f"<!-- ערכים אפשריים: {', '.join(VALID_OUTCOMES)} -->\n\n" f"<!-- ערכים אפשריים: {', '.join(VALID_OUTCOMES)} -->\n\n"
) + template ) + template
return template return ok(template)
async def validate_decision(case_number: str) -> str: async def validate_decision(case_number: str) -> str:
@@ -370,15 +448,15 @@ async def validate_decision(case_number: str) -> str:
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
try: try:
result = await qa_validator.validate_decision(case_id) result = await qa_validator.validate_decision(case_id)
return json.dumps(result, default=str, ensure_ascii=False, indent=2) return ok(result)
except ValueError as e: except ValueError as e:
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2) return err(str(e))
async def export_docx(case_number: str, output_path: str = "") -> str: async def export_docx(case_number: str, output_path: str = "") -> str:
@@ -395,7 +473,7 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
@@ -403,40 +481,37 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
# fail (or before QA has been run at all). Gate on the STORED qa_results — # fail (or before QA has been run at all). Gate on the STORED qa_results —
# cheap SELECT, no LLM re-run. # cheap SELECT, no LLM re-run.
if not await db.qa_run_exists(case_id): if not await db.qa_run_exists(case_id):
return json.dumps({ return err("ייצוא נחסם: בקרת איכות (QA) טרם רצה על התיק. "
"status": "error", "הרץ validate_decision לפני ייצוא.")
"message": "ייצוא נחסם: בקרת איכות (QA) טרם רצה על התיק. "
"הרץ validate_decision לפני ייצוא.",
}, ensure_ascii=False, indent=2)
critical = await db.get_critical_qa_failures(case_id) critical = await db.get_critical_qa_failures(case_id)
if critical: if critical:
gate_names = ", ".join(r["check_name"] for r in critical) gate_names = ", ".join(r["check_name"] for r in critical)
return json.dumps({ return err(
"status": "error", f"ייצוא נחסם: שערי QA קריטיים נכשלו ({gate_names}). "
"message": f"ייצוא נחסם: שערי QA קריטיים נכשלו ({gate_names}). "
f"תקן את הליקויים והרץ validate_decision מחדש לפני ייצוא.", f"תקן את הליקויים והרץ validate_decision מחדש לפני ייצוא.",
"failed_gates": [r["check_name"] for r in critical], data={"failed_gates": [r["check_name"] for r in critical]},
}, ensure_ascii=False, indent=2) )
try: try:
path = await docx_exporter.export_decision(case_id, output_path or None) path = await docx_exporter.export_decision(case_id, output_path or None)
# Register this export as the new source of truth # Register this export as the new source of truth
await db.set_active_draft_path(case_id, path) await db.set_active_draft_path(case_id, path)
await audit.log_action_safe(
"export_docx", case_id=case_id,
details={"path": str(path)},
)
await db.mark_blocks_stale(case_id, False)
case_dir = config.find_case_dir(case_number) case_dir = config.find_case_dir(case_number)
if case_dir.exists(): if case_dir.exists():
git_sync.commit_and_push(case_dir, f"ייצוא DOCX: {Path(path).name}") git_sync.commit_and_push(case_dir, f"ייצוא DOCX: {Path(path).name}")
return json.dumps({ return ok({
"status": "completed",
"path": path, "path": path,
"active_draft_path": path, "active_draft_path": path,
"message": f"DOCX נוצר: {path}", "message": f"DOCX נוצר: {path}",
}, ensure_ascii=False, indent=2) })
except ValueError as e: except ValueError as e:
return json.dumps({ return err(str(e))
"status": "error",
"message": str(e),
}, ensure_ascii=False, indent=2)
# ── Interim draft (pre-ruling) ──────────────────────────────────── # ── Interim draft (pre-ruling) ────────────────────────────────────
@@ -460,16 +535,40 @@ async def extract_appraiser_facts(case_number: str) -> str:
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return json.dumps({"status": "error", return err(f"תיק {case_number} לא נמצא.")
"message": f"תיק {case_number} לא נמצא."},
ensure_ascii=False, indent=2)
case_id = UUID(case["id"]) case_id = UUID(case["id"])
try: try:
result = await appraiser_facts_extractor.extract_appraiser_facts(case_id) result = await appraiser_facts_extractor.extract_appraiser_facts(case_id)
return json.dumps(result, default=str, ensure_ascii=False, indent=2) return ok(result)
except Exception as e: except Exception as e:
return json.dumps({"status": "error", "message": str(e)}, return err(str(e))
ensure_ascii=False, indent=2)
async def get_appraiser_facts(case_number: str) -> str:
"""קריאת עובדות-השמאי שכבר חולצו לתיק — ללא הרצת חילוץ מחדש (INV-TOOL4 / GAP-44).
ה-get המקביל ל-extract_appraiser_facts: מחזיר את העובדות השמורות בטבלת
appraiser_facts + סתירות מזוהות בין שמאים, בלי קריאת-LLM יקרה ולא-דטרמיניסטית.
מחזיר facts ריק אם החילוץ טרם רץ (status=ok, count=0) — לא שגיאה.
Args:
case_number: מספר תיק הערר
"""
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"])
try:
facts = await db.list_appraiser_facts(case_id)
conflicts = await db.detect_appraiser_conflicts(case_id)
return ok({
"case_number": case_number,
"count": len(facts),
"facts": facts,
"conflicts": conflicts,
})
except Exception as e:
return err(str(e))
async def write_interim_draft(case_number: str, instructions: str = "") -> str: async def write_interim_draft(case_number: str, instructions: str = "") -> str:
@@ -488,9 +587,7 @@ async def write_interim_draft(case_number: str, instructions: str = "") -> str:
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return json.dumps({"status": "error", return err(f"תיק {case_number} לא נמצא.")
"message": f"תיק {case_number} לא נמצא."},
ensure_ascii=False, indent=2)
case_id = UUID(case["id"]) case_id = UUID(case["id"])
# Make sure appraiser facts exist before writing block-tet (which depends on them). # Make sure appraiser facts exist before writing block-tet (which depends on them).
@@ -519,13 +616,12 @@ async def write_interim_draft(case_number: str, instructions: str = "") -> str:
"error": str(e), "error": str(e),
}) })
return json.dumps({ return ok({
"status": "completed",
"blocks": results, "blocks": results,
"appraiser_facts_run": facts_run, "appraiser_facts_run": facts_run,
"total_words": sum(r.get("word_count", 0) for r in results), "total_words": sum(r.get("word_count", 0) for r in results),
"completed": sum(1 for r in results if r["status"] == "completed"), "completed": sum(1 for r in results if r["status"] == "completed"),
}, default=str, ensure_ascii=False, indent=2) })
async def export_interim_draft(case_number: str, output_path: str = "") -> str: async def export_interim_draft(case_number: str, output_path: str = "") -> str:
@@ -541,9 +637,7 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return json.dumps({"status": "error", return err(f"תיק {case_number} לא נמצא.")
"message": f"תיק {case_number} לא נמצא."},
ensure_ascii=False, indent=2)
case_id = UUID(case["id"]) case_id = UUID(case["id"])
try: try:
@@ -554,16 +648,14 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
case_dir = config.find_case_dir(case_number) case_dir = config.find_case_dir(case_number)
if case_dir.exists(): if case_dir.exists():
git_sync.commit_and_push(case_dir, f"טיוטת ביניים: {Path(path).name}") git_sync.commit_and_push(case_dir, f"טיוטת ביניים: {Path(path).name}")
return json.dumps({ return ok({
"status": "completed",
"mode": "interim", "mode": "interim",
"path": path, "path": path,
"active_draft_path": path, "active_draft_path": path,
"message": f"טיוטת ביניים נוצרה: {path}", "message": f"טיוטת ביניים נוצרה: {path}",
}, ensure_ascii=False, indent=2) })
except ValueError as e: except ValueError as e:
return json.dumps({"status": "error", "message": str(e)}, return err(str(e))
ensure_ascii=False, indent=2)
async def apply_user_edit(case_number: str, edit_filename: str) -> str: async def apply_user_edit(case_number: str, edit_filename: str) -> str:
@@ -582,35 +674,30 @@ async def apply_user_edit(case_number: str, edit_filename: str) -> str:
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return json.dumps({"status": "error", return err(f"תיק {case_number} לא נמצא.")
"message": f"תיק {case_number} לא נמצא."},
ensure_ascii=False, indent=2)
case_id = UUID(case["id"]) case_id = UUID(case["id"])
export_dir = config.find_case_dir(case_number) / "exports" export_dir = config.find_case_dir(case_number) / "exports"
edit_path = export_dir / edit_filename if "/" not in edit_filename else Path(edit_filename) edit_path = export_dir / edit_filename if "/" not in edit_filename else Path(edit_filename)
if not edit_path.exists(): if not edit_path.exists():
return json.dumps({"status": "error", return err(f"קובץ לא נמצא: {edit_path}")
"message": f"קובץ לא נמצא: {edit_path}"},
ensure_ascii=False, indent=2)
try: try:
retrofit_result = docx_retrofit.retrofit_bookmarks(edit_path) retrofit_result = docx_retrofit.retrofit_bookmarks(edit_path)
await db.set_active_draft_path(case_id, str(edit_path)) await db.set_active_draft_path(case_id, str(edit_path))
await db.mark_blocks_stale(case_id, True)
case_dir = config.find_case_dir(case_number) case_dir = config.find_case_dir(case_number)
if case_dir.exists(): if case_dir.exists():
git_sync.commit_and_push(case_dir, f"גרסת עריכה: {edit_path.name}") git_sync.commit_and_push(case_dir, f"גרסת עריכה: {edit_path.name}")
return json.dumps({ return ok({
"status": "completed",
"active_draft_path": str(edit_path), "active_draft_path": str(edit_path),
"bookmarks_added": retrofit_result.get("bookmarks_added", []), "bookmarks_added": retrofit_result.get("bookmarks_added", []),
"missing_blocks": retrofit_result.get("missing_blocks", []), "missing_blocks": retrofit_result.get("missing_blocks", []),
"structural_fallback": retrofit_result.get("structural_fallback", []), "structural_fallback": retrofit_result.get("structural_fallback", []),
"existing_bookmarks": retrofit_result.get("existing_bookmarks", []), "existing_bookmarks": retrofit_result.get("existing_bookmarks", []),
}, ensure_ascii=False, indent=2) })
except Exception as e: except Exception as e:
return json.dumps({"status": "error", "message": str(e)}, return err(str(e))
ensure_ascii=False, indent=2)
async def list_bookmarks(case_number: str) -> str: async def list_bookmarks(case_number: str) -> str:
@@ -622,26 +709,20 @@ async def list_bookmarks(case_number: str) -> str:
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return json.dumps({"status": "error", return err(f"תיק {case_number} לא נמצא.")
"message": f"תיק {case_number} לא נמצא."},
ensure_ascii=False, indent=2)
active_path = await db.get_active_draft_path(UUID(case["id"])) active_path = await db.get_active_draft_path(UUID(case["id"]))
if not active_path or not Path(active_path).exists(): if not active_path or not Path(active_path).exists():
return json.dumps({"status": "no_active_draft", return empty("לא נמצא active_draft. הרץ ייצוא או העלה עריכה.")
"message": "לא נמצא active_draft. הרץ ייצוא או העלה עריכה."},
ensure_ascii=False, indent=2)
try: try:
names = docx_reviser.list_bookmarks(active_path) names = docx_reviser.list_bookmarks(active_path)
return json.dumps({ return ok({
"status": "completed",
"active_draft_path": active_path, "active_draft_path": active_path,
"bookmarks": names, "bookmarks": names,
}, ensure_ascii=False, indent=2) })
except Exception as e: except Exception as e:
return json.dumps({"status": "error", "message": str(e)}, return err(str(e))
ensure_ascii=False, indent=2)
async def revise_draft(case_number: str, revisions_json: str, async def revise_draft(case_number: str, revisions_json: str,
@@ -663,22 +744,17 @@ async def revise_draft(case_number: str, revisions_json: str,
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return json.dumps({"status": "error", return err(f"תיק {case_number} לא נמצא.")
"message": f"תיק {case_number} לא נמצא."},
ensure_ascii=False, indent=2)
case_id = UUID(case["id"]) case_id = UUID(case["id"])
active_path = await db.get_active_draft_path(case_id) active_path = await db.get_active_draft_path(case_id)
if not active_path or not Path(active_path).exists(): if not active_path or not Path(active_path).exists():
return json.dumps({"status": "error", return err("אין active_draft. הרץ ייצוא או apply_user_edit קודם.")
"message": "אין active_draft. הרץ ייצוא או apply_user_edit קודם."},
ensure_ascii=False, indent=2)
try: try:
raw = json.loads(revisions_json) if isinstance(revisions_json, str) else revisions_json raw = json.loads(revisions_json) if isinstance(revisions_json, str) else revisions_json
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
return json.dumps({"status": "error", "message": f"JSON לא תקף: {e}"}, return err(f"JSON לא תקף: {e}")
ensure_ascii=False, indent=2)
revisions = [] revisions = []
for item in raw: for item in raw:
@@ -710,14 +786,14 @@ async def revise_draft(case_number: str, revisions_json: str,
active_path, output_path, revisions, author=author, active_path, output_path, revisions, author=author,
) )
await db.set_active_draft_path(case_id, str(output_path)) await db.set_active_draft_path(case_id, str(output_path))
await db.mark_blocks_stale(case_id, True)
case_dir = config.find_case_dir(case_number) case_dir = config.find_case_dir(case_number)
if case_dir.exists(): if case_dir.exists():
git_sync.commit_and_push( git_sync.commit_and_push(
case_dir, case_dir,
f"revise: טיוטה-v{next_ver} ({result.applied} שינויים, {result.failed} נכשלו)", f"revise: טיוטה-v{next_ver} ({result.applied} שינויים, {result.failed} נכשלו)",
) )
return json.dumps({ return ok({
"status": "completed",
"output_path": str(output_path), "output_path": str(output_path),
"version": next_ver, "version": next_ver,
"applied": result.applied, "applied": result.applied,
@@ -727,10 +803,9 @@ async def revise_draft(case_number: str, revisions_json: str,
{"id": r.id, "status": r.status, "error": r.error} {"id": r.id, "status": r.status, "error": r.error}
for r in result.results for r in result.results
], ],
}, ensure_ascii=False, indent=2) })
except Exception as e: except Exception as e:
return json.dumps({"status": "error", "message": str(e)}, return err(str(e))
ensure_ascii=False, indent=2)
async def get_block_context(case_number: str, block_id: str, instructions: str = "") -> str: async def get_block_context(case_number: str, block_id: str, instructions: str = "") -> str:
@@ -745,14 +820,14 @@ async def get_block_context(case_number: str, block_id: str, instructions: str =
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
try: try:
ctx = await block_writer.get_block_context(case_id, block_id, instructions) ctx = await block_writer.get_block_context(case_id, block_id, instructions)
return json.dumps(ctx, default=str, ensure_ascii=False, indent=2) return ok(ctx)
except ValueError as e: except ValueError as e:
return str(e) return err(str(e))
async def save_block_content(case_number: str, block_id: str, content: str) -> str: async def save_block_content(case_number: str, block_id: str, content: str) -> str:
@@ -767,14 +842,14 @@ async def save_block_content(case_number: str, block_id: str, content: str) -> s
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
try: try:
result = await block_writer.save_block_content(case_id, block_id, content) result = await block_writer.save_block_content(case_id, block_id, content)
return json.dumps(result, default=str, ensure_ascii=False, indent=2) return ok(result)
except ValueError as e: except ValueError as e:
return str(e) return err(str(e))
async def analyze_style(appeal_subtype: str = "") -> str: async def analyze_style(appeal_subtype: str = "") -> str:
@@ -787,7 +862,7 @@ async def analyze_style(appeal_subtype: str = "") -> str:
from legal_mcp.services.style_analyzer import analyze_corpus from legal_mcp.services.style_analyzer import analyze_corpus
result = await analyze_corpus(appeal_subtype) result = await analyze_corpus(appeal_subtype)
return json.dumps(result, ensure_ascii=False, indent=2) return ok(result)
async def write_block( async def write_block(
@@ -806,15 +881,15 @@ async def write_block(
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
try: try:
result = await block_writer.write_and_store_block(case_id, block_id, instructions) result = await block_writer.write_and_store_block(case_id, block_id, instructions)
return json.dumps(result, default=str, ensure_ascii=False, indent=2) return ok(result)
except ValueError as e: except ValueError as e:
return str(e) return err(str(e))
async def write_all_blocks( async def write_all_blocks(
@@ -834,7 +909,7 @@ async def write_all_blocks(
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
@@ -868,8 +943,8 @@ async def write_all_blocks(
break break
total_words = sum(r.get("word_count", 0) for r in results) total_words = sum(r.get("word_count", 0) for r in results)
return json.dumps({ return ok({
"blocks": results, "blocks": results,
"total_words": total_words, "total_words": total_words,
"completed": sum(1 for r in results if r["status"] == "completed"), "completed": sum(1 for r in results if r["status"] == "completed"),
}, default=str, ensure_ascii=False, indent=2) })

View File

@@ -0,0 +1,58 @@
"""מעטפת-תשובה קנונית לכלי-ה-MCP (GAP-48 / INV-TOOL1).
מקור-אמת יחיד לצורת-הפלט של כל כלי. כל כלי שעבר מיגרציה מחזיר מחרוזת-JSON
אחידה ``{status, data, message}`` — לא list-לפעמים, string-לפעמים, ``{error}``
לפעמים. שלושת המצבים מובחנים מפורשות (INV-TOOL1):
status — "ok" הצלחה עם payload ב-``data``.
"empty" שאילתה תקינה שלא החזירה תוצאות (מובחן מ-error).
"error" הקריאה נכשלה; ``message`` מסביר.
data — ה-payload (list/dict/scalar) או null.
message — הערה קריאה-לאדם (עברית), בעיקר ל-empty/error.
מחליף את ה-``_ok``/``_err`` שהשתכפלו ב-5 קבצי-כלים עם 3 מוסכמות שונות (G2).
צרכן-API ב-``web/`` שמעביר פלט-כלי ל-HTTP חייב **לפרק** את המעטפת
(להחזיר ``data``) כדי לשמר את חוזה-ה-UI↔API (X6) — ראה ``envelope_unwrap``.
"""
from __future__ import annotations
import json
from typing import Any
def _dump(obj: dict) -> str:
return json.dumps(obj, ensure_ascii=False, indent=2, default=str)
def ok(data: Any = None, message: str = "") -> str:
"""הצלחה עם payload."""
return _dump({"status": "ok", "data": data, "message": message})
def empty(message: str = "", data: Any = None) -> str:
"""שאילתה תקינה ללא תוצאות — מובחן מהצלחה (data לא-ריק) ומשגיאה."""
return _dump({"status": "empty", "data": [] if data is None else data, "message": message})
def err(message: str, *, data: Any = None) -> str:
"""כשל בקריאה."""
return _dump({"status": "error", "data": data, "message": message})
def envelope_unwrap(parsed: Any) -> Any:
"""פירוק מעטפת לצורך צרכן-API שמשמר חוזה-HTTP ישן.
בהינתן dict-מעטפת מפורסר (``json.loads`` של פלט-כלי שעבר מיגרציה):
status=="ok" → מחזיר ``data`` (ה-payload המקורי, למשל list).
status in {empty,error}→ מחזיר ``{"message": message}`` (תאימות-לאחור
לצורה שצרכני-ה-API החזירו על מחרוזת-פרוזה).
קלט שאינו מעטפת (אין מפתח ``status``) מוחזר כמות-שהוא — בטוח לתקופת-המעבר
שבה רק חלק מהכלים עברו מיגרציה.
"""
if isinstance(parsed, dict) and "status" in parsed and "data" in parsed:
if parsed["status"] == "ok":
return parsed["data"]
return {"message": parsed.get("message", "")}
return parsed

View File

@@ -14,9 +14,8 @@ decisions and enforces the required metadata at the tool boundary.
from __future__ import annotations from __future__ import annotations
import json
from legal_mcp.services import internal_decisions as int_svc from legal_mcp.services import internal_decisions as int_svc
from legal_mcp.tools.envelope import err as _err, ok as _ok # GAP-48: SSoT envelope
# Valid Hebrew district names (matches _COURT_TO_DISTRICT in service) # Valid Hebrew district names (matches _COURT_TO_DISTRICT in service)
VALID_DISTRICTS = {"ירושלים", "מרכז", "תל אביב", "תל-אביב", "צפון", "דרום", "חיפה", "ארצי"} VALID_DISTRICTS = {"ירושלים", "מרכז", "תל אביב", "תל-אביב", "צפון", "דרום", "חיפה", "ארצי"}
@@ -26,14 +25,6 @@ VALID_DISTRICTS = {"ירושלים", "מרכז", "תל אביב", "תל-אביב
VALID_PROCEEDING_TYPES = {"ערר", 'בל"מ'} VALID_PROCEEDING_TYPES = {"ערר", 'בל"מ'}
def _ok(payload) -> str:
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
def _err(msg: str) -> str:
return json.dumps({"error": msg}, ensure_ascii=False)
async def internal_decision_upload( async def internal_decision_upload(
file_path: str, file_path: str,
case_number: str, case_number: str,

View File

@@ -2,10 +2,10 @@
from __future__ import annotations from __future__ import annotations
import json
from uuid import UUID from uuid import UUID
from legal_mcp.services import argument_aggregator, db from legal_mcp.services import argument_aggregator, db
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
async def aggregate_claims_to_arguments( async def aggregate_claims_to_arguments(
@@ -20,17 +20,14 @@ async def aggregate_claims_to_arguments(
""" """
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return json.dumps( return err(f"תיק {case_number} לא נמצא.")
{"status": "error", "message": f"תיק {case_number} לא נמצא."},
ensure_ascii=False, indent=2,
)
case_id = UUID(case["id"]) case_id = UUID(case["id"])
result = await argument_aggregator.aggregate_claims_to_arguments( result = await argument_aggregator.aggregate_claims_to_arguments(
case_id, force=force, case_id, force=force,
) )
result["case_number"] = case_number result["case_number"] = case_number
return json.dumps(result, ensure_ascii=False, indent=2, default=str) return ok(result)
async def get_legal_arguments( async def get_legal_arguments(
@@ -46,21 +43,16 @@ async def get_legal_arguments(
""" """
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return json.dumps( return err(f"תיק {case_number} לא נמצא.")
{"status": "error", "message": f"תיק {case_number} לא נמצא."},
ensure_ascii=False, indent=2,
)
case_id = UUID(case["id"]) case_id = UUID(case["id"])
args = await argument_aggregator.get_legal_arguments(case_id, party=party) args = await argument_aggregator.get_legal_arguments(case_id, party=party)
if not args: if not args:
return json.dumps({ return empty(
"status": "empty", "לא נמצאו טיעונים מאוגדים. הרץ aggregate_claims_to_arguments תחילה.",
"case_number": case_number, data={"case_number": case_number, "arguments": []},
"message": "לא נמצאו טיעונים מאוגדים. הרץ aggregate_claims_to_arguments תחילה.", )
"arguments": [],
}, ensure_ascii=False, indent=2)
# Group by party for nicer display. # Group by party for nicer display.
party_he = { party_he = {
@@ -75,9 +67,8 @@ async def get_legal_arguments(
label = party_he.get(a["party"], a["party"]) label = party_he.get(a["party"], a["party"])
by_party.setdefault(label, []).append(a) by_party.setdefault(label, []).append(a)
return json.dumps({ return ok({
"status": "ok",
"case_number": case_number, "case_number": case_number,
"total": len(args), "total": len(args),
"by_party": by_party, "by_party": by_party,
}, ensure_ascii=False, indent=2, default=str) })

View File

@@ -18,18 +18,10 @@ Three tools:
from __future__ import annotations from __future__ import annotations
import json
from uuid import UUID from uuid import UUID
from legal_mcp.services import db from legal_mcp.services import db
from legal_mcp.tools.envelope import err as _err, ok as _ok # GAP-48: SSoT envelope
def _ok(payload) -> str:
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
def _err(msg: str) -> str:
return json.dumps({"error": msg}, ensure_ascii=False)
async def _resolve_case_id(case_number: str) -> UUID | None: async def _resolve_case_id(case_number: str) -> UUID | None:

View File

@@ -3,7 +3,8 @@
This is distinct from: This is distinct from:
- ``precedents`` (case_precedents table) — chair-attached quotes scoped to - ``precedents`` (case_precedents table) — chair-attached quotes scoped to
a specific case section. Use ``precedent_search_library`` for that. a specific case section. Use ``search_case_precedents`` for that (GAP-49:
renamed from the misleading ``precedent_search_library``).
- ``style_corpus`` (Daphna's prior decisions) — searched via - ``style_corpus`` (Daphna's prior decisions) — searched via
``search_decisions`` for style/voice. ``search_decisions`` for style/voice.
@@ -17,19 +18,11 @@ the chair approves them — per project review policy.
from __future__ import annotations from __future__ import annotations
import json
import time import time
from uuid import UUID from uuid import UUID
from legal_mcp.services import db, precedent_library, telemetry from legal_mcp.services import db, precedent_library, telemetry
from legal_mcp.tools.envelope import empty, err as _err, ok as _ok # GAP-48: SSoT envelope
def _ok(payload) -> str:
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
def _err(msg: str) -> str:
return json.dumps({"error": msg}, ensure_ascii=False)
async def precedent_library_upload( async def precedent_library_upload(
@@ -215,6 +208,37 @@ async def precedent_extract_metadata(case_law_id: str) -> str:
return _ok(result) return _ok(result)
async def precedent_reindex(case_law_id: str) -> str:
"""re-chunk + re-embed פסיקה קיימת מה-full_text השמור (FU-3/GAP-09).
לתיקון drift של embeddings או אחרי שינוי-תוכן. אינו מריץ OCR/LLM — רק
chunking + voyage embeddings. idempotent (מוחק ובונה chunks מחדש).
"""
try:
cid = UUID(case_law_id)
except ValueError:
return _err("case_law_id לא תקין")
try:
from legal_mcp.services import ingest
result = await ingest.reindex_case_law(cid)
except Exception as e:
return _err(str(e))
return _ok(result)
async def extraction_status() -> str:
"""סטטוס תור-החילוץ — כמה פסיקות ממתינות לחילוץ metadata/halacha (INV-TOOL4 / GAP-45).
חושף את התור ש-precedent_process_pending מרוקן: עומק-תור + גיל הבקשה
הוותיקה ביותר לכל סוג. read-only — אינו מרוקן את התור.
"""
try:
status = await db.extraction_queue_status()
except Exception as e:
return _err(str(e))
return _ok(status)
async def precedent_process_pending(kind: str = "metadata", limit: int = 20) -> str: async def precedent_process_pending(kind: str = "metadata", limit: int = 20) -> str:
"""ריקון תור בקשות חילוץ שנערמו ע"י כפתורי ה-UI. kind: 'metadata' או 'halacha'. """ריקון תור בקשות חילוץ שנערמו ע"י כפתורי ה-UI. kind: 'metadata' או 'halacha'.
@@ -262,7 +286,7 @@ async def search_precedent_library(
Returns: רשימה מדורגת. כל פריט הוא {"type": "halacha"|"passage", "score", ...}. Returns: רשימה מדורגת. כל פריט הוא {"type": "halacha"|"passage", "score", ...}.
""" """
if not query or len(query.strip()) < 2: if not query or len(query.strip()) < 2:
return json.dumps([], ensure_ascii=False) return empty("שאילתה קצרה מדי (פחות מ-2 תווים).")
q = query.strip() q = query.strip()
t0 = time.perf_counter() t0 = time.perf_counter()
results = await precedent_library.search_library( results = await precedent_library.search_library(
@@ -332,7 +356,22 @@ async def halacha_review(
return _ok(row) return _ok(row)
async def halachot_pending(limit: int = 100) -> str: async def halachot_pending(limit: int = 100, include_low_quality: bool = False) -> str:
"""תור ההלכות הממתינות לאישור (review_status='pending_review').""" """תור ההלכות הממתינות לאישור (review_status='pending_review').
rows = await db.list_halachot(review_status="pending_review", limit=limit)
כברירת-מחדל (#84.1, #84.3) התור **מסונן** — הלכות עם דגל-איכות כלשהו
(application / ציטוט-לא-מאומת / קטוע / obiter / restatement דק / לא-נתמך /
near-duplicate) מוסתרות (הן שייכות ל'דורש תיקון-חילוץ', לא לתור-האישור),
ו**ממוין לפי עדיפות** (טופלו-לרעה תחילה, אז הכי לא-ודאיים, אז הישנים).
Args:
limit: מספר מקסימלי.
include_low_quality: True כדי לחשוף גם פריטים מסומני-איכות (בקט 'דורש תיקון').
"""
rows = await db.list_halachot(
review_status="pending_review",
limit=limit,
exclude_low_quality=not include_low_quality,
order_by_priority=True,
)
return _ok(rows) return _ok(rows)

View File

@@ -7,11 +7,10 @@ free-text citations the chair attaches during the compose phase.
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path
from uuid import UUID from uuid import UUID
from legal_mcp.services import db from legal_mcp.services import db
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
async def precedent_attach( async def precedent_attach(
@@ -34,14 +33,22 @@ async def precedent_attach(
""" """
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return json.dumps({"error": f"תיק {case_number} לא נמצא."}, ensure_ascii=False) return err(f"תיק {case_number} לא נמצא.")
pdf_uuid: UUID | None = None pdf_uuid: UUID | None = None
if pdf_document_id: if pdf_document_id:
try: try:
pdf_uuid = UUID(pdf_document_id) pdf_uuid = UUID(pdf_document_id)
except ValueError: except ValueError:
return json.dumps({"error": "pdf_document_id לא תקין"}, ensure_ascii=False) return err("pdf_document_id לא תקין")
# INV-TOOL3 / GAP-52: idempotent on (case_id, section_id, citation, quote).
# Re-attaching the same quote to the same section returns the existing row.
for _p in await db.list_case_precedents(UUID(case["id"])):
if (_p.get("citation") == citation and _p.get("quote") == quote
and (_p.get("section_id") or None) == (section_id or None)):
_p["idempotent_existing"] = True
return ok(_p)
row = await db.create_case_precedent( row = await db.create_case_precedent(
case_id=UUID(case["id"]), case_id=UUID(case["id"]),
@@ -52,17 +59,17 @@ async def precedent_attach(
pdf_document_id=pdf_uuid, pdf_document_id=pdf_uuid,
practice_area=case.get("practice_area"), practice_area=case.get("practice_area"),
) )
return json.dumps(row, ensure_ascii=False, indent=2, default=str) return ok(row)
async def precedent_list(case_number: str) -> str: async def precedent_list(case_number: str) -> str:
"""רשימת כל הפסיקות שצורפו לתיק, ממוינות לפי סעיף ואז לפי זמן יצירה.""" """רשימת כל הפסיקות שצורפו לתיק, ממוינות לפי סעיף ואז לפי זמן יצירה."""
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return json.dumps({"error": f"תיק {case_number} לא נמצא."}, ensure_ascii=False) return err(f"תיק {case_number} לא נמצא.")
rows = await db.list_case_precedents(UUID(case["id"])) rows = await db.list_case_precedents(UUID(case["id"]))
return json.dumps(rows, ensure_ascii=False, indent=2, default=str) return ok(rows)
async def precedent_remove(precedent_id: str) -> str: async def precedent_remove(precedent_id: str) -> str:
@@ -70,18 +77,18 @@ async def precedent_remove(precedent_id: str) -> str:
try: try:
pid = UUID(precedent_id) pid = UUID(precedent_id)
except ValueError: except ValueError:
return json.dumps({"error": "precedent_id לא תקין"}, ensure_ascii=False) return err("precedent_id לא תקין")
ok = await db.delete_case_precedent(pid) deleted = await db.delete_case_precedent(pid)
return json.dumps( return ok({"deleted": deleted, "precedent_id": precedent_id})
{"deleted": ok, "precedent_id": precedent_id}, ensure_ascii=False,
)
async def precedent_search_library( async def search_case_precedents(
query: str, practice_area: str = "", limit: int = 10, query: str, practice_area: str = "", limit: int = 10,
) -> str: ) -> str:
"""חיפוש בספרייה הרוחבית — כל הפסיקות שצורפו אי-פעם בכל התיקים. """חיפוש רוחבי בציטוטי-הפסיקה שצורפו ידנית לתיקים (case_precedents) — קורפוס
"case-attached". GAP-49 (INV-TOOL2): שם קודם `precedent_search_library` (מטעה).
זו **אינה** ספריית-הפסיקה הסמכותית — לזו השתמש ב-`search_precedent_library`.
Args: Args:
query: מחרוזת חיפוש (מתחרה מול citation ומול quote) query: מחרוזת חיפוש (מתחרה מול citation ומול quote)
@@ -89,7 +96,7 @@ async def precedent_search_library(
limit: מספר תוצאות מקסימלי limit: מספר תוצאות מקסימלי
""" """
if not query or len(query.strip()) < 2: if not query or len(query.strip()) < 2:
return json.dumps([], ensure_ascii=False) return empty("שאילתה קצרה מדי (פחות מ-2 תווים).")
rows = await db.search_precedent_library(query.strip(), practice_area, limit) rows = await db.search_precedent_library(query.strip(), practice_area, limit)
return json.dumps(rows, ensure_ascii=False, indent=2, default=str) return ok(rows)

View File

@@ -2,12 +2,12 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import time import time
from uuid import UUID from uuid import UUID
from legal_mcp.services import db, embeddings, hybrid_search, practice_area as pa, telemetry from legal_mcp.services import db, embeddings, hybrid_search, practice_area as pa, telemetry
from legal_mcp.tools.envelope import empty, err, ok
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -53,8 +53,8 @@ async def search_decisions(
# search to its domain. This is a data anomaly — BLOCK rather than # search to its domain. This is a data anomaly — BLOCK rather than
# silently running a cross-domain search for a specific case. # silently running a cross-domain search for a specific case.
if not practice_area: if not practice_area:
return ( return err(
f"שגיאה: לא ניתן לקבוע את התחום המשפטי (practice_area) של תיק " f"לא ניתן לקבוע את התחום המשפטי (practice_area) של תיק "
f"{case_number}. לתיק אין practice_area מוגדר ולא ניתן להסיק אותו " f"{case_number}. לתיק אין practice_area מוגדר ולא ניתן להסיק אותו "
f"ממספר התיק. זוהי אנומליית נתונים — נא להגדיר את ה-practice_area " f"ממספר התיק. זוהי אנומליית נתונים — נא להגדיר את ה-practice_area "
f"של התיק (למשל דרך case_update) לפני הרצת חיפוש מסונן לתיק זה." f"של התיק (למשל דרך case_update) לפני הרצת חיפוש מסונן לתיק זה."
@@ -88,7 +88,7 @@ async def search_decisions(
) )
if not results: if not results:
return "לא נמצאו תוצאות." return empty("לא נמצאו תוצאות.")
formatted = [] formatted = []
for r in results: for r in results:
@@ -103,7 +103,7 @@ async def search_decisions(
"image_thumbnail": r.get("image_thumbnail_path"), "image_thumbnail": r.get("image_thumbnail_path"),
}) })
return json.dumps(formatted, ensure_ascii=False, indent=2) return ok(formatted)
async def search_case_documents( async def search_case_documents(
@@ -120,7 +120,7 @@ async def search_case_documents(
""" """
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_uuid = UUID(case["id"]) case_uuid = UUID(case["id"])
query_emb = await embeddings.embed_query(query) query_emb = await embeddings.embed_query(query)
@@ -143,7 +143,7 @@ async def search_case_documents(
) )
if not results: if not results:
return f"לא נמצאו תוצאות בתיק {case_number}." return empty(f"לא נמצאו תוצאות בתיק {case_number}.")
formatted = [] formatted = []
for r in results: for r in results:
@@ -157,7 +157,7 @@ async def search_case_documents(
"image_thumbnail": r.get("image_thumbnail_path"), "image_thumbnail": r.get("image_thumbnail_path"),
}) })
return json.dumps(formatted, ensure_ascii=False, indent=2) return ok(formatted)
async def find_similar_cases( async def find_similar_cases(
@@ -216,7 +216,7 @@ async def find_similar_cases(
) )
if not results: if not results:
return "לא נמצאו תיקים דומים." return empty("לא נמצאו תיקים דומים.")
# Deduplicate by case_number, keep best score per case. # Deduplicate by case_number, keep best score per case.
# image-only rows still carry case_number from the join. # image-only rows still carry case_number from the join.
@@ -240,7 +240,7 @@ async def find_similar_cases(
"match_type": r.get("match_type", "text"), "match_type": r.get("match_type", "text"),
}) })
return json.dumps(formatted, ensure_ascii=False, indent=2) return ok(formatted)
async def search_internal_decisions( async def search_internal_decisions(
@@ -296,7 +296,7 @@ async def search_internal_decisions(
) )
if not results: if not results:
return "לא נמצאו החלטות ועדת ערר רלוונטיות." return empty("לא נמצאו החלטות ועדת ערר רלוונטיות.")
# Cap primary results back to ``limit`` (we over-fetched only to seed # Cap primary results back to ``limit`` (we over-fetched only to seed
# the citation expansion below — the user asked for ``limit`` items). # the citation expansion below — the user asked for ``limit`` items).
@@ -334,7 +334,7 @@ async def search_internal_decisions(
for row in cited_rows: for row in cited_rows:
formatted.append(_format_internal_row(row, match_type="cited_by")) formatted.append(_format_internal_row(row, match_type="cited_by"))
return json.dumps(formatted, ensure_ascii=False, indent=2) return ok(formatted)
def _format_internal_row(r: dict, *, match_type: str = "primary") -> dict: def _format_internal_row(r: dict, *, match_type: str = "primary") -> dict:

View File

@@ -15,18 +15,10 @@ CLI is available, and the row gets enriched.
from __future__ import annotations from __future__ import annotations
import json
from uuid import UUID from uuid import UUID
from legal_mcp.services import db, style_metadata_extractor from legal_mcp.services import db, style_metadata_extractor
from legal_mcp.tools.envelope import err as _err, ok as _ok # GAP-48: SSoT envelope
def _ok(payload) -> str:
return json.dumps({"ok": True, **payload}, ensure_ascii=False, default=str)
def _err(msg: str) -> str:
return json.dumps({"ok": False, "error": msg}, ensure_ascii=False)
async def extract_decision_metadata(corpus_id: str, overwrite: bool = False) -> str: async def extract_decision_metadata(corpus_id: str, overwrite: bool = False) -> str:

View File

@@ -2,11 +2,16 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from uuid import UUID from uuid import UUID
from legal_mcp.services import db from legal_mcp.services import db
from legal_mcp.services.lessons import (
OUTCOME_LABELS_HE,
VALID_OUTCOMES,
canonical_outcome,
)
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -19,7 +24,7 @@ async def workflow_status(case_number: str) -> str:
""" """
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
docs = await db.list_documents(case_id) docs = await db.list_documents(case_id)
@@ -64,7 +69,7 @@ async def workflow_status(case_number: str) -> str:
"next_steps": _suggest_next_steps(case, docs, has_draft), "next_steps": _suggest_next_steps(case, docs, has_draft),
} }
return json.dumps(status, ensure_ascii=False, indent=2) return ok(status)
def _suggest_next_steps(case: dict, docs: list, has_draft: bool) -> list[str]: def _suggest_next_steps(case: dict, docs: list, has_draft: bool) -> list[str]:
@@ -109,12 +114,12 @@ async def get_metrics(case_number: str = "") -> str:
if case_number: if case_number:
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
result = await metrics.get_case_metrics(UUID(case["id"])) result = await metrics.get_case_metrics(UUID(case["id"]))
else: else:
result = await metrics.get_dashboard() result = await metrics.get_dashboard()
return json.dumps(result, default=str, ensure_ascii=False, indent=2) return ok(result)
async def processing_status() -> str: async def processing_status() -> str:
@@ -130,14 +135,14 @@ async def processing_status() -> str:
corpus_count = await conn.fetchval("SELECT COUNT(*) FROM style_corpus") corpus_count = await conn.fetchval("SELECT COUNT(*) FROM style_corpus")
pattern_count = await conn.fetchval("SELECT COUNT(*) FROM style_patterns") pattern_count = await conn.fetchval("SELECT COUNT(*) FROM style_patterns")
return json.dumps({ return ok({
"cases": case_count, "cases": case_count,
"documents": doc_count, "documents": doc_count,
"pending_processing": pending_count, "pending_processing": pending_count,
"chunks": chunk_count, "chunks": chunk_count,
"style_corpus_entries": corpus_count, "style_corpus_entries": corpus_count,
"style_patterns": pattern_count, "style_patterns": pattern_count,
}, ensure_ascii=False, indent=2) })
# ── Outcome & Brainstorming ─────────────────────────────────────── # ── Outcome & Brainstorming ───────────────────────────────────────
@@ -151,18 +156,20 @@ async def set_outcome(
Args: Args:
case_number: מספר תיק הערר case_number: מספר תיק הערר
outcome: תוצאה — rejected (דחייה), accepted (קבלה), partial (קבלה חלקית) outcome: תוצאה — rejection (דחייה) / partial_acceptance (קבלה חלקית) /
full_acceptance (קבלה מלאה). ערכי-legacy (rejected/accepted/partial) ממופים אוטומטית.
reasoning: נימוק (אופציונלי). אם ריק — מפעיל סיעור מוחות. reasoning: נימוק (אופציונלי). אם ריק — מפעיל סיעור מוחות.
""" """
from legal_mcp.services import brainstorm from legal_mcp.services import brainstorm
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
valid_outcomes = ("rejected", "accepted", "partial") # GAP-51: accept legacy vocabulary (rejected/accepted/partial), store canonical.
if outcome not in valid_outcomes: outcome = canonical_outcome(outcome)
return f"תוצאה לא תקינה. אפשרויות: {', '.join(valid_outcomes)}" if outcome not in VALID_OUTCOMES:
return err(f"תוצאה לא תקינה. אפשרויות: {', '.join(VALID_OUTCOMES)}")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
@@ -187,7 +194,7 @@ async def set_outcome(
# Update case status # Update case status
await db.update_case(case_id, status="in_progress", expected_outcome=outcome) await db.update_case(case_id, status="in_progress", expected_outcome=outcome)
outcome_hebrew = {"rejected": "דחייה", "accepted": "קבלה", "partial": "קבלה חלקית"}.get(outcome, outcome) outcome_hebrew = OUTCOME_LABELS_HE.get(outcome, outcome)
result = { result = {
"decision_id": decision["id"], "decision_id": decision["id"],
@@ -204,7 +211,7 @@ async def set_outcome(
result["message"] = f"תוצאה נשמרה: {outcome_hebrew}. ניתן להתחיל כתיבת טיוטה." result["message"] = f"תוצאה נשמרה: {outcome_hebrew}. ניתן להתחיל כתיבת טיוטה."
result["next_step"] = "draft" result["next_step"] = "draft"
return json.dumps(result, default=str, ensure_ascii=False, indent=2) return ok(result)
async def brainstorm_directions( async def brainstorm_directions(
@@ -219,14 +226,14 @@ async def brainstorm_directions(
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
# Get existing decision for outcome # Get existing decision for outcome
decision = await db.get_decision_by_case(case_id) decision = await db.get_decision_by_case(case_id)
if not decision: if not decision:
return "לא הוזנה תוצאה לתיק. הפעל set_outcome קודם." return err("לא הוזנה תוצאה לתיק. הפעל set_outcome קודם.")
outcome = decision.get("outcome", "") outcome = decision.get("outcome", "")
reasoning = decision.get("outcome_reasoning", "") reasoning = decision.get("outcome_reasoning", "")
@@ -239,7 +246,7 @@ async def brainstorm_directions(
direction_doc={"brainstorm": directions, "approved": False}, direction_doc={"brainstorm": directions, "approved": False},
) )
return json.dumps(directions, default=str, ensure_ascii=False, indent=2) return ok(directions)
async def approve_direction( async def approve_direction(
@@ -258,18 +265,18 @@ async def approve_direction(
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
decision = await db.get_decision_by_case(case_id) decision = await db.get_decision_by_case(case_id)
if not decision: if not decision:
return "לא הוזנה תוצאה לתיק." return err("לא הוזנה תוצאה לתיק.")
direction_data = decision.get("direction_doc") or {} direction_data = decision.get("direction_doc") or {}
brainstorm_result = direction_data.get("brainstorm", {}) brainstorm_result = direction_data.get("brainstorm", {})
if not brainstorm_result.get("directions"): if not brainstorm_result.get("directions"):
return "לא בוצע סיעור מוחות. הפעל brainstorm_directions קודם." return err("לא בוצע סיעור מוחות. הפעל brainstorm_directions קודם.")
direction_doc = brainstorm.build_direction_doc( direction_doc = brainstorm.build_direction_doc(
outcome=decision.get("outcome", ""), outcome=decision.get("outcome", ""),
@@ -281,11 +288,8 @@ async def approve_direction(
await db.update_decision(UUID(decision["id"]), direction_doc=direction_doc) await db.update_decision(UUID(decision["id"]), direction_doc=direction_doc)
return json.dumps({ return ok({"direction": direction_doc},
"status": "approved", message="כיוון אושר. ניתן להתחיל כתיבת טיוטה.")
"message": "כיוון אושר. ניתן להתחיל כתיבת טיוטה.",
"direction": direction_doc,
}, default=str, ensure_ascii=False, indent=2)
async def ingest_final_version( async def ingest_final_version(
@@ -304,7 +308,7 @@ async def ingest_final_version(
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if not case: if not case:
return f"תיק {case_number} לא נמצא." return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
@@ -314,12 +318,12 @@ async def ingest_final_version(
final_text, _, _ = await extractor.extract_text(file_path) final_text, _, _ = await extractor.extract_text(file_path)
if not final_text: if not final_text:
return "לא סופק טקסט — יש לספק file_path או final_text." return err("לא סופק טקסט — יש לספק file_path או final_text.")
try: try:
result = await learning_loop.process_final_version(case_id, final_text) result = await learning_loop.process_final_version(case_id, final_text)
except ValueError as e: except ValueError as e:
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2) return err(str(e))
# Auto-ingest into internal committee decisions corpus (best-effort). # Auto-ingest into internal committee decisions corpus (best-effort).
try: try:
@@ -339,7 +343,7 @@ async def ingest_final_version(
logger.warning("ingest_final_version: internal corpus ingestion failed (non-fatal): %s", e) logger.warning("ingest_final_version: internal corpus ingestion failed (non-fatal): %s", e)
result["internal_corpus_ingested"] = False result["internal_corpus_ingested"] = False
return json.dumps(result, default=str, ensure_ascii=False, indent=2) return ok(result)
# ── Chair feedback tools ────────────────────────────────────────── # ── Chair feedback tools ──────────────────────────────────────────
@@ -369,7 +373,7 @@ async def record_chair_feedback(
"factual_error", "style", "other", "factual_error", "style", "other",
] ]
if category not in valid_categories: if category not in valid_categories:
return f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}" return err(f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}")
feedback_id = await db.record_chair_feedback( feedback_id = await db.record_chair_feedback(
case_id=case_id, case_id=case_id,
@@ -379,21 +383,20 @@ async def record_chair_feedback(
lesson_extracted=lesson_extracted, lesson_extracted=lesson_extracted,
) )
return json.dumps({ return ok({
"status": "ok",
"feedback_id": str(feedback_id), "feedback_id": str(feedback_id),
"message": f"הערה נרשמה בהצלחה. קטגוריה: {category}.",
"next_steps": [ "next_steps": [
"כדי להפיק לקח מההערה, הפעל: analyze_chair_feedback", "כדי להפיק לקח מההערה, הפעל: analyze_chair_feedback",
"כדי לסמן כמטופל: resolve_chair_feedback", "כדי לסמן כמטופל: resolve_chair_feedback",
], ],
}, ensure_ascii=False, indent=2) }, message=f"הערה נרשמה בהצלחה. קטגוריה: {category}.")
async def list_chair_feedback( async def list_chair_feedback(
case_number: str = "", case_number: str = "",
category: str = "", category: str = "",
unresolved_only: bool = True, unresolved_only: bool = True,
limit: int = 100,
) -> str: ) -> str:
"""הצגת הערות יו"ר שתועדו, עם אפשרות סינון. """הצגת הערות יו"ר שתועדו, עם אפשרות סינון.
@@ -401,6 +404,7 @@ async def list_chair_feedback(
case_number: סינון לפי תיק (אם ריק — כל ההערות) case_number: סינון לפי תיק (אם ריק — כל ההערות)
category: סינון לפי קטגוריה category: סינון לפי קטגוריה
unresolved_only: האם להציג רק הערות שלא טופלו (ברירת מחדל: כן) unresolved_only: האם להציג רק הערות שלא טופלו (ברירת מחדל: כן)
limit: תקרת תוצאות (INV-TOOL5 / GAP-53)
""" """
case_id = None case_id = None
if case_number: if case_number:
@@ -412,10 +416,11 @@ async def list_chair_feedback(
case_id=case_id, case_id=case_id,
category=category or None, category=category or None,
unresolved_only=unresolved_only, unresolved_only=unresolved_only,
limit=limit,
) )
if not feedbacks: if not feedbacks:
return "אין הערות שמתאימות לסינון." return empty("אין הערות שמתאימות לסינון.")
items = [] items = []
for fb in feedbacks: for fb in feedbacks:
@@ -430,7 +435,7 @@ async def list_chair_feedback(
"date": fb["created_at"].isoformat() if fb.get("created_at") else None, "date": fb["created_at"].isoformat() if fb.get("created_at") else None,
}) })
return json.dumps({ return ok({
"total": len(items), "total": len(items),
"feedbacks": items, "feedbacks": items,
}, ensure_ascii=False, indent=2, default=str) })

View File

@@ -0,0 +1,74 @@
"""FU-7: audit-trail + provenance (offline, monkeypatched I/O)."""
from __future__ import annotations
import asyncio
from uuid import uuid4
import pytest
from legal_mcp.services import audit, db
def _run(coro):
return asyncio.run(coro)
# ── GAP-18: log_action_safe is non-fatal ───────────────────────────────
def test_log_action_safe_swallows_db_error(monkeypatch):
async def _boom(*a, **k):
raise RuntimeError("db down")
monkeypatch.setattr(audit, "log_action", _boom)
# must NOT raise
_run(audit.log_action_safe("write_block", details={"x": 1}))
def test_log_action_safe_forwards_args(monkeypatch):
seen = {}
async def _capture(action, case_id=None, document_id=None, details=None, user="system"):
seen.update(action=action, details=details)
monkeypatch.setattr(audit, "log_action", _capture)
_run(audit.log_action_safe("export_docx", details={"path": "/x"}))
assert seen["action"] == "export_docx" and seen["details"] == {"path": "/x"}
# ── GAP-20: structural citation resolver ────────────────────────────────
def test_resolve_citation_case_law_ids_splits(monkeypatch):
good = uuid4()
bad = uuid4()
class _Conn:
async def fetchval(self, q, cid):
return cid == good
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
class _Pool:
def acquire(self): return _Conn()
async def _pool():
return _Pool()
monkeypatch.setattr(db, "get_pool", _pool)
out = _run(db.resolve_citation_case_law_ids([good, bad]))
assert good in out["resolved"] and bad in out["unresolved"]
# ── GAP-17: blocks_stale helper ────────────────────────────────────────
def test_mark_blocks_stale_executes_update(monkeypatch):
seen = {}
class _Conn:
async def execute(self, q, *a):
seen["q"] = q; seen["args"] = a
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
class _Pool:
def acquire(self): return _Conn()
async def _pool(): return _Pool()
monkeypatch.setattr(db, "get_pool", _pool)
cid = uuid4()
_run(db.mark_blocks_stale(cid, True))
assert "blocks_stale" in seen["q"] and seen["args"][0] is True and seen["args"][1] == cid

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
import os
from legal_mcp.services import claude_session as cs
def test_clean_env_strips_session_markers(monkeypatch):
"""Nested claude -p must not inherit the parent session markers (#85)."""
for k in (
"CLAUDECODE",
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_SESSION_ID",
"CLAUDE_CODE_EXECPATH",
"CLAUDE_CODE_SSE_PORT",
"CLAUDE_AGENT_SDK_VERSION",
"AI_AGENT",
"CLAUDE_EFFORT",
):
monkeypatch.setenv(k, "x")
env = cs._clean_subprocess_env()
assert "CLAUDECODE" not in env
assert "AI_AGENT" not in env
assert "CLAUDE_EFFORT" not in env
assert not any(k.startswith("CLAUDE_CODE_") for k in env)
assert not any(k.startswith("CLAUDE_AGENT_") for k in env)
def test_clean_env_keeps_auth_and_path(monkeypatch):
"""Auth/config + PATH/HOME must survive — they are needed by the CLI."""
monkeypatch.setenv("CLAUDECODE", "1")
monkeypatch.setenv("CLAUDE_CONFIG_DIR", "/home/chaim/.claude")
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://example")
monkeypatch.setenv("PATH", os.environ.get("PATH", "/usr/bin"))
env = cs._clean_subprocess_env()
# CLAUDE_CONFIG_DIR carries credentials — must NOT be stripped.
assert env.get("CLAUDE_CONFIG_DIR") == "/home/chaim/.claude"
assert env.get("ANTHROPIC_BASE_URL") == "https://example"
assert "PATH" in env
assert "CLAUDECODE" not in env

View File

@@ -234,14 +234,15 @@ def test_mcp_precedent_upload_rejects_arar_citation() -> None:
"ARAR 8126-25 ב. קרן-נכסים", "ARAR 8126-25 ב. קרן-נכסים",
): ):
result = loop.run_until_complete(call(citation)) result = loop.run_until_complete(call(citation))
assert "error" in result, ( # GAP-48: tools return the {status,data,message} envelope.
assert result.get("status") == "error", (
f"expected guard to reject {citation!r}, got {result!r}" f"expected guard to reject {citation!r}, got {result!r}"
) )
# The error message should mention internal_decision_upload so # The error message should mention internal_decision_upload so
# the caller knows the alternative path. # the caller knows the alternative path.
assert "internal_decision_upload" in result["error"], ( assert "internal_decision_upload" in result["message"], (
f"error message should redirect to internal_decision_upload, " f"error message should redirect to internal_decision_upload, "
f"got {result['error']!r}" f"got {result['message']!r}"
) )
finally: finally:
loop.close() loop.close()

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import pytest
from legal_mcp.services import corroboration as cor
@pytest.mark.parametrize("raw,expected", [
({"treatment": "followed"}, "followed"),
({"treatment": "OVERRULED"}, "overruled"), # case-insensitive
({"treatment": "bananas"}, "mentioned"), # unknown -> neutral default
({}, "mentioned"), # missing -> neutral default
])
def test_coerce_treatment(raw, expected):
assert cor._coerce_treatment(raw) == expected
def test_treatment_polarity():
assert cor.is_positive("followed") and cor.is_positive("explained")
assert cor.is_negative("distinguished") and cor.is_negative("overruled")
assert not cor.is_positive("mentioned") and not cor.is_negative("mentioned")
def test_match_accepts_above_threshold():
assert cor.accept_match(("h1", 0.62), floor=0.50) == "h1"
def test_match_rejects_below_threshold():
assert cor.accept_match(("h1", 0.41), floor=0.50) is None
def test_match_rejects_empty():
assert cor.accept_match(None, floor=0.50) is None
def _link(src, treatment):
return {"source_id": src, "treatment": treatment}
def test_aggregate_counts_distinct_positive():
links = [_link("d1","followed"), _link("d1","explained"), _link("d2","followed")]
agg = cor.aggregate(links, min_cites=2)
assert agg["positive_sources"] == 2 # d1 counted once (INV-COR4 independence)
assert agg["has_negative"] is False
assert agg["corroborated"] is True
def test_aggregate_negative_blocks():
links = [_link("d1","followed"), _link("d2","followed"), _link("d3","distinguished")]
agg = cor.aggregate(links, min_cites=2)
assert agg["has_negative"] is True
assert agg["corroborated"] is False # any negative -> not corroborated (INV-COR2)
def test_aggregate_below_threshold():
agg = cor.aggregate([_link("d1","followed")], min_cites=2)
assert agg["corroborated"] is False # single source insufficient (INV-COR4)
# --- Phase 2: approval decision (INV-COR2/COR4) ---
def test_approval_action_corroborated_approves():
agg = {"positive_sources": 2, "has_negative": False, "corroborated": True}
assert cor.approval_action(agg, has_overruled=False) == "approve"
def test_approval_action_overruled_demotes_even_if_corroborated():
# overruled outranks any positive count (INV-COR2 strong form)
agg = {"positive_sources": 3, "has_negative": True, "corroborated": False}
assert cor.approval_action(agg, has_overruled=True) == "demote"
def test_approval_action_single_source_noop():
agg = {"positive_sources": 1, "has_negative": False, "corroborated": False}
assert cor.approval_action(agg, has_overruled=False) is None
def test_approval_action_negative_nonoverruled_noop():
# distinguished blocks approval but does not demote (no overruled)
agg = {"positive_sources": 2, "has_negative": True, "corroborated": False}
assert cor.approval_action(agg, has_overruled=False) is None

View File

@@ -125,8 +125,9 @@ def test_export_blocked_when_critical_failures(
monkeypatch.setattr(db, "get_critical_qa_failures", _get_critical) monkeypatch.setattr(db, "get_critical_qa_failures", _get_critical)
out = json.loads(_run(drafting.export_docx("8001-24"))) out = json.loads(_run(drafting.export_docx("8001-24")))
# GAP-48: {status,data,message} envelope; failed_gates rides in data.
assert out["status"] == "error" assert out["status"] == "error"
assert out["failed_gates"] == ["claims_coverage", "structural_integrity"] assert out["data"]["failed_gates"] == ["claims_coverage", "structural_integrity"]
assert "claims_coverage" in out["message"] assert "claims_coverage" in out["message"]
assert patched_export["exported"] is False, "must not call the exporter" assert patched_export["exported"] is False, "must not call the exporter"
assert patched_export["committed"] is False, "must not git-commit" assert patched_export["committed"] is False, "must not git-commit"
@@ -145,7 +146,8 @@ def test_export_proceeds_when_clean(
monkeypatch.setattr(db, "get_critical_qa_failures", _get_critical) monkeypatch.setattr(db, "get_critical_qa_failures", _get_critical)
out = json.loads(_run(drafting.export_docx("8001-24"))) out = json.loads(_run(drafting.export_docx("8001-24")))
assert out["status"] == "completed", out # GAP-48: success is envelope status "ok"; payload (path) rides in data.
assert out["path"] == "/tmp/decision.docx" assert out["status"] == "ok", out
assert out["data"]["path"] == "/tmp/decision.docx"
assert patched_export["exported"] is True, "clean QA must allow export" assert patched_export["exported"] is True, "clean QA must allow export"
assert patched_export["set_draft"] is True, "active_draft_path must be set" assert patched_export["set_draft"] is True, "active_draft_path must be set"

View File

@@ -0,0 +1,62 @@
"""FU-2b: deterministic bare-number extraction (offline)."""
from __future__ import annotations
import importlib.util
from pathlib import Path
import pytest
# Load the migration script as a module (it lives in scripts/, not a package).
_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "fu2b_reconcile_internal_case_numbers.py"
_spec = importlib.util.spec_from_file_location("fu2b_reconcile", _SCRIPT)
fu2b = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(fu2b)
@pytest.mark.parametrize("raw,expected_bare", [
("ערר (‏ועדות ערר - תכנון ובנייה ירושלים‏) 403/17 אהרון ברק נ'", "403-17"),
("ערר (...) 8136-10-24 שחר שות'", "8136-10-24"), # month preserved
("בל\"מ (...) 1028/20 חלוואני ריאד", "1028-20"),
("8047/23", "8047-23"), # already-bare-ish
("ערר 81002-01-21", "81002-01-21"),
])
def test_extract_bare_single_token(raw, expected_bare):
bare, flag = fu2b._extract_bare(raw)
assert bare == expected_bare
assert flag == "OK"
def test_extract_bare_no_number():
bare, flag = fu2b._extract_bare("ערר אדלר נ' הוועדה")
assert bare is None and flag == "NO_NUMBER"
def test_extract_bare_multiple_numbers_flagged():
# Two case-number-shaped tokens → ambiguous, must NOT auto-pick.
bare, flag = fu2b._extract_bare("ערר 403/17 ו-1024/24 מאוחדים")
assert bare is None and flag == "MULTI_NUMBER"
def test_extract_bare_preserves_month_not_padding():
# Month kept exactly; 2-part stays 2-part (no invented month).
assert fu2b._extract_bare("ערר 8126/24 פלוני")[0] == "8126-24"
assert fu2b._extract_bare("ערר 8126-03-25 פלוני")[0] == "8126-03-25"
def test_consistency_flag_when_bare_absent_from_citation():
# proposed bare must appear in citation_formatted, else MISMATCH.
assert fu2b._consistency_flag("403-17", "ערר (...) 403/17 אהרון ברק") == "OK"
assert fu2b._consistency_flag("403-17", "ערר (...) 1975/24 מישהו אחר") == "MISMATCH"
assert fu2b._consistency_flag("403-17", "") == "NO_CITATION"
def test_proc_mismatch_detects_prefix_vs_type_conflict():
# case_number prefix disagrees with proceeding_type → must flag (prefix is
# stripped by the migration, so a wrong proceeding_type loses the signal).
assert fu2b._proc_mismatch('בל"מ 1010-01-25', "ערר") is True
assert fu2b._proc_mismatch('בל"מ (...) 1028/20 חלוואני', "ערר") is True
# agreement → no flag
assert fu2b._proc_mismatch('ערר 1024/24 נילי', "ערר") is False
assert fu2b._proc_mismatch('בל"מ 1010-01-25', 'בל"מ') is False
# bare number with no prefix → nothing to contradict
assert fu2b._proc_mismatch("8047/23", 'בל"מ') is False

View File

@@ -0,0 +1,255 @@
from __future__ import annotations
import pytest
from legal_mcp.services import halacha_quality as hq
# ── non-decision / obiter ──
@pytest.mark.parametrize("text", [
"איני רואה לקבוע מסמרות בשאלה זו",
"אין צורך להכריע בטענה זו",
"למעלה מן הצורך נעיר כי",
"הערה זו ניתנת אגב אורחא",
])
def test_detect_non_decision_hits(text):
assert hq.detect_non_decision(text) is not None
@pytest.mark.parametrize("text", [
"בית המשפט קבע כי ההיתר בטל",
"ועדת הערר מוסמכת לדון בטענת סטייה מתכנית",
"",
])
def test_detect_non_decision_misses(text):
assert hq.detect_non_decision(text) is None
def test_non_decision_scans_all_fields():
# marker sits in the quote, not the abstracted rule
assert hq.detect_non_decision("כלל כללי", "", "וכאן אין צורך להכריע") is not None
# ── truncated quote ──
def test_truncated_dangling_letter():
assert hq.is_quote_truncated("ראוי כי תהיה השפעה על ה") is True
def test_truncated_empty():
assert hq.is_quote_truncated(" ") is True
@pytest.mark.parametrize("quote", [
"ועדת הערר היא הגוף המקצועי האמון על בחינת ההיבטים התכנוניים.",
"אין לועדה סמכות לסטות מתקנות התכנון והבניה", # no period, but full word
"ההיתר תואם את התכנית החלה על האיזור",
])
def test_not_truncated_complete_clauses(quote):
assert hq.is_quote_truncated(quote) is False
# ── thin restatement ──
def test_thin_restatement_near_copy():
quote = "ביטול היתר מחייב טעמים כבדי משקל של אינטרס ציבורי"
rule = "ביטול היתר מחייב טעמים כבדי משקל של אינטרס ציבורי"
assert hq.is_thin_restatement(rule, quote) is True
def test_not_thin_when_abstracted():
quote = "אין חולק כי אין לועדה סמכות לסטות מתקנות"
rule = ("ועדה מקומית לתכנון ובניה אינה מוסמכת לסטות מהוראות תקנות התכנון "
"והבניה, ובכלל זה מהוראות התוספת השנייה, ואין בידה ליתן היתר הסוטה מהן.")
assert hq.is_thin_restatement(rule, quote) is False
def test_thin_handles_empty():
assert hq.is_thin_restatement("", "something") is False
# ── aggregate flags + auto-approve gate semantics ──
def test_clean_halacha_no_flags():
rule = ("ועדת הערר מוסמכת לדון בערר על החלטה ליתן היתר בנייה גם כאשר נטען "
"כי ההיתר סוטה מתכנית, בהתאם למגמת תיקון 43 לחוק.")
quote = ("פרשנות מרחיבה המאפשרת הגשת ערר גם במקרה של מתן היתר כאשר נטען כי "
"ההיתר סוטה מתכנית הולמת את מגמת המחוקק בתיקון 43.")
assert hq.compute_quality_flags(rule, quote, "", quote_verified=True) == []
def test_flags_accumulate():
flags = hq.compute_quality_flags(
"כלל אגב אורחא על ה", "כלל אגב אורחא על ה",
quote_verified=False,
)
assert hq.FLAG_NON_DECISION in flags
assert hq.FLAG_TRUNCATED_QUOTE in flags
assert hq.FLAG_QUOTE_UNVERIFIED in flags
def test_normalize_text_quote_variants():
assert hq.normalize_text('עע"מ 317/10') == hq.normalize_text("עע״מ 317/10")
# ── #81.3 NLI entailment — pure prompt + parser ──
def test_build_nli_prompt_contains_pairs():
items = [
{"rule_statement": "כלל אלף", "supporting_quote": "ציטוט אלף"},
{"rule_statement": "כלל בית", "supporting_quote": "ציטוט בית"},
]
p = hq.build_nli_prompt(items)
assert "כלל אלף" in p and "ציטוט בית" in p
assert "זוג 1" in p and "זוג 2" in p
@pytest.mark.parametrize("raw,n,expected", [
(["entailed", "neutral"], 2, ["entailed", "neutral"]),
(["ENTAILED", "Contradiction"], 2, ["entailed", "contradiction"]), # case-insensitive
([{"verdict": "neutral"}, {"verdict": "entailed"}], 2, ["neutral", "entailed"]), # dict shape
(["entailed"], 2, ["entailed", "entailed"]), # length mismatch -> fail-open
(None, 2, ["entailed", "entailed"]), # non-list -> fail-open
(["bananas", "neutral"], 2, ["entailed", "neutral"]), # unknown label -> entailed
])
def test_parse_nli_verdicts(raw, n, expected):
assert hq.parse_nli_verdicts(raw, n) == expected
# ── _nli_check (async, via claude_session) — fail-open + verdict mapping ──
def test_nli_check_fail_open(monkeypatch):
import asyncio
from legal_mcp.services import halacha_extractor as he
async def boom(*a, **k):
raise RuntimeError("no claude CLI here")
monkeypatch.setattr(he.claude_session, "query_json", boom)
items = [{"rule_statement": "a", "supporting_quote": "b"}]
assert asyncio.run(he._nli_check(items)) == ["entailed"] # never blocks
def test_nli_check_maps_verdicts(monkeypatch):
import asyncio
from legal_mcp.services import halacha_extractor as he
async def fake(*a, **k):
return ["entailed", "neutral"]
monkeypatch.setattr(he.claude_session, "query_json", fake)
items = [{"rule_statement": "a", "supporting_quote": "b"},
{"rule_statement": "c", "supporting_quote": "d"}]
assert asyncio.run(he._nli_check(items)) == ["entailed", "neutral"]
def test_nli_check_empty():
import asyncio
from legal_mcp.services import halacha_extractor as he
assert asyncio.run(he._nli_check([])) == []
# ── #81.5 consolidation — pure prompt + fold-group parser ──
def test_build_consolidation_prompt():
items = [
{"halacha_index": 3, "rule_statement": "כלל גימל", "reasoning_summary": "כי"},
{"halacha_index": 7, "rule_statement": "כלל זין", "reasoning_summary": ""},
]
p = hq.build_consolidation_prompt(items)
assert "[3] כלל גימל" in p and "[7] כלל זין" in p and "היגיון: כי" in p
@pytest.mark.parametrize("raw,expected", [
([[2, 5, 9], [14, 18]], [[2, 5, 9], [14, 18]]),
([[2, 5], [7]], [[2, 5]]), # singleton group dropped
([["2", "5"]], [[2, 5]]), # string ints coerced
([[2, 2, 5]], [[2, 5]]), # dedup within group
([], []), # nothing to fold
("garbage", []), # non-list -> safe
(None, []), # None -> safe
([[1, "x"], [3, 4]], [[3, 4]]), # drop group that falls below 2 valid
])
def test_parse_fold_groups(raw, expected):
assert hq.parse_fold_groups(raw) == expected
def test_consolidation_priority_prefers_approved_then_confidence():
from legal_mcp.services import halacha_extractor as he
approved = {"id": "a", "review_status": "approved", "confidence": 0.7,
"quote_verified": True, "rule_statement": "x"}
pending_hi = {"id": "b", "review_status": "pending_review", "confidence": 0.95,
"quote_verified": True, "rule_statement": "x"}
# approved sorts before higher-confidence pending → kept as canonical
assert min([approved, pending_hi], key=he._consolidation_priority)["id"] == "a"
# ── #81.4 fact-dependent / application ──
@pytest.mark.parametrize("rule", [
"במקרה דנן ועדת הערר קבעה כי ההיתר בטל",
"בענייננו אין הצדקה לפיצוי",
"בערר שלפנינו הוכח כי השומה שגויה",
])
def test_is_fact_dependent_hits(rule):
assert hq.is_fact_dependent(rule) is True
@pytest.mark.parametrize("rule", [
"ועדת הערר מוסמכת לדון בהיטל השבחה",
"נטל ההוכחה מוטל על המבקש",
"פגיעה תכנונית מזכה בפיצוי לפי סעיף 197",
])
def test_is_fact_dependent_misses(rule):
assert hq.is_fact_dependent(rule) is False
def test_application_flag_from_rule_type():
flags = hq.compute_quality_flags(
"נטל ההוכחה על המבקש", "נטל ההוכחה על המבקש כאמור",
rule_type="application",
)
assert hq.FLAG_APPLICATION in flags
def test_application_flag_from_deixis_even_if_binding():
flags = hq.compute_quality_flags(
"במקרה דנן נדחה הערר", "כפי שקבענו במקרה דנן נדחה הערר",
rule_type="binding",
)
assert hq.FLAG_APPLICATION in flags
def test_clean_binding_rule_has_no_flags():
flags = hq.compute_quality_flags(
"ועדת הערר מוסמכת לדון בטענות חוקתיות הנוגעות לתכנית",
"הוועדה מוסמכת לדון אף בטענות מסוג זה, ככל שהן נוגעות לתכנית שבנדון.",
rule_type="binding",
)
assert flags == []
# ── #82.3 lexical near-duplicate signal ──
def test_jaccard_high_for_reworded_same_rule():
a = "נטל ההוכחה בהיטל השבחה מוטל על הוועדה המקומית"
b = "נטל ההוכחה בהיטל השבחה מוטל על הוועדה המקומית בלבד"
assert hq.jaccard_shingles(a, b) >= 0.5
def test_jaccard_low_for_distinct_rules():
a = "ועדת הערר מוסמכת לדון בהיטל השבחה"
b = "המועד להגשת ערר הוא שלושים יום"
assert hq.jaccard_shingles(a, b) < 0.2
def test_normalized_levenshtein_identical_and_disjoint():
assert hq.normalized_levenshtein("אבג", "אבג") == 1.0
assert hq.normalized_levenshtein("", "אבג") == 0.0
def test_lexical_near_duplicate_band():
a = "נטל ההוכחה בהיטל השבחה מוטל על הוועדה המקומית"
b = "נטל ההוכחה בהיטל השבחה מוטל על הוועדה המקומית, כך נפסק"
assert hq.lexical_near_duplicate(a, b) is True
c = "המועד להגשת ערר על שומה הוא שלושים ימים"
assert hq.lexical_near_duplicate(a, c) is False

View File

@@ -0,0 +1,122 @@
"""FU-2a: idempotent ingest + write-time normalization + searchable flag.
Offline tests for the *pure* pieces (canonical normalization, completeness
predicate) and ingest wiring. The real ON CONFLICT upsert is verified by a
DB smoke test against localhost:5433 (see plan Task 6), since it requires a
live Postgres partial unique index.
"""
from __future__ import annotations
import asyncio
from uuid import uuid4
import pytest
from legal_mcp.services import db, ingest
def _run(coro):
return asyncio.run(coro)
# ── GAP-06: canonical normalization (pure, deterministic) ──────────────
@pytest.mark.parametrize("raw,expected", [
("ערר 8137/24", "8137-24"),
(" עע\"מ 1/20 ", "1-20"),
("8126-03-25", "8126-03-25"), # month segment preserved
("בל\"מ 1010-01-25", "1010-01-25"),
("8047/23", "8047-23"),
])
def test_canonical_case_number(raw, expected):
assert db._canonical_case_number(raw) == expected
def test_canonical_does_not_invent_month():
# No month in input → none added (X1 §1).
assert db._canonical_case_number("8126/24") == "8126-24"
# ── GAP-13: completeness predicate (pure) ──────────────────────────────
def _complete_row():
return {
"case_number": "8047-23", "case_name": "פלוני נ' הוועדה",
"practice_area": "rishuy_uvniya", "source_kind": "internal_committee",
"extraction_status": "completed", "headnote": "תקציר",
"summary": "", "subject_tags": [],
}
def test_compute_searchable_true_when_complete():
assert db._compute_searchable(_complete_row(), has_embedded_chunk=True) is True
def test_compute_searchable_false_without_embedded_chunk():
assert db._compute_searchable(_complete_row(), has_embedded_chunk=False) is False
def test_compute_searchable_false_without_metadata():
row = _complete_row()
row["headnote"] = ""; row["summary"] = ""; row["subject_tags"] = []
assert db._compute_searchable(row, has_embedded_chunk=True) is False
def test_compute_searchable_false_when_extraction_incomplete():
row = _complete_row(); row["extraction_status"] = "pending"
assert db._compute_searchable(row, has_embedded_chunk=True) is False
def test_compute_searchable_false_without_core_fields():
row = _complete_row(); row["practice_area"] = ""
assert db._compute_searchable(row, has_embedded_chunk=True) is False
def test_compute_searchable_external_allows_empty_practice_area():
# External precedents (e.g. בג"ץ) are cross-domain — empty practice_area
# must NOT disqualify them, as long as the rest of the contract holds.
row = _complete_row()
row["source_kind"] = "external_upload"
row["practice_area"] = ""
assert db._compute_searchable(row, has_embedded_chunk=True) is True
# ── ingest wires in recompute_searchable (both types) ──────────────────
def test_ingest_calls_recompute_searchable(monkeypatch, tmp_path):
calls = {"recompute": [], "meta": [], "hal": []}
async def _extract_text(path): return ("text", 1, [0])
monkeypatch.setattr(ingest.extractor, "extract_text", _extract_text)
monkeypatch.setattr(ingest.extractor, "strip_nevo_preamble", lambda t: t)
monkeypatch.setattr(ingest.chunker, "chunk_document",
lambda t, page_offsets=None: [type("C", (), {
"chunk_index": 0, "content": "c", "section_type": "b",
"page_number": 1})()])
async def _embed(texts, input_type="document"): return [[0.0] * 8 for _ in texts]
monkeypatch.setattr(ingest.embeddings, "embed_texts", _embed)
async def _store(cid, dicts): return len(dicts)
monkeypatch.setattr(ingest.db, "store_precedent_chunks", _store)
async def _create_internal(**kw): return {"id": uuid4()}
monkeypatch.setattr(ingest.db, "create_internal_committee_decision", _create_internal)
async def _noop(*a, **k): return None
monkeypatch.setattr(ingest.db, "set_case_law_extraction_status", _noop)
monkeypatch.setattr(ingest.db, "set_case_law_halacha_status", _noop)
monkeypatch.setattr(ingest.db, "request_metadata_extraction",
lambda cid: calls["meta"].append(cid) or _noop())
monkeypatch.setattr(ingest.db, "request_halacha_extraction",
lambda cid: calls["hal"].append(cid) or _noop())
async def _recompute(cid): calls["recompute"].append(cid)
monkeypatch.setattr(ingest.db, "recompute_searchable", _recompute)
async def _mark_indexed(cid): return None
monkeypatch.setattr(ingest.db, "mark_indexed", _mark_indexed)
monkeypatch.setattr(ingest.config, "PARENT_DOC_RETRIEVAL_ENABLED", False)
monkeypatch.setattr(ingest.config, "MULTIMODAL_ENABLED", False)
from legal_mcp.services import internal_decisions
_run(internal_decisions.ingest_internal_decision(
case_number="8047/23", text="t", chair_name="x", practice_area="rishuy_uvniya"))
assert len(calls["recompute"]) == 1, "ingest must recompute searchable after success"

View File

@@ -0,0 +1,118 @@
from __future__ import annotations
from legal_mcp.services import extractor as ex
# Nevo preamble block shared by the Nevo-sourced cases.
_PREAMBLE = (
"חקיקה שאוזכרה:\n"
"חוק התכנון והבניה, תשכ\"ה-1965: סע' 197\n\n"
"מיני-רציו:\n"
"* העותרים לא הוכיחו טעם מיוחד.\n"
"ביהמ\"ש העליון דחה את העתירה בקובעו:\n"
"המחוקק הגביל את הזמן ל-3 שנים.\n\n"
)
def test_strips_court_ruling_judge_opening():
# #86.1: court rulings open with the authoring judge — previously NOT stripped.
text = _PREAMBLE + "השופט ס' ג'ובראן:\n\nהאם קיימים טעמים מיוחדים..."
out = ex.strip_nevo_preamble(text)
assert out.startswith("השופט ס' ג'ובראן:")
assert "מיני-רציו" not in out
assert "דחה את העתירה בקובעו" not in out
def test_strips_court_ruling_pdin_header():
text = _PREAMBLE + "פסק-דין\n\nלפנינו עתירה..."
out = ex.strip_nevo_preamble(text)
assert out.startswith("פסק-דין")
assert "מיני-רציו" not in out
def test_strips_vaada_opening_regression():
# existing behaviour must keep working
text = _PREAMBLE + "בפנינו ערר על החלטת הוועדה המקומית..."
out = ex.strip_nevo_preamble(text)
assert out.startswith("בפנינו ערר")
assert "מיני-רציו" not in out
def test_non_nevo_unchanged():
# no Nevo markers → returned as-is even though it has a judge line
text = "פסק דין\nהשופט כהן: בעניין שלפנינו..."
assert ex.strip_nevo_preamble(text) == text
def test_nevo_markers_but_no_body_start_unchanged():
# markers present but nothing that looks like a decision body → leave intact
text = "מיני-רציו:\n* תקציר בלבד ללא גוף החלטה\n"
assert ex.strip_nevo_preamble(text) == text
def test_markers_past_400_chars_still_detected():
# a long court/parties header pushes the markers past the old 400-char window
header = "בבית המשפט העליון " + ("x " * 200) + "\n" # ~600 chars
text = header + _PREAMBLE + "השופטת ע' ארבל:\n\nגוף ההחלטה..."
out = ex.strip_nevo_preamble(text)
assert out.startswith("השופטת ע' ארבל:")
# ── extract_nevo_ratio (#86.3 gold-set capture) ──
def test_extract_ratio_returns_block_before_body():
text = _PREAMBLE + "השופט ס' ג'ובראן:\n\nגוף ההחלטה..."
ratio = ex.extract_nevo_ratio(text)
assert "העותרים לא הוכיחו טעם מיוחד" in ratio
assert "המחוקק הגביל את הזמן" in ratio
# must not bleed into the judgment body
assert "גוף ההחלטה" not in ratio
assert "השופט ס' ג'ובראן" not in ratio
def test_extract_ratio_stops_at_following_marker():
# ratio first, then a bibliography marker AFTER it
text = (
"מיני-רציו:\n* עיקרון אחד בלבד.\n\n"
"פסקי דין שאוזכרו:\nבג\"ץ 1/00\n\n"
"פסק-דין\nגוף..."
)
ratio = ex.extract_nevo_ratio(text)
assert "עיקרון אחד בלבד" in ratio
assert "פסקי דין שאוזכרו" not in ratio
assert "בג\"ץ 1/00" not in ratio
def test_extract_ratio_empty_when_no_marker():
assert ex.extract_nevo_ratio("פסק דין\nהשופט כהן: ...") == ""
assert ex.extract_nevo_ratio("") == ""
# ── #86.2 over-strip regressions ──
def test_citation_judge_line_is_not_a_decision_start():
# "השופט מ' חשין, פסקה 23" is a CITATION (comma, no colon) — must NOT be
# treated as the decision opening, or 32K of real body gets stripped.
body = (
"**פסק דין**\n\n"
"שני ערעורים לפניי. כפי שנפסק מפי כבוד \n\n"
"השופט מ' חשין, פסקה 23 (להלן עניין קהתי), יש לבחון...\n"
)
text = _PREAMBLE + body
out = ex.strip_nevo_preamble(text)
assert out.startswith("**פסק דין**")
assert "השופט מ' חשין, פסקה" in out # citation kept inside body
assert "מיני-רציו" not in out
def test_markdown_wrapped_pdin_header_is_stripped():
text = _PREAMBLE + "**פסק דין**\n\nשני ערעוריה הנדונים..."
out = ex.strip_nevo_preamble(text)
assert out.startswith("**פסק דין**")
assert "מיני-רציו" not in out
def test_author_line_with_colon_still_strips():
text = _PREAMBLE + "כב' השופטת ד' ברק-ארז:\n\nגוף ההחלטה..."
out = ex.strip_nevo_preamble(text)
assert out.startswith("כב' השופטת ד' ברק-ארז:")
assert "מיני-רציו" not in out

View File

@@ -0,0 +1,119 @@
"""FU-8a / GAP-22: fitness function — forbid un-sanctioned Paperclip access.
Fails if any scanned source (outside the allowlist) reaches the Paperclip API
with a raw HTTP client or inserts directly into agent_wakeup_requests. The
sanctioned paths are web/paperclip_api.py::pc_request (Python) and scripts/pc.sh
(bash); wakeup must go through POST /api/agents/{id}/wakeup.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[2]
SCAN_ROOTS = [REPO / "web", REPO / "mcp-server" / "src", REPO / "scripts"]
# Exempt ONLY from the raw-HTTP-to-Paperclip rule. Two categories, per the
# endorsed "differentiate production code from operational tooling" pattern for
# architectural fitness functions (cf. InfoQ fitness-functions; ESLint `overrides`):
# (a) the sanctioned helpers themselves (the one place raw HTTP is correct);
# (b) standalone operator/admin scripts run manually or by cron with the board
# key — a distinct category from app/agent code. Forcing them through the
# wrapper is over-engineering (DRY: "duplication is cheaper than the wrong
# abstraction"); direct httpx with the board key is acceptable for tooling.
# NOTE: the agent_wakeup_requests-INSERT rule is NOT exempted for anyone (below) —
# it is a hard invariant for ALL code (a direct insert skips heartbeat creation).
HTTP_RULE_ALLOWLIST = {
REPO / "web" / "paperclip_api.py", # the sanctioned pc_request helper
REPO / "scripts" / "pc.sh", # the sanctioned bash wrapper
REPO / "web" / "paperclip_client.py", # legacy: DB reads only
REPO / "scripts" / "sync_agents_across_companies.py", # operator tool: CMP→CMPA agent-config sync (CLAUDE.md)
REPO / "scripts" / "audit_corpus_integrity.py", # cron audit tool: posts CEO wakeup via the wakeup API
REPO / "scripts" / "fix_paperclipai_skills_drift.py", # one-shot operator fix (Gap #28 runbook)
REPO / "scripts" / "sync_missing_agent_skills.py", # one-shot operator fix (Gap #28)
}
# Directories to skip entirely during scan (dead/archived code, virtual envs, test fixtures).
_SKIP_PATH_FRAGMENTS = {"/.venv/", "/tests/", "/.archive/"}
_PC_URL = re.compile(r"PAPERCLIP_API_URL|127\.0\.0\.1:3100|localhost:3100|pc\.nautilus\.marcusgroup\.org")
_HTTP_CLIENT = re.compile(r"\bhttpx\b|\brequests\.(get|post|put|patch|delete)\b|\baiohttp\b|\bcurl\b")
_WAKEUP_INSERT = re.compile(r"insert\s+into\s+agent_wakeup_requests", re.IGNORECASE)
def _wakeup_violation(text: str) -> str | None:
"""Universal hard invariant — applies to ALL code (never allowlisted)."""
if _WAKEUP_INSERT.search(text):
return "direct INSERT INTO agent_wakeup_requests — use the wakeup API (POST /api/agents/{id}/wakeup)"
return None
def _http_violation(text: str) -> str | None:
"""Raw HTTP to Paperclip — exempted for HTTP_RULE_ALLOWLIST files only."""
if _PC_URL.search(text) and _HTTP_CLIENT.search(text):
return "raw HTTP client + Paperclip URL — use web/paperclip_api.pc_request or scripts/pc.sh"
return None
def _scan_text(text: str) -> list[str]:
"""All violation reasons for a file's text, ignoring allowlist (used by unit tests)."""
return [r for r in (_wakeup_violation(text), _http_violation(text)) if r]
def _iter_source_files():
for root in SCAN_ROOTS:
if not root.exists():
continue
for ext in ("*.py", "*.sh"):
for f in root.rglob(ext):
if any(frag in str(f) for frag in _SKIP_PATH_FRAGMENTS):
continue
yield f
def find_violations() -> list[tuple[str, str]]:
"""Wakeup-INSERT rule applies to every file; HTTP rule respects HTTP_RULE_ALLOWLIST."""
out = []
for f in _iter_source_files():
try:
text = f.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
w = _wakeup_violation(text)
if w:
out.append((str(f.relative_to(REPO)), w))
if f not in HTTP_RULE_ALLOWLIST:
h = _http_violation(text)
if h:
out.append((str(f.relative_to(REPO)), h))
return out
def test_scan_flags_raw_http_to_paperclip():
bad = 'import httpx\nasync def f():\n await httpx.post(f"{PAPERCLIP_API_URL}/x")\n'
assert _scan_text(bad)
def test_scan_flags_wakeup_insert():
bad = "await conn.execute('INSERT INTO agent_wakeup_requests (id) VALUES ($1)', x)"
assert _scan_text(bad)
def test_scan_ignores_plain_code():
assert _scan_text("def add(a, b):\n return a + b\n") == []
def test_wakeup_insert_rule_is_universal_not_allowlisted():
# The wakeup-INSERT invariant must apply to ALL code; find_violations checks it
# for every file regardless of HTTP_RULE_ALLOWLIST. _wakeup_violation is the
# standalone check used unconditionally in find_violations (no allowlist branch).
assert _wakeup_violation("INSERT INTO agent_wakeup_requests (id) VALUES ($1)") is not None
assert _http_violation('httpx.post(f"{PAPERCLIP_API_URL}/x")') is not None
def test_repo_has_no_paperclip_access_violations():
violations = find_violations()
assert violations == [], "Un-sanctioned Paperclip access found:\n" + "\n".join(
f" {f}: {r}" for f, r in violations)

Some files were not shown because too many files have changed in this diff Show More