198 Commits

Author SHA1 Message Date
c4f3048248 Merge pull request 'feat(claims): אכלוס claims.party_name במקור + backfill — סגירת השדה-המת (#224)' (#403) from worktree-claims-party-name-populate into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m32s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-06 15:11:23 +00:00
f15cc896d6 feat(claims): אכלוס claims.party_name במקור + backfill — סגירת השדה-המת (#224)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 32s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
עד כה claims.party_name נשאר ריק והצבירה/הכתיבה גזרו את הפרדת-המשיבים
מ-source_document בזמן-ריצה (שדה-מת). עכשיו הוא מאוכלס במקור ונקרא ע"י הצרכנים.

- claims_extractor.extract_and_store_claims: מחתים party_name=source_document
  ל-SPLIT_PARTIES (respondent/permit_applicant); single-voice נשאר ''. אותו
  כלל SPLIT_PARTIES של הצבירה (G2, import לא-מעגלי).
- SCHEMA_V52: backfill idempotent ל-claims קיימים (רק split + party_name ריק).
- argument_aggregator + block_writer._build_claims_context: קוראים
  claims.party_name, עם fallback ל-source_document ל-claims מדור-קודם. התנהגות
  זהה (party_name==source_document) — אפס-רגרסיה, אך השדה כעת מקור-האמת (G1).

py_compile + טעינת-שרשרת-ייבוא נקיים. סוגר את הפתוח האחרון של #224.
Invariants: G1 (אכלוס במקור, לא גזירה-בקריאה), G2 (כלל-פיצול יחיד).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 15:10:50 +00:00
6ad0a387fe Merge pull request 'fix(block-yod): התייחסות פרטנית לעמדות משיבים מרובים + זיהוי עמדות-מנוגדות (#224)' (#402) from worktree-block-yod-respondent-positions into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m34s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-07-06 13:20:14 +00:00
0fa2281ada fix(block-yod): התייחסות פרטנית לעמדות משיבים מרובים + זיהוי עמדות-מנוגדות (#224)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
השלמת #224 לבלוק י' (דיון והכרעה): הקשר-הטענות כבר מופרד לפי כתב-תשובה
(PR#401, אותו _build_claims_context), אך ה-prompt של block-yod לא הנחה
להתייחס לכל משיב לגופו. נוסף כלל "צדדים מרובים": התייחס לעמדת כל משיב/כתב-תשובה
בנפרד (משיבות 2-3 מול משיבים 4-6), אל תמזג ל"טענות המשיבים" גורפות, וכשעמדות
מנוגדות — ציין והכרע בנימוק.

שינוי-prompt בלבד; משפיע על ניסוח בלוק י' בהחלטות עם כמה משיבים. py_compile ירוק.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 13:19:37 +00:00
9f1ad67536 Merge pull request 'fix(block-zayin): הפרדת-משיבים בבלוק ז' (טענות הצדדים) — השלמת #224 לשכבת-הכתיבה' (#401) from worktree-block-zayin-respondent-split into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m33s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 15s
2026-07-06 12:47:16 +00:00
1da1585ca8 fix(block-zayin): הפרדת-משיבים בבלוק ז' (טענות הצדדים) — השלמת #224 לשכבת-הכתיבה
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
בלוק ז' של טיוטת-הביניים מיזג את כל המשיבים לסעיף אחד גנרי (או השמיטם) —
כי (א) _build_claims_context קיבץ רק לפי party_role, ו-(ב) ה-prompt נעל
"מבנה קבוע 3 חלקים: עוררים / ועדה מקומית / מבקשי-היתר" — בלי "משיבים" בכלל.
לכן בתיק 1043-02-26 המשיבים 2-3 ו-4-6 לא הופיעו כצדדים מובחנים, אף שהנתונים
(129 טענות-משיבים מפוצלות לפי כתב-תשובה) תקינים ב-claims. הפרדת-המשיבים של
#224 יושמה בצבירה+פאנל בלבד (PR#392) — לא בכותב-הבלוקים.

- `_build_claims_context` מקבץ לפי (party_role, brief) כאשר brief=source_document
  ל-SPLIT_PARTIES (respondent/permit_applicant) — **אותו כלל בדיוק כמו הצבירה**
  (import SPLIT_PARTIES מ-argument_aggregator, G2). התוצאה: סעיף נפרד לכל
  כתב-תשובה ("טענות המשיבים — כתב תשובה משיבות 2 3", "— משיבים 4 6"...).
- prompt בלוק ז': מבנה-צדדים דינמי לפי הכותרות בקונטקסט; הוראה מפורשת לא למזג
  משיבים שונים ולשקף עמדות-מנוגדות; יעד-אורך גמיש לכמה כתבי-תשובה.

משפיע גם על בלוק י' (אותו _build_claims_context) — הקשר מופרד. py_compile ירוק.
עבודה על MCP → דורש host reload + הרצה-מחדש כדי לראות בפועל.
Invariants: G2 (נגזרת-הפרדה יחידה משותפת עם הצבירה), G1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:46:32 +00:00
66cddd20f0 Merge pull request 'refactor(export): שם קובץ טיוטת-הביניים → טיוטה-טענות_הצדדים_{N} (הנחיית חיים)' (#400) from worktree-interim-filename-rename into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m54s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-07-06 12:32:02 +00:00
9e7ef4fced refactor(export): שם קובץ טיוטת-הביניים → טיוטה-טענות_הצדדים_{N} (הנחיית חיים)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 31s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
במקום "טיוטת-ביניים-v{N}.docx" — שם מדבר יותר: "טיוטה-טענות_הצדדים_{N}.docx".
הפורמט הסופי ("טיוטה-v{N}") לא משתנה.

- docx_exporter._draft_naming: (prefix, sep) פר-מצב; interim=("טיוטה-טענות_הצדדים","_"),
  final=("טיוטה","-v"). glob/parse משתמשים ב-rsplit(sep,1) — נכון גם כשה-prefix
  עצמו מכיל "_".
- app.py _generation_output_ready + frontend pickLatestVersioned: matcher-prefix
  מעודכן; regex-הגרסה עוגן-לסוף /[_v](\d+)$/ (ה-"_" שבתוך הprefix לא מבלבל).
- docstrings + legal-ceo.md + הערות מעודכנים לשם החדש. types.ts (auto-gen)
  יתעדכן ב-api:types.

שינוי-נתון (שם-קובץ), לא layout — בגדר חריג-שער-העיצוב. tsc+eslint+py_compile
ירוקים. הערה: ייצור-השם רץ ב-MCP המקומי (host) → דורש reload; ה-matchers
בקונטיינר (Coolify).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:31:30 +00:00
a0a30092e8 Merge pull request 'feat(generate): סימון-סטטוס נשמר-שרת לריצת-הרקע של "הפקת מסמכים" (#227 ג)' (#399) from worktree-generate-status-indicator into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 42s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-07-06 11:34:26 +00:00
b296422d87 feat(generate): סימון-סטטוס לריצת-הרקע של "הפקת מסמכים" (#227 ג)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 15s
עד כה ה-"מייצר…" כיסה רק את שנייה-ה-POST; אחרי זה לא היה חיווי אם ההפקה
רצה/הצליחה/נכשלה — ריצה שבוטלה נראתה כמו הצלחה. עכשיו צ'יפ-סטטוס ליד כל
כפתור-הפקה: בתור · רץ+שעון-חי · הושלם+מתי · נכשל+סיבה+"נסה שוב".

**נשמר-שרת (דרישת חיים):** הסטטוס נגזר בשרת מ-issue-הילד של ה-CEO +
ה-heartbeat_run האחרון + קיום-הקובץ — לא מזיכרון-הדף. שעון-הריצה מעוגן
ל-started_at של השרת, כך שעזיבה+חזרה לדף מציגות את המצב והזמן הנכונים,
בלי צורך להישאר בדף.

Backend:
- paperclip_client.get_generation_run_status — קורא issue-ילד + latest run
  מ-Paperclip DB (read-only, מאחורי שער-הפלטפורמה G12); dedup לפי כותרת.
- GET /api/cases/{n}/generate/{action}/status — ממפה ל-idle/queued/running/
  done/failed + output_ready + started_at/finished_at (UTC). run שהסתיים בלי
  קובץ = failed (שלא ייראה כהצלחה).

Frontend:
- useGenerateStatus (poll 5s רק כשפעיל; עוצר בטרמינלי) + GenerateStatusChip
  (שעון-ריצה חי מ-started_at; רכיב חסר-מצב). כפתורי-ההפקה מרעננים את
  שאילתת-הסטטוס ב-onSuccess. שורה 3 (החלטה מלאה, סינכרוני) — בלי צ'יפ.

עיצוב: מוקאפ 29-generate-status-indicator אושר ע"י חיים דרך שער Claude Design (X17).
tsc + eslint + py_compile ירוקים. (api:types לריענון post-deploy.)
Invariants: G12 (Paperclip רק בשער), G1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:33:41 +00:00
21ad541659 Merge pull request 'fix(generate): הפקת מסמכים דרך issue-ילד משויך-ל-CEO — לא מבוטלת בהמתנה-ליו"ר (#227 ב)' (#398) from worktree-generate-reliability-fix into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 15s
2026-07-06 11:17:24 +00:00
3462cb7320 fix(generate): הפקת מסמכים דרך issue-ילד משויך-ל-CEO — לא מבוטלת בהמתנה-ליו"ר (#227)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 33s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
באג: כפתורי "הפקת מסמכים" (טיוטת-ביניים / סיכום-מנהלים) העירו את ה-CEO
*על ה-issue הראשי*. כשהתיק ב-in_review משויך-לחיים (המצב הרגיל של תיק
ממתין-ליו"ר), Paperclip ביטל את הריצה מיד (issue_assignee_changed) — הכלום
הופק, וה-endpoint החזיר 202 בכל מקרה, כך שהכשל היה שקוף.

תיקון: wake_ceo_for_action יוצר issue-ילד משויך-ל-CEO תחת ה-issue הראשי
ומעיר על הילד — בדיוק כדפוס wake_analyst_for_argument_aggregation שעובד.
ה-CEO בעל הילד → הריצה לא מבוטלת. ההוראה מוכלת בתיאור-הילד (הרץ את הכלי,
comment, סגור done) ולא תלויה בפענוח payload.action. legal-ceo.md מעודכן
שההערה מגיעה על issue-ילד ייעודי שיש לסגור בסיום.

מגע-Paperclip רק בשער-הפלטפורמה (G12). זהו החצי הראשון (ב) של #227; חצי
שני (ג) = סימון-סטטוס ב-UI. py_compile ירוק.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:16:46 +00:00
17c23ab377 Merge pull request 'refactor(positions): קיפול "עמדות וטענות", איזון שורה תחתונה, שם-כרטיס מדויק (#226)' (#397) from worktree-positions-collapse-balance into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-07-06 09:35:44 +00:00
972935ceb5 refactor(positions): קיפול "עמדות וטענות", איזון שורה תחתונה, שם-כרטיס מדויק (#226)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
לפי הנחיות חיים על טאב "טיעונים ועמדות":
- "עמדות וטענות" נעטף באקורדיון single/collapsible סגור-כברירת-מחדל, כמו
  "טיעונים מאוגדים לפי צד" ו"מה קרה בדיון" — הטאב נפתח כ-3 פסים מקופלים.
  התיאור עבר לטריגר; הוסר ה-lead הכפול מהרכיב.
- שורה תחתונה מאוזנת: "פסיקה מצורפת" ו"ייצוא ועדכון הניתוח" באותו גובה
  (items-stretch + h-full על שני הכרטיסים).
- כותרת הכרטיס "השלמה והעברה" → "ייצוא ועדכון הניתוח" (שם מדויק — הכרטיס
  מייצא/מעדכן/מוריד את analysis-and-research.md, לא "משלים ומעביר").
- הוסרו שתי שורות-הסטטוס "הרץ למידת-קול" + "הרץ אימות-הלכות" (post-final;
  קיימות בטאב "טיוטות והערות" — הסרת-כפילות G2). נוקו helpers+import לא-משומשים.

עיצוב: מוקאפ 28-case-positions-collapsed-balanced אושר ע"י חיים דרך שער Claude Design (X17).
tsc + eslint ירוקים. תואם INV-IA1 (דפוס-אח מאושר).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 09:35:12 +00:00
538d80f6ec Merge pull request 'fix(hearing): פאנל "מה קרה בדיון" סגור כברירת-מחדל (#226)' (#396) from worktree-hearing-panel-collapsed into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m19s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-06 09:13:59 +00:00
fdb0c98650 fix(hearing): פאנל "מה קרה בדיון" סגור כברירת-מחדל (#226)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 32s
Lint — undefined names / undefined-names (pull_request) Successful in 20s
עוטף את HearingChangesPanel באקורדיון single/collapsible סגור-כברירת-מחדל,
זהה לפאנל-האח "טיעונים מאוגדים לפי צד" מעליו (הנחיית חיים). הכותרת+תיאור
עוברים לטריגר-האקורדיון; מוסרת הכותרת הכפולה מהרכיב (כפתור-ההרצה נשמר,
מיושר לקצה בתצוגה המלאה). tsc + eslint ירוקים.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 09:13:13 +00:00
0d0631fea5 Merge pull request 'feat(hearing): פאנל "מה קרה בדיון" — חשיפת ניתוח-הפרוטוקול ב-UI (#226)' (#395) from worktree-hearing-changes-panel into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m37s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-05 12:17:56 +00:00
ab5d34a9d3 feat(hearing): פאנל "מה קרה בדיון" — חשיפת ניתוח-הפרוטוקול ב-UI (#226)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 59s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
תוצר analyze_protocol (איך הטענות הכתובות התפתחו בדיון בעל-פה) היה שכבת
ידע-תיק ללא endpoint ותצוגה — נצרך רק downstream ע"י הכותב בבלוק-י. פאנל חדש
בתחתית טאב "טיעונים ועמדות", מתחת לכרטיס-הטיעונים (INV-IA1: לא טאב-מקביל).

Backend:
- SCHEMA_V51: cases.hearing_attendees JSONB — בית קנוני לנוכחות-בדיון
  (panel_members/appellants_present/respondents_present). נבדל סמנטית
  מ-decisions.panel_members (מותב חותם-ההחלטה) → אין מסלול-מקביל (G2).
- protocol_analyzer._extract_header: מאכלס hearing_attendees; refresh על re-run
  (מתקן #223), לא דורס עם חילוץ ריק. אישור-משתמש: "אפשרות א" (persist+הצגה).
- GET /api/cases/{n}/protocol-analysis — header (תאריך+נוכחים קנוניים) + verdicts
  מקובצים לפי צד. קריאה-בלבד, container-safe, ריק≠שגיאה.
- POST /api/cases/{n}/analyze-protocol — מעיר את המנתח (analyze_protocol מריץ
  claude מקומי, נעדר בקונטיינר) כתבנית aggregate-arguments.
- מגע-Paperclip רק דרך agent_platform_port (G12): wake_analyst_for_protocol_analysis.

Frontend:
- lib/api/protocol-analysis.ts (hooks read+trigger) + HearingChangesPanel
  (סרגל-כותרת, פסי-סינון התחזק/נטען-לראשונה, כרטיסים לפי צד, ציטוט-ראיה).
- חיווט לטאב arguments מתחת ל-LegalArgumentsPanel.

עיצוב: מוקאפ 27-case-hearing-changes-panel אושר ע"י חיים דרך שער Claude Design (X17).
Invariants: מקיים G2 (בית קנוני יחיד, לא מסלול-מקביל), G1 (נרמול-במקור),
G12 (שער-הפלטפורמה), INV-IA1 (לא טאב-מקביל), INV-AH (evidence_quote כבר נאכף במקור).
אימות: py_compile + tsc --noEmit + eslint ירוקים; runtime של ה-endpoint = post-deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 12:16:35 +00:00
6414e5f94a Merge pull request 'fix(protocol): בחירת פרוטוקול ועדת-ערר לפי protocol_scope + document_id (#223)' (#393) from worktree-protocol-scope-fix into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m13s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-05 10:00:34 +00:00
f4ce8332fe fix(protocol): בחירת פרוטוקול ועדת-ערר לפי protocol_scope + document_id (#223)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
analyze_protocol בחר את מסמך-הפרוטוקול הראשון (doc_type='protocol') והתעלם
מ-protocol_scope. בתיק עם כמה פרוטוקולים — נספח ועדה-מקומית + דיון ועדת-הערר —
הוא ניתח את הלא-נכון (התגלה ב-1043-02-26: ניתח "נספח 18 השתלשלות פרוטוקולים"
במקום "פרוטוקול דיון 23.6.26").

- _find_protocol מעדיף פרוטוקול ועדת-ערר (scope שאינו 'lower'); פרוטוקול
  'lower' (ועדה מקומית/מחוזית) לא נכנס להשוואה — הוא רקע לבלוק ו בלבד.
- כמה פרוטוקולי-ערר → האחרון (created_at) גובר; document_id מכוון במפורש.
- analyze_protocol (service + drafting tool + MCP tool) מקבל document_id/
  target_document_id אופציונלי.
- _protocol_scope: קורא metadata.protocol_scope (ריק=appeal, כמו ה-UI).
- 5 בדיקות חדשות: דילוג-lower, all-lower→None, most-recent, targeting, unknown-id.

טווח: Fix1 (קוד). ניקוי 1043 (תיוג נספח 18 כ-lower + מחיקת 11 רשומות שגויות
+ הרצה מכוונת ל-23.6.26) דורש טעינה-מחדש של ה-MCP — אחרי מיזוג. פרוצדורת
ניתוח-מסמך-נוסף הכללית — המשך ב-#223.

Invariants: G1 (נרמול-במקור — קריאת scope מ-metadata, לא ניחוש-בקריאה) ·
G2 (אותו נתיב ניתוח, בורר מדויק) · §6 (no_protocol מחזיר סטטוס מפורש, לא בליעה).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:59:30 +00:00
e5c7455284 Merge pull request 'feat(arguments): הפרדת-משיבים בצבירה לפי כתב-תשובה (#224)' (#392) from worktree-respondent-party-separation into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m11s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-05 09:53:27 +00:00
68c2be2a7b feat(arguments): הפרדת-משיבים בצבירה לפי כתב-תשובה (#224)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 11m48s
Lint — undefined names / undefined-names (pull_request) Successful in 12s
הצבירה קיבצה את כל המשיבים תחת מפלגה אחת "respondent", כך שכתבי-תשובה
נפרדים (משיבות 2-3 מול משיבים 4-6) עם עמדות שעלולות להיות מנוגדות נבלעו
לרשימה אחת. התגלה בתיק 1043-02-26.

השינוי מפצל את הצדדים הרב-משתתפים (respondent/permit_applicant) לפי
source_document — כל כתב-תשובה משותף = יחידת-ליטיגציה קוהרנטית נפרדת —
ומאחסן את התווית ב-legal_arguments.party_name (מיגרציה V50). עוררים/ועדה
נשארים קבוצה יחידה. בונוס: פיצול ה-129 טענות ל-קריאות-Claude קטנות מתקן
את הכשל-בשקט המקורי (קריאה של 100+ פרופוזיציות החזירה non-JSON והפילה את
כל הצד).

- db.py: SCHEMA_V50_SQL — legal_arguments.party_name (ADD COLUMN IF NOT EXISTS, אידמפוטנטי)
- argument_aggregator: קיבוץ (party, party_name); _build_prompt מקבל שם-כתב; SELECT מחזיר party_name
- UI: PartySection מתת-קבץ לפי כתב-תשובה; טיפוס LegalArgument.party_name

טווח v1: הקיבוץ לפי source_document; אכלוס party_name ב-extractor וזיהוי
עמדות-מנוגדות בבלוק ז/י — המשך ב-#224.

Invariants: G1 (נרמול-במקור — party_name בסכמה, לא תיקון-בקריאה) · G2 (אין
מסלול-צבירה מקביל, אותו pipeline) · §6 (אין בליעה שקטה — הכשל-בשקט תוקן).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:42:09 +00:00
acb887845c Merge pull request 'chore(case-ui): הסרת כפתור "פתח עורך החלטה" הכפול + regen api types' (#391) from worktree-remove-decision-editor-btn into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 44s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-05 07:19:44 +00:00
93cce0505e chore(case-ui): הסרת כפתור "פתח עורך החלטה" הכפול + regen api types
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
הכפתור בבאנר-התיק רק ביצע setTab("decision") — כפילות מדויקת של טאב
"ההחלטה" שכבר קיים ברצועת-הטאבים (עורך-ההחלטה אוחד לתוכה, /compose נמחק).
שריד שאיבד משמעות אחרי איחוד-המסכים; הוסר.

בנוסף: regen של src/lib/api/types.ts מול הסכמה החיה — קולט את שדות
DocumentPatchRequest (is_post_hearing/protocol_scope) שנוספו ב-#390.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 07:19:14 +00:00
cc48896a0e Merge pull request 'feat(case-ui): חלוקת רשימת-המסמכים בטאב-הסקירה ל-4 קבוצות' (#390) from worktree-docs-grouped-overview into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m45s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-07-05 06:54:16 +00:00
cc8af903b7 feat(case-ui): חלוקת רשימת-המסמכים בטאב-הסקירה ל-4 קבוצות
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 36s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
חלוקה פנימית (לנוחות בלבד, לא משנה doc_type) של רשימת מסמכי-התיק:
1. עיקריים (appeal/response/appraisal) · 2. נלווים · 3. פרוטוקול ועדת הערר ·
4. לאחר הדיון. הסיווג נגזר בצד-הלקוח מ-doc_type + שני דגלי-metadata.

שני פקדים חדשים בעורך-התיוג:
- מתג "התקבל אחרי הדיון" → metadata.is_post_hearing (כבר נצרך ע"י בלוק-ח);
  גובר על הסיווג-לפי-סוג ומעביר לקבוצה 4.
- בורר "פרוטוקול של: ועדת הערר / ועדה מקומית-מחוזית" → metadata.protocol_scope
  (נראה רק כשהסוג protocol); ברירת-מחדל appeal→קבוצה 3, lower→קבוצה 2.

Backend: PATCH /documents/{id} + MCP document_update מקבלים is_post_hearing
ו-protocol_scope, נשמרים ב-metadata JSONB (אותו מסלול כמו appraiser_side).

עיצוב אושר ב-Claude Design (X17): 18k-case-documents-grouped.html.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 06:52:17 +00:00
bc0a4bb895 Merge pull request 'fix(block-writer): רתימת חלון-1M של Opus 4.8 לבלוק-י (#216)' (#389) from worktree-blockyod-1m into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m34s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-30 20:40:48 +00:00
9d88255050 Merge pull request 'feat(case-ui): agents tab v2 + dismiss/reaper for stale interactions (#215)' (#388) from worktree-agents-tab-v2 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 42s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 20:38:16 +00:00
2b68d6cbeb fix(block-writer): רתימת חלון-1M של Opus 4.8 לבלוק-י (#216)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
הגבול _MAX_PROMPT_CHARS=400_000 היה שומר מלאכותי שתאם את קיר ה-200K-טוקן
של בניית-Opus הסטנדרטית (~400K תווים עברית @~2 תווים/טוקן) — לא גבול-מודל.
Opus 4.8 מציע חלון 1M-טוקן במחיר רגיל; `claude -p` חושף אותו ישירות כ-model-id
`claude-opus-4-8[1m]` (אומת CLI 2.1.196, בלי beta-header). בלוק-י (דיון), שנושא
את התיק המלא כ-source-context (805K/891K תווים), נחסם רק ע"י השומר המיושן.

שינויים:
- block_writer: GENERATION_MODEL_1M + סף אסקלציה _CTX_1M_THRESHOLD_CHARS=350_000.
  פרומפט > הסף → `[1m]`; אחרת הבנייה הסטנדרטית (בלוקים קטנים נשארים זולים/מהירים).
- _MAX_PROMPT_CHARS 400_000 → 1_500_000 (~750K טוקנים; מרווח מתחת ל-1M) כשומר-קשיח
  סופי גם על בניית-ה-1M; הודעת-השגיאה מפנה לצמצום-source-context.
- model_used (provenance) משקף את הבנייה בפועל (200K/[1m]).
- claude_session: אזהרת-גודל מעודכנת (350K + רמז ל-[1m]).
- ספ 04-analysis-writing §1.3: תיעוד האסקלציה כעידון של אותו pin (לא מסלול מקביל, G2).

Invariants: מקיים G2 (אותו מודל-נעוץ, חלון רחב — לא מסלול מקביל), G1, INV-WR*;
לא בולע שגיאות (שומר-קשיח סופי נשאר). xhigh לבלוק-י ללא שינוי.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:38:00 +00:00
581a4ba36f feat(case-ui): agents tab v2 + dismiss/reaper for stale interactions (#215)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
מוקאפ 18i v2 (מאושר ב-Claude Design) + תיקון-מקור להצטברות pending interactions.

UI (agent-activity-feed.tsx):
- רוסטר-סוכנים קומפקטי, כל הסוכנים בשורה אחת (grid-cols-9); הוסרה שורת
  "מה הסוכן עושה עכשיו" — היא שייכה תגובה לפי role-fallback, כך שסוכנים
  בעלי אותו תפקיד הציגו טקסט זהה.
- כל אירוע "ממתין לתשובתך" מקופל כאקורדיון (הראשון פתוח).
- כפתור "התעלם" לכל בקשה ממתינה — מבטל שאלה כפולה/מיושנת בלי להעיר את הסוכן.
- ציר-הזמן מקובץ לפי משימה (issue), כל קבוצה אקורדיון עם מזהה+סטטוס+מונה.

Backend (#215 — מניעת הצטברות pending):
- cancel_interaction: ביטול interaction בודד ישירות ל-cancelled, ללא
  wake_assignee (resolve רגיל היה מעיר את הסוכן). אין triggers על הטבלה.
- reap_stale_interactions: reaper שמבטל pending על issues סגורים
  (done/cancelled) — המקור הדומיננטי לערימה. רץ כל 15 דק' ב-lifespan.
- endpoint POST /api/cases/{case}/agents/interaction-dismiss (מאחורי כפתור
  "התעלם"). הכל דרך agent_platform_port (G12).

supersede-at-creation נדחה (תועד ב-#215): ביטול-אוטומטי של שאלה על issue
פעיל אחד על-פני אחר אינו דטרמיניסטי; הליבה הבטוחה = reaper-ליתומים +
כפתור-ביטול, והשורש האמיתי הוא ריסון לולאות-recovery של Paperclip.

Invariants: מקיים G12 (מגע-Paperclip רק דרך הפורט), G2 (אין מסלול מקביל —
מרחיב את endpoints הסוכנים הקיימים), §6 (אין בליעת-שגיאות שקטה — הביטול
מחזיר {ok,error}, ה-reaper מתעד warning).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:36:56 +00:00
88977b2f10 Merge pull request 'chore(web-ui): regen api types — document-generation endpoints (#214)' (#387) from worktree-regen-api-types-214 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-30 20:06:32 +00:00
40a2032e0d chore(web-ui): regen api types — document-generation endpoints (#214)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Regenerate web-ui/src/lib/api/types.ts from the PROD OpenAPI schema
(https://legal-ai.nautilus.marcusgroup.org/openapi.json) via `npm run api:types`.

Adds 3 drifted paths (pure additions, generated — not hand-edited):
- POST /api/cases/{case_number}/generate/party-claims-summary  (#214)
- POST /api/cases/{case_number}/generate/interim-draft          (#214)
- /api/status-model                                             (drift)

Invariants:
- X6 (UI<->API contract in sync) — generated types now match the deployed surface.
- G2 (single generated client) — openapi-typescript output only, no hand edits.

Verified: `npx tsc --noEmit` passes clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:05:31 +00:00
def823b89f Merge pull request 'feat(case-ui): כרטיס "הפקת מסמכים" עם 3 כפתורי-הפק דטרמיניסטיים (#214)' (#386) from worktree-agent-a72c8793960b71772 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 44s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 19:56:46 +00:00
3ea5d3b536 feat(case-ui): כרטיס "הפקת מסמכים" עם 3 כפתורי-הפק דטרמיניסטיים (#214)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
מוסיף כרטיס "הפקת מסמכים" בראש טאב "טיוטות והערות" (mockup 26 — תוספת ל-18h),
תוספת לעמוד הקיים, כל הסקשנים נשמרים.

- כפתור 1 "הפק סיכום מנהלים" → endpoint חדש POST /generate/party-claims-summary
  → CEO wakeup סטרוקטורלי action="party_claims_summary" (שלב H2, host-side).
  Polling: GET /research/party-claims-summary (200=מוכן → פתח/DOCX).
- כפתור 2 "הפק טיוטה של טענות הצדדים" → endpoint חדש POST /generate/interim-draft
  → CEO wakeup action="interim_draft" (שלב H, host-side).
  Polling: רשימת ה-exports עד שמופיע טיוטת-ביניים-{case}-vN.docx.
- כפתור 3 "הפק טיוטת החלטה מלאה" → ה-useExportDocx הקיים (in-container, ללא LLM);
  הועבר מהבאנר (כפתור "הפק DOCX" הוסר משם — אין כפילות). disabled+title כשאין
  בלוקים כתובים (isDraftReady).

מסלול-עירור יחיד דרך ה-Platform Port (pc_wake_ceo_for_action) → API helper
/api/agents/{id}/wakeup, לעולם לא DB insert. Israel-time + cache-invalidation.

Invariants: G12 (מגע-Paperclip רק ב-paperclip_client + agent_platform_port;
leak-guard ירוק) · G2 (אין מסלול-עירור מקביל — שימוש חוזר ב-pc_request הקיים) ·
G10 (הפקה מגודרת-יו"ר/CEO) · INV-UI9 (Asia/Jerusalem) · X6 (UI↔API contract).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:54:54 +00:00
425305844a Merge pull request 'docs(status): align agent status maps to the canonical (nested) status model' (#385) from worktree-status-docs-refresh into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 19:52:52 +00:00
5867f7175f docs(status): align agent status maps to the canonical (nested) status model
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Follow-up to the status SSoT (#382): analyst_verified + research_complete are
now first-class canonical statuses, so refresh the docs that still framed them
as legacy/decorative.

- HEARTBEAT.md §7: rewritten to point at the single source
  (case_status_model.py / GET /api/status-model), list the 12 canonical statuses
  by phase, and state the two intermediates are canonical (not legacy); genuinely
  old markers (proofread/analysis_enriched/…) are display-only fallbacks.
- legal-qa.md / legal-ceo.md status maps: dropped the "legacy/optional" framing
  on research_complete; point to the canonical model.
- scripts/backfill_case_status_trim.py: removed analyst_verified +
  research_complete from the downgrade map (they are valid statuses again — must
  NOT be downgraded) + updated the docstring.

Docs/prompts only — no behaviour change. py_compile clean.
NOTE: the host ~/legal-ai needs `git pull` for the Paperclip agents to pick up
the refreshed prompts (they run on the host tree, not the container).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:52:27 +00:00
c954dded66 Merge pull request 'fix(case-ui): drafts cards in mockup order — files → final → feedback (18h)' (#384) from worktree-drafts-card-order into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 42s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 19:41:21 +00:00
5cf1ff65f4 fix(case-ui): drafts cards in mockup order — files → final → feedback (18h)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
The 3 drafts cards rendered final-decision before draft-files; mockup 18h has
the draft-files card first, then final+learning, then feedback. Switched the
container to flex-col and gave each card an explicit `order` so the visual order
matches the mockup without reordering any JSX (zero-risk).

tsc --noEmit + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:41:03 +00:00
91bd3ac88c Merge pull request 'feat(case-ui): agents tab — full 18i (rich roster + surfaced pending forms)' (#383) from worktree-agents-full-18i into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 19:37:25 +00:00
bb83ddc611 feat(case-ui): agents tab — full 18i (rich roster + surfaced pending forms)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
Completes mockup 18i beyond the earlier minimal version:
- AgentRoster — a 5-up grid of agents, each with a status dot, name, role label
  and its current activity (derived from the agent's most recent comment).
  Replaces the simple AgentStatusWidget list at the top of the feed (reset still
  lives in the banner status-rail popover).
- "ממתין לתשובתך" now renders the actual pending InteractionCards (the
  approve/reject/answer forms) at the top, not just a count pointer — and those
  pending interactions are excluded from the timeline below so they aren't shown
  twice.

All interaction logic / mutations preserved. tsc --noEmit + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:37:01 +00:00
d1ca1152b1 Merge pull request 'feat(status): single source of truth for case-status model + canonicalize intermediates' (#382) from worktree-status-single-source into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m40s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-30 19:32:00 +00:00
44f6915f0c feat(status): single source of truth for the case-status model + canonicalize intermediates
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
Fixes the case where the H1 chip, the pipeline stepper and the manual-changer
dropdown disagreed (e.g. 1043-02-26 on analyst_verified): the status sat between
the canonical 10 and a legacy bucket, so each surface read a different list.

Root fix — ONE authoritative status model + canonicalize the real intermediate
states (chair-approved):

- New mcp-server/.../case_status_model.py — the single registry: ordered
  StatusDef list (key/label/description/phase/selectable/terminal/on_enter),
  the 5 phases, STATUS_ORDER, phase_of/label_of, and a drift assertion that the
  CaseStatus enum matches it. `on_enter` is the seam for a status to *do*
  something on entry, declared next to its definition (no dispatcher wired yet).
- models.CaseStatus enum: analyst_verified + research_complete promoted to
  first-class canonical statuses (the agents set them — they belong in the
  model, not a legacy fallback).
- tools/cases.py: the forward-only STATUS_ORDER guard now derives from the
  registry instead of an inline list.
- GET /api/status-model exposes the model so the frontend mirror can be
  generated against it.
- web-ui case-status.ts mirrors it: the two intermediates moved from the
  legacy map into CASE_STATUSES / PHASES(thinking) / STATUS_LABELS /
  STATUS_DESCRIPTIONS; status-badge icons+tones extended. So the chip (status),
  stepper (its phase) and dropdown (now includes it) all agree.

Invariants: G2 — collapses the scattered status definitions (enum, inline
STATUS_ORDER, two frontend label maps) onto one backend authority + a documented
frontend mirror; no parallel status list remains. Agent-prompt alignment +
backfill follow in a separate change. py_compile + tsc + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:31:21 +00:00
3b0d3e3b2e Merge pull request 'feat(ceo): wire party-claims executive summary as chair-requestable stage (#202 follow-up)' (#381) from worktree-agent-a7ef79d9409c5c6ca into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-30 19:23:58 +00:00
b560417dd7 feat(ceo): wire party-claims executive summary as chair-requestable stage (#202 follow-up)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
The summarize_party_claims tool (#202) generated a distilled executive
summary of party claims but was orphaned: not granted to any agent and
no CEO trigger. Wire it parallel to the interim-draft (שלב H).

- Grant mcp__legal-ai__summarize_party_claims in legal-ceo.md frontmatter.
- Add שלב H2 (סיכום מנהלים) mirroring שלב H: side-quest (no cases.status
  change, no sub-agent issues), in_progress → run tool → in_review + notify.
  Two triggers: chair comment ("סיכום מנהלים"/"סיכום טענות"/"סיכום לקראת
  דיון"/"executive summary") OR structured action
  $PAPERCLIP_WAKE_PAYLOAD_JSON action == "party_claims_summary".
- Add structured-action trigger to שלב H (interim draft):
  action == "interim_draft" — same deterministic UI-button path.
- שלב 0 + HEARTBEAT.md: route structured actions deterministically.
- Spec note in docs/spec/04-analysis-writing.md §1.4.

Invariants: G2 (reuse existing summarize_party_claims tool/path, no parallel
route), G10 (chair-requested side-quest). Agent-prompt/docs only — no app code.

Note: CEO prompt is shared across CMP+CMPA via instructionsFilePath; after
merge the host tree needs `git pull` (agents run host-side reading
.claude/agents/*.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:22:26 +00:00
0bf311041b Merge pull request 'feat(case-ui): drafts tab — 3 clear cards (X17 18h)' (#380) from worktree-drafts-three-cards into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 47s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 19:16:05 +00:00
148f303378 feat(case-ui): drafts tab — 3 clear cards (X17 18h)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Groups the drafts/comments tab into distinct cards instead of one long scroll:
- "פסיקה שצוטטה" (CitationsSection) moved inside the chair's signed-final card
  as its sub-section (was floating between final and the files table).
- "קבצי טיוטה" and "הערות יו״ר" sections each wrapped in a bordered surface card.

Result: draft-ready banner + active-draft badge at top, then three cards —
final-decision+learning (gold), draft files, chair feedback. All logic,
mutations, dialogs and the feedback list preserved verbatim.

tsc --noEmit + eslint clean. Design-gate: mockup 18h approved in Claude Design X17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:15:44 +00:00
e439fad1a9 Merge pull request 'feat(case-ui): agents roster + awaiting-you banner; Hebraize drafts table' (#379) from worktree-agents-drafts-redesign into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 44s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-30 19:07:15 +00:00
fa2fc9390c feat(case-ui): agents roster + awaiting-you banner; Hebraize drafts table
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
- Agents tab: render the AgentStatusWidget roster at the top of the activity
  feed ("who's doing what right now") and surface pending agent interactions in
  an amber "ממתין לתשובתך" banner above the timeline (mockup 18i key wins).
- Drafts tab: Hebraize the drafts-table headers File/Size/Date → קובץ/גודל/תאריך
  (mockup 18h).

All existing feed logic, mutations and interaction forms preserved.
tsc --noEmit + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:06:48 +00:00
331c712762 Merge pull request 'feat(case-ui): full-width tabs + parties popover + subject on title line' (#378) from worktree-header-tabs-tweaks into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 41s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 19:03:25 +00:00
130f7cff71 feat(case-ui): full-width tabs + parties popover + subject on title line
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Chair-requested refinements to the case banner:
- Tabs now span the full band width via justify-between — wide gaps with the
  current 6 tabs, tightening as more are added (gap-2 no-overlap floor).
- Parties moved out of the always-on header line into a "צדדים" Popover, so the
  band is shorter and the tab content sits higher on the page.
- Case subject (title) moved up onto the case-number line in a smaller font,
  instead of its own line below.

tsc --noEmit + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:03:05 +00:00
abff00faec Merge pull request 'feat(calibration): style-clean recommend_effort (anti-primary) + confidence + --rerank (#213)' (#377) from worktree-agent-ae80f93f2f0f100cb into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-30 19:02:09 +00:00
c7156b575e feat(calibration): style-clean recommend_effort (anti-primary) + confidence + --rerank (#213)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Make the effort-calibration recommendation style-clean (style acquisition is
the goal — write like chair Dafna Tamir):

- recommend_effort ranks by anti_pattern_total (PRIMARY, the clean style-rule
  violation count) → golden_ratio_deviation_pp (SECONDARY, structural-style,
  None=worst) → distance (faint final tiebreak). change_percent is EXCLUDED from
  the ranking (it mixes style with content-completeness per 07-learning §0.7 —
  noise for STYLE) and kept in the report as context only.
- Add a confidence flag ("clear"/"weak"): a pick whose anti-pattern lead over the
  runner-up is within noise (margin < epsilon = max(0.5, 0.20·spread)) is flagged
  ⚠️ weak, so an over-claimed pick (the old confident block-zayin "low") is never
  shown as confident. Rendered as a confidence column in the report.
- Add --rerank <report.json>: re-apply the new key to a saved calibration JSON
  offline (NO LLM, NO DB), print + write *-reranked.md. Re-ranks today's 8-final
  run without regenerating.
- recommend_effort stays a single shared pure function reused by
  scripts/calibrate_block_yod.py (G2); both --self-test suites pass.
- Spec note added (07-learning §0.7) + SCRIPTS.md updated.

Invariants: G8 (style-clean empirical ranking), G2 (single shared recommend_effort,
reused by the block-yod harness — no parallel metric path), INV-LRN5 (style-only
signal; change% reported-not-ranked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:00:41 +00:00
4285288ba7 Merge pull request 'feat(case-ui): redesign אימות-פסיקה tab + overview proportions (X17 #4)' (#376) from worktree-case-tab-redesigns into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 46s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 18:59:44 +00:00
39aff8fd45 feat(case-ui): overview body — wider documents column (X17 18j)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:59:17 +00:00
72f9bbb77c feat(case-ui): redesign אימות-פסיקה — progress + filters + accordion (X17 18g)
Summary with a verification progress bar, status filter chips + only-unverified
toggle, per-argument collapsible accordion with a v/t verify-progress chip, and
compact precedent rows (clamped/expandable quote, collapsible chair note). All
verify/saveNote mutations + types preserved verbatim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:57:58 +00:00
b17a2e4997 Merge pull request 'feat(case-ui): merge /compose editor into case tabs (X17 #3)' (#375) from worktree-compose-merge into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 42s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 18:52:38 +00:00
4d7e623607 feat(case-ui): merge /compose editor into case tabs (X17 #3)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Folds the standalone /compose decision-editor route into the main case page so
there is no more "sub-page" (mockup 18f), with the final 6-tab workflow order:
סקירה · טיעונים ועמדות · אימות פסיקה · ההחלטה · טיוטות והערות · סוכנים.

- New PositionsPanel — the chair's editing surface (threshold claims + issues
  SubsectionCards + ChairEditor + background prose + case precedents + finish
  rail), extracted verbatim from the former /compose "עמדות וטענות" tab. Drops
  only the doc-strip (the overview DocumentsPanel is the sole owner — no dup).
- "טיעונים ועמדות" tab = PositionsPanel above + the aggregated by-party
  LegalArgumentsPanel in a collapsible below.
- "אימות פסיקה" promoted to its own top-level tab (CitationVerificationPanel,
  was /compose tab 3); placed BEFORE "ההחלטה" — verify citations, then write
  (INV-AH: the writer cites only verified precedents).
- "ההחלטה" stays the single 12-block editor (DecisionBlocksPanel) — no longer
  duplicated between /compose and the case page.
- Tabs are now controlled; the "פתח עורך החלטה" CTA switches to the ההחלטה tab
  instead of navigating to the deleted route.
- /compose route deleted; header-context /compose special-case removed.

No duplication remains (G2): blocks editor single, docs single (overview),
citation-verify single. Design-gate: mockup 18f approved in Claude Design X17.
tsc --noEmit + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:52:07 +00:00
7fd94cdd30 Merge pull request 'feat(eval): כיול-effort ייעודי לבלוק י' (דיון) עם אספקת-כיוון read-only (#212)' (#374) from worktree-agent-a9878f5b0b82c055b into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 18:51:52 +00:00
858e05fb8b feat(eval): block-yod effort calibration harness with read-only direction supply (#212)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
בלוק י' (דיון והכרעה) הוא הבלוק החשוב ביותר אך ה-effort שלו (xhigh) נקבע
'by feel' ולא כויל אמפירית. ה-harness הכללי (#208) לא יכל לכייל אותו כי
write_block(block-yod) זורק "ללא כיוון מאושר" — בלוק-הדיון דורש
decisions.direction_doc.approved שתיקי-הכיול חסרים.

scripts/calibrate_block_yod.py מוסיף שכבת אספקת-כיוון read-only מעל מכונת
המדידה של #208:
- overlay על db.get_decision_by_case/get_decision לתהליך-הזה-בלבד מזרים
  direction_doc שמיש ל-write_block — אפס כתיבה ל-DB, מצב-תיק-הייצור לא
  משתנה (reverted ב-__exit__).
- אסטרטגיה per-תיק: stored (כיוון-מאושר שמור, נאמן) → reconstructed
  (מינימלי מ-decision.outcome + סקשן-הדיון של הסופי באותו תיק, INV-LRN5:
  אפס מהות חוצת-תיקים, מתויג reconstructed) → skip.
- משתמש-חוזר ב-style_distance.block_distance_to_final + _score_cell/
  recommend_effort מ-calibrate_effort (G2 — אין מסלול-מדידה מקביל).
- כתיבה אינקרמנטלית per-תיק + שער-per-תא לא-פטאלי (INV-G8).

ממצא-קורפוס: מ-8 סופיים 7 ניתנים-לכיול; רק 8126-03-25 stored+scorable
(8174-12-24 שמור-אך-חסר-סקשן-דיון) → הנסיגה reconstructed היא מה שמפעיל
את ה-harness היום.

Invariants: G8 (eval-harness), G2 (שימוש-חוזר ב-style_distance/block_writer,
אין fork), INV-LRN5 (כיוון משוחזר = סגנון/שיטה בלבד, אפס מהות חוצת-תיקים),
claude_session local-only (ייצור דרך claude -p, מקומי-בלבד).

אימות: --self-test ALL PASS (28 checks: מדידה + אספקת-כיוון + overlay
install/revert) · --dry-run מול DB חי (read-only): 7/8 scorable, 28
ייצורים מתוכננים · --allow-reconstructed false → 1 (נאמן בלבד) ·
--case מסנן. אפס mutation calls ב-harness (read-only מאומת).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:50:01 +00:00
ff07e0bc98 Merge pull request 'feat(case-ui): banner-A status rail + V2 segmented tabs (X17 #2)' (#373) from worktree-case-banner-v2 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 47s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 18:46:34 +00:00
3f253269f6 feat(case-ui): banner-A status rail + V2 segmented tabs (X17 #2)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Implements the approved X17 case-detail redesign foundation (mockups 18c "A" +
18e "V2"):

- New StatusRail component — the integrated status strip (current-phase chip,
  expected-outcome semantic tag, hearing/updated/sync meta inline, agents
  Popover, manual-changer Popover) over the 5-phase pipeline stepper. Lives
  INSIDE the parchment band (CaseHeader), so status is now visible on EVERY tab
  (supersedes the overview-only StatusHero / reverses the prior INV-IA1
  overview-only decision, deliberately).
- CaseHeader absorbs the rail and drops its separate hearing/updated/sync dl
  (those moved into the rail — no duplication).
- Tabs restyled to the V2 "segmented" look (each tab a pill, active = raised
  white card) with per-tab icons.
- Overview tab body is now just documents + agent-activity preview (status left
  the body).
- StatusHero deleted — its content moved wholesale into StatusRail, so no
  parallel status surface remains (G2).

Reuses the shared StatusChanger / StatusGuide / AgentStatusWidget verbatim
(G2). Design-gate: mockups 18c + 18e approved in Claude Design X17.
tsc --noEmit + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:46:04 +00:00
38d73c4f89 Merge pull request 'feat(blocks): block-vav effort medium→low (empirical calibration #208)' (#372) from worktree-agent-a26909c71a51a387d into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 18:41:46 +00:00
88b7dd1004 feat(blocks): block-vav effort medium→low (empirical calibration #208)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Set block-vav (בלוק ו', רקע/background) generation effort from medium to
low in BLOCK_CONFIG. The #208 empirical effort calibration
(scripts/calibrate_effort.py, report
data/eval/effort-calibration-20260630T155054Z.md, n=7 finals) found that
for block-vav, low effort lands closest to chair Dafna Tamir's finalized
text — winning on BOTH the composite distance metric (low 0.4454 vs
medium 0.4749 vs high 0.5381 vs xhigh 0.5413) AND the cleaner style
signal anti_pattern_total (low 1.57 vs medium 2.29 vs high 4.14). Chair
(chaim) approved adopting low for block-vav.

This is the only block with a clean/confident calibration signal —
block-zayin and block-yod-alef signals were ambiguous/tied and are left
unchanged. No other block effort is touched.

Invariants: G8 (effort empirically calibrated, not by-feel);
claude_session-local-only (block_writer runs via the local MCP/claude_local
generation path). Host-side generation parameter — after merge the host
tree needs `git pull` to take effect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:40:26 +00:00
b79c7bf161 Merge pull request 'fix(status): render pipeline phase for legacy statuses (analyst_verified)' (#371) from worktree-status-legacy-phase-fix into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 42s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 18:31:57 +00:00
7c48c8753e fix(status): render pipeline phase for legacy statuses (analyst_verified)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
A case parked on a status the trimmed PHASES list dropped — but the agents
still set, e.g. `analyst_verified` (HEARTBEAT.md), `research_complete`
(legal-researcher) — rendered with NO active phase: phaseIndexOf returned -1,
so the pipeline stepper went fully blank and the chair couldn't see where the
case stood (reported on 1043-02-26, currently at analyst_verified).

Add LEGACY_STATUS_PHASE in case-status.ts mapping each legacy/intermediate
status to its display phase (analyst_verified/research_complete → "ניתוח וכיוון"),
and a single phaseIndexOf() exported from the SSoT. status-hero.tsx and
workflow-timeline.tsx both dropped their identical local phaseIndexOf (which had
the bug) and import the shared one — no parallel path (G2).

Display-only data-mapping fix; the stepper design is unchanged. Does NOT address
the root drift (agents still set analyst_verified) — that prompt-alignment is a
separate, carefully-sequenced follow-up needing cross-company agent sync.

Invariants: G2 (single phase-resolution path, legacy handled in the SSoT, no
new parallel logic). No backend / status-model change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:31:17 +00:00
5987d50ebf Merge pull request 'feat(agents): שטן-מליץ — שער-קבע אוטומטי אחרי הניתוח עם עצירת-אישור לידים ליו"ר (#211)' (#370) from worktree-agent-a5ebed3e62b0c790c into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 18:11:13 +00:00
9836957848 feat(agents): שטן-מליץ — שער-קבע אוטומטי אחרי הניתוח עם עצירת-אישור לידים ליו"ר (#211)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
שינוי-מדיניות יו"ר: ה-red-team (gemini_local) עובר מ-on-demand ל-standing gate
שרץ אוטומטית אחרי שלב-הניתוח ולפני הכותב — אבל הלידים נעצרים לאישור-יו"ר ולעולם
אינם מוזנים לכותב אוטומטית. רק לידים שהיו"ר מאשר → chair_directions (דרך
record_chair_feedback → get_chair_directions → approve_direction). הכותב צורך רק
פלט-מנתח מעוגן + הנחיות-מאושרות.

- legal-ceo.md: שורת-הטבלה + סעיף שטן-מליץ (on-demand→שער-קבע) + שלב A2 (red-team
  אוטומטי) + שלב A3 (עצירת-אישור לידים, in_review + notify) מוזרקים אחרי שלב A
  ולפני שלב B; מפת-הסטטוסים (documents_ready/analyst_verified/research_complete)
  מנתבת דרך השער.
- legal-analyst-gemini-critique.md: trigger=standing gate (לא on-demand); הפלט
  נשאר לידים לא-סמכותיים תחת אישור-יו"ר.
- HEARTBEAT.md §7: שער שטן-מליץ בזרימת-הסטטוס.
- docs/spec/X4-agents.md: INV-AG4 + הערה במפת-הסוכנים.
- docs/spec/04-analysis-writing.md §1.6: שער ה-red-team בין ניתוח לכתיבה.

Invariants: G10 (שער אנושי קשיח), INV-AH/INV-LRN5 (לידים לא-סמכותיים, לא
מוזנים אוטומטית), G12 (שינוי מוגבל למשטח agent-prompt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:08:50 +00:00
850ca25239 Merge pull request 'perf(citation-verification): cache view + parallelize backend retrieval' (#369) from worktree-citation-verify-perf into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m33s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 17:36:37 +00:00
88ca96bf19 perf(citation-verification): cache view + parallelize backend retrieval
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
The "אימות פסיקה" tab reloaded its data on every tab revisit and the first
load took 1.5–3s. Two independent causes, two fixes:

Frontend (web-ui/src/lib/api/citation-verification.ts):
- staleTime 10s → 5min and refetchOnMount: false. Radix Tabs unmounts inactive
  TabsContent, so switching back used to trigger a full rebuild once the 10s
  window elapsed. Verify mutations still invalidate the query, so edits pull
  fresh data.

Backend (mcp-server/.../case_citation_verification.py):
- The per-argument loop ran a Voyage embed + vector search + batched authority
  query sequentially (N args × ~300ms waterfall). Extracted to _fetch() and
  fanned out with asyncio.gather — order preserved, n_verified counter and
  per-argument seen-sets unchanged. First load ~5× faster on multi-argument cases.

Pure read/assembly — no new parallel path (G2), no silent error swallowing
(search/radar still log warnings). Invariants: INV-DIG1/INV-AH unaffected
(still read-only, never cites).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:35:52 +00:00
b2d673fdb0 Merge pull request 'fix(tz): timezone-aware datetimes — unblock TZ=Asia/Jerusalem (#210)' (#368) from worktree-agent-a29624b2a56f70804 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 9s
2026-06-30 17:25:55 +00:00
9402c7302f fix(tz): timezone-aware datetimes — unblock TZ=Asia/Jerusalem (#210)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Backend follow-up to INV-UI9 (X6 §"אודיט-נלווה"): make server-side
datetimes explicit so flipping the container to TZ=Asia/Jerusalem cannot
silently skew stored or displayed timestamps. Storage stays UTC; human
display is Israel time.

- audit.py: naive datetime.utcnow() → datetime.now(timezone.utc) for the
  TIMESTAMPTZ audit_log.created_at. Under TZ=Asia/Jerusalem the naive UTC
  wall-clock would be read as Israel-local and stored ~3h off. (§6: no
  silent skew.)
- calibrate_effort.py: _ts() report stamp now Israel-time via
  ZoneInfo("Asia/Jerusalem") with a filename-safe "-IL" suffix (script runs
  on the UTC host); added a labeled "שעון ישראל" line to the report header
  so the chair-facing timestamp is unambiguous. --self-test still ALL PASS.
- research_md.py: display fields made deterministic Israel-tz —
  update_chair_position timestamp (datetime.now(IL_TZ)) and parse() mtime
  (datetime.fromtimestamp(..., tz=IL_TZ)) — correct regardless of process TZ.

Left intentionally Israel-local-day date.today()/year-cutoff call sites
untouched (drafting/party_claims_summary/block_writer/court_fetch_supreme)
— a Jerusalem committee wants the Israel calendar day; they resolve
correctly once TZ flips.

Invariants: INV-UI9 (store-UTC / display-Israel, server side); §6
(timezone-aware datetimes, no silent ~3h skew).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:24:17 +00:00
d3d99180ec Merge pull request 'fix(web-ui): תצוגת זמנים אנושית דטרמיניסטית ב-Asia/Jerusalem (אחסון נשאר UTC) — INV-UI9 (#210)' (#367) from worktree-agent-a441570a4556d14e5 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 46s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-30 17:20:35 +00:00
67c2c43777 fix(web-ui): display all human-facing timestamps in Asia/Jerusalem (deterministic)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Storage stays UTC (DB TIMESTAMPTZ, API ISO-UTC) — only the display layer is
localized, and now deterministically: every timestamp renders pinned to
Asia/Jerusalem via a single Intl-based formatter, so SSR (UTC container) and
the browser agree on any runtime. No layout/visible-format change — only the tz.

- New single date formatter web-ui/src/lib/format-date.ts (G2): formatDate /
  formatDateShort / formatDateLong / formatDateTime / formatDateTimeFull /
  formatTime / formatIsoDate + Israel helpers getIsraelYear / israelDayKey /
  israelMidnightMs / israelParts + formatRelative (long/short/tight wording).
- Routed all ad-hoc toLocaleDateString/toLocaleTimeString/toLocaleString +
  hand-rolled new Date(...).get*() / toISOString().slice(0,10) timestamp call
  sites (30 files) through it. Number toLocaleString left untouched.
- Spec: added INV-UI9 to docs/spec/X6 (UTC storage, Asia/Jerusalem display).

Display-only; no layout/design/IA change → Claude Design gate N/A.
Invariants: G2 (single date formatter, no parallel ad-hoc formatting), INV-UI9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:18:09 +00:00
c71bbc0df2 Merge pull request 'feat(export): INV-EX4 מקור-תבנית-יחיד + תיקון יישור מסלול-העריכה' (#366) from worktree-inv-ex4-template into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m33s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 17:08:17 +00:00
a8dd8ee152 feat(export): INV-EX4 single-template source + fix edit-path justification
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
מקור-תבנית-יחיד לכל מפיקי-מסמכי-ההחלטה: כולם מחילים סגנונות דרך
skills/docx/decision_template.docx בלבד; אסור hand-setting של פונט/גודל.

- docs/spec/06-export.md — INV-EX4 חדש (מקור-תבנית-יחיד, →G2)
- docx_reviser: מסלול-העריכה (revise_draft/apply_user_edit) הכניס פסקאות
  עם jc=right קשיח + David/sz ידני → איבד יישור דו-צדדי של התבנית.
  עכשיו מחיל pStyle מן-הטמפלט (Normal/Heading 2/Quote) + jc=both קנוני,
  בלי rFonts/sz ידני (הגופן/גודל מגיעים מהסגנון).
- שומר-CI test_docx_template_single_source — נכשל על בניית rFonts/sz
  במפיקים מחוץ לטמפלט (חריג מסומן # INV-EX4-ok ל-_mark_run_rtl,
  שמחזק את גופן-התבנית David כעקיפת באג-RTL של Word).
- אומת: build_party_claims_summary_docx (סיכום-מנהלים, WS3) + export_decision
  (סופי/טיוטת-ביניים) כבר תבניתיים ותואמי-INV-EX4.
- ארכוב scripts/exec_summary_1043.py (one-off; הוחלף ב-build_party_claims_summary_docx).
- עדכון 2 טסטים שאימתו את ההתנהגות הישנה (rFonts על run מוכנס).

Invariants: מקיים INV-EX4 (חדש), INV-G2 (מקור-יחיד, אין מסלול-סגנון מקביל),
INV-EX1 (DOCX נגזר). נוגע ב-docx_reviser/docx_exporter/analysis_docx_exporter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:07:21 +00:00
73d2f15915 Merge pull request 'fix(calibration): drop block-yod + per-cell guard + incremental write (#208)' (#365) from worktree-agent-aeea23e1387d8d3cb into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-30 15:50:37 +00:00
70a06c3745 fix(calibration): drop block-yod + per-cell guard + incremental write (#208)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
A live calibration run crashed with no error handling and lost ~2h of work:
the per-cell loop in _run() reached block-yod (discussion), block_writer.write_block
raised ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר…") because the discussion
block needs an approved direction calibration cases don't have, and since the report
was only written at the very end of main(), every completed generation was lost.

Three robustness fixes (no metric/scoring change — G2):

1. Drop block-yod from default CALIBRATABLE_BLOCKS — out of WS5 interim-draft scope
   AND structurally non-calibratable standalone (needs brainstorm→approve_direction).
   Kept in new VALID_BLOCKS so a user can still force it via --blocks block-yod.
2. Per-cell try/except in _run(): any single (case, block, effort) cell failure is
   logged as a warning (case/block/effort/error) and skipped, never fatal —
   defense-in-depth for a forced block-yod and for transient rate-limits mid-run.
3. Incremental persistence: _write_report flushes after each completed block (ts
   threaded into _run), so a crash later in the grid never loses finished blocks.
   JSON/MD shape unchanged — partial results omit not-yet-done blocks from by_block.

--self-test: ALL PASS (14/14). block-yod gone from --dry-run defaults.

Invariants — נוגע / מקיים:
- G8 (eval-harness robustness) — partial results persist; a single-cell failure is
  non-fatal; the harness survives block-yod's missing-direction raise and rate-limits.
- G2 (מקור-מדידה יחיד) — no metric-path change; _score_cell / block_distance_to_final
  / recommend_effort / aggregate_cell untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:49:15 +00:00
c6d20654a9 Merge pull request 'chore(web-ui): regen api types — party-claims-summary endpoints (#202)' (#364) from worktree-agent-ae0bc7fc1516f1f34 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 12:52:26 +00:00
c84417369d chore(web-ui): regen api types — party-claims-summary endpoints (#202)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Regenerate src/lib/api/types.ts from the live production OpenAPI schema
after PR #358 (#202) added the read-only summarize_party_claims endpoints:

- GET /api/cases/{case_number}/research/party-claims-summary (read)
- GET /api/cases/{case_number}/research/party-claims-summary/download
- GET /api/cases/{case_number}/research/party-claims-summary/export-docx

The regen also picks up other endpoints that had drifted out of sync
(citation-verification, agents/reset, digest-radar, plans/fetch,
style-distance-history, scripts/{name}/run, canonical-halachot instances),
bringing the single generated client back in line with production.

Invariants: X6 (UI<->API contract kept in sync via the generated client),
G2 (single auto-generated client, no hand-edited parallel types).

tsc --noEmit passes clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:51:16 +00:00
d9783b1c14 Merge pull request 'feat(ui): WS6 — אקורדיון-צד בטיעונים, compose רוחב-מלא, status-hero בסקירה (#206)' (#363) from worktree-agent-a31f83020f84e89bf into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 44s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-30 12:24:01 +00:00
c68f3e004e Merge pull request 'docs(spec): WS7 — יישור 01/02/03/04/06 + gap-audit; ווידוא סגירת 7 דליפות עיצוב-מחדש (#207)' (#359) from worktree-agent-a3dd49d429baa5308 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 12:22:38 +00:00
f1e9103723 feat(ui): WS6 — party accordion, full-width compose, status-hero overview (#206)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 9s
שלושת שינויי-ה-UI של WS6 לפי המוקאפים המאושרים (25b/03b/18b, X17):

1. טאב "טיעונים" (legal-arguments-panel) — אקורדיון ברמת-הצד, סגור
   כברירת-מחדל (type="multiple" ללא defaultValue). כל trigger מציג
   שם-צד + תת-כותרת + באדג'-מונה. בתוך כל צד נשמר הקיבוץ-לפי-קדימות
   והאקורדיון הפנימי ברמת-הטיעון.

2. טאב "עמדות וטענות" (compose) — רוחב-דף מלא: בוטל ה-grid 1fr/320px,
   ראיל "מסמכי התיק" הוסר (DocumentsPanel בסקירה הוא הבעלים היחיד)
   ועבר לפס-מסמכים מתקפל למעלה (סגור כברירת-מחדל). כרטיסי-הסוגיות
   2-up. פסיקה-מצורפת ושערי-הייצוא/העלאה שומרו (relocate, לא remove).
   מפת-StatusChip המקבילה הוסרה → StatusBadge המשותף (G2).

3. סטטוס רק בסקירה (overview) — StatusHero חדש (stepper אופקי + chip
   שלב-נוכחי + שינוי-סטטוס ידני + meta-strip), הבעלים היחיד של
   הסטטוס. WorkflowTimeline/StatusChanger/AgentStatusWidget ירדו משאר
   הטאבים; הטאבים האחרים רוחב-מלא. StatusBadge read-only נשאר ב-H1.

Invariants: INV-IA1 (בעלים-יחיד לסטטוס=hero), INV-IA3/G10 (כל שער-אנוש
נשמר במקום אחד — שינוי-סטטוס בסקירה, ייצוא/העלאה ב-compose),
INV-IA4 (compose=מרחב-עבודה ממוקד-משימה רוחב-מלא), INV-IA6 (מפת-סטטוסים
progressive-disclosure), INV-UI7/UI8, G2 (StatusBadge משותף, אין
מפת-סטטוס מקבילה).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:22:07 +00:00
6f60dc7a14 Merge remote-tracking branch 'origin/main' into rebase-359
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
# Conflicts:
#	docs/spec/04-analysis-writing.md
2026-06-30 12:20:57 +00:00
4feac756ba Merge pull request 'feat(analysis): דגל "לא-נותח" + ניתוח-מחדש מאחד + בדיקת-השפעה (#201)' (#361) from worktree-agent-abe485b6eb373903f into main
Some checks failed
Build & Deploy / build-and-deploy (push) Successful in 1m34s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Has been cancelled
2026-06-30 12:20:39 +00:00
7655acddc3 Merge pull request 'fix(ceo): סריקת הערות-יו"ר תלויות company-wide עם שיוך-לתיק חובה (#164)' (#362) from worktree-ceo-pending-comment-scan into main
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
G12 Leak-Guard / leak-guard (push) Has been cancelled
Lint — undefined names / undefined-names (push) Has been cancelled
2026-06-30 12:19:43 +00:00
dfb2ffe7ce feat(analysis): דגל "לא-נותח" + ניתוח-מחדש מאחד + בדיקת-השפעה (#201)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
WS2b של עיצוב-מחדש זרימת-העבודה. מאפשר ניתוח כתב-הערר לבד ואז איחוד
מסמך-עיקרי שנוסף מאוחר, בלי force-delete גורף, עם diff ליו"ר.

- SCHEMA_V49: documents.claims_extracted_at + claims_extraction_status
  (אירוע-חילוץ per-מסמך, לא נגזר מ-doc_type), + אינדקס חלקי
  idx_documents_claims_pending. idempotent (ADD COLUMN IF NOT EXISTS).
  V48 שמור ל-#357.
- claims_extractor חותם את המסמך אחרי שמירת/אי-מציאת טענות.
- db.primary_docs_not_analyzed (is_primary V47 + claims_extracted_at IS NULL)
  + mark_document_claims_extracted; _row_to_doc חושף claims_analyzed.
- reanalyze_claims (כלי-MCP): snapshot→חילוץ-מאחד רק למסמכים חדשים/לא-נותחו
  (store_claims מחליף per-source ⇒ טענות אחרות נשמרות)→aggregate force=True
  (מסלול קנוני, מוחק רק legal_arguments)→_impact_diff before↔after ליו"ר.
- workflow_status חושף primary_docs_not_analyzed + next-step.
- ספ: 02-data-model §2ג (דגל לא-נותח), 04-analysis-writing §1.3.
- 5 בדיקות-יחידה ל-_impact_diff/_snapshot (פונקציות טהורות).

Invariants:
- G1 (נרמול-במקור): claims_extracted_at נחתם בנקודת-החילוץ, claims_analyzed
  נגזר ממנו — לא תיקון-בקריאה.
- G2 (מסלול קנוני יחיד / merge-not-fork): reanalyze מרחיב את מסלול
  claims_extractor+argument_aggregator הקיים, לא forks; האיחוד דרך
  store_claims per-source; aggregate force=True מוחק רק נגזר (legal_arguments).
- INV-DM (מודל-נתונים): דגל-אירוע per-מסמך, אינדקס חלקי, מקור-אמת יחיד.
- G10 (שער-אנושי): בדיקת-ההשפעה מוצגת ליו"ר, לא מוחלת אוטומטית.
- INV-TOOL idempotency: SCHEMA_V49 idempotent; ניתוח-מחדש חוזר על מסמך
  כבר-נותח הוא refresh נקי (store_methods replace per-source).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:19:28 +00:00
26451f7c9d fix(ceo): company-wide pending-comment scan with mandatory case binding
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Root cause (proven 2026-06-30, legal-ai #164): the comment-sweep wakes the CEO
via ctx.agents.invoke, which does NOT populate $PAPERCLIP_WAKE_REASON or
payload.issueId. The run transcript showed "generic heartbeat with no scoped
wake" — the CEO's `user_commented` branch never fired, it ran a generic
heartbeat that scans only CEO-assigned issues, and chair comments on
chaim/sub-agent-owned issues (e.g. CMPA-116) were invisible → no-op. Two real
6-day-stale chair requests sat undelivered.

Fix: on any wake without a scoped reason, the CEO must DISCOVER pending chair
comments itself. Adds "סריקת הערות-יו"ר תלויות (company-wide)":
- enumerate all active company issues (/api/companies/{id}/issues)
- pending = newest user comment newer than newest agent comment (ignore
  system); order-independent sort_by(createdAt) since the API is newest-first
- MANDATORY case binding from the STRUCTURED project.name ("ערר <case> — …"),
  never from the title (CMPA-116 has none) nor the comment content (which may
  name a different case, e.g. "ניסיתי ב-CMPA-89"). No confident case → do NOT
  act; escalate to chair.
- handle each pending comment scoped to its own $ISSUE_ID/$CASE_NUMBER

Verified end-to-end against live data: 13 active issues → exactly 2 pending
(CMPA-116, CMPA-89), both resolved to case 8125-09-24 via project.name, zero
false positives. Step 0 + Step A wired to run the scan on blind wakes.

Refs #164. Pairs with the plugin sweep (delivery) — this is discovery+action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:19:05 +00:00
99574f0eba Merge pull request 'fix(export): תיקוני-תבנית DOCX — חתימות לסוף, א–ד ללא נתונים, "החלטה"=כותרת (#205)' (#355) from worktree-agent-a4e6a6d273c4c3379 into main
Some checks failed
G12 Leak-Guard / leak-guard (push) Has been cancelled
Lint — undefined names / undefined-names (push) Has been cancelled
Build & Deploy / build-and-deploy (push) Has been cancelled
2026-06-30 12:18:41 +00:00
085de937da Merge pull request 'feat(analysis): סיכום-מנהלים מזוקק של טענות הצדדים — מסמך הכנה לדיון (#202, WS3)' (#358) from worktree-agent-abc25c98bc1092fa3 into main
Some checks failed
G12 Leak-Guard / leak-guard (push) Has been cancelled
Build & Deploy / build-and-deploy (push) Has been cancelled
Lint — undefined names / undefined-names (push) Has been cancelled
2026-06-30 12:18:09 +00:00
9e6a441076 Merge remote-tracking branch 'origin/main' into rebase-358
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
# Conflicts:
#	docs/spec/04-analysis-writing.md
2026-06-30 12:16:41 +00:00
eb0653fb80 Merge pull request 'feat(calibration): כיול-אמפירי model×effort מול הסופיים (#208)' (#360) from worktree-agent-a5a22be0318670871 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 12:15:46 +00:00
f935f166a9 feat(calibration): כיול-אמפירי model×effort מול הסופיים (#208)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
A/B harness שמכייל את ה-effort הנעוץ per-בלוק (#204) מול הסופיים של דפנה:
מייצר מחדש כל בלוק דרך מסלול-הייצור (write_block(effort_override=…) →
claude_session.query → claude -p, Opus 4.8, מקומי-בלבד) ומודד מול הסקשן
המתאים בסופי דרך style_distance.block_distance_to_final (change_percent,
anti_pattern_total, golden-ratio deviation, composite distance). ממליץ
per-בלוק על ה-effort הקרוב-ביותר לסופי.

- scripts/calibrate_effort.py — ההארנס (מודל eval_retrieval.py): --self-test
  (offline, מוכיח מדידה+המלצה, אפס DB/CLI) · --dry-run · --efforts/--blocks/
  --case/--repeats. דוח data/eval/effort-calibration-<ts>.{json,md} עם
  גודל-מדגם בולט — עדות-כיוון, לא רגרסיה (מעט סופיים-עלויים).
- style_distance.py — block_distance_to_final + split_final_by_section
  (מקור-מדידה יחיד, G2; reuse compute_diff_stats/count_anti_patterns/chunker).
- block_writer.py — write_block(effort_override=) להזרקת effort per-קריאה
  בלי לדרוס את ברירות-המחדל הנעוצות; רושם את ה-effort האפקטיבי.
- scripts/SCRIPTS.md — ערך חדש.

Invariants: G8 (eval-harness — מדידה אמפירית, לא הנחה) · G2 (reuse של
style_distance/learning_loop — אין מסלול-מדד מקביל) · claude_session
local-only (reference_claude_generation_path) · INV-LRN4/5 (השוואה מול
הסופי, מדידת-סגנון; אין מהות-תיק נגררת). אומת: --self-test 14/14 PASS.
הריצה החיה host-only (claude CLI) — לא ניתנת-להרצה ב-worktree/קונטיינר.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:13:43 +00:00
33b36e38ee Merge pull request 'feat(analysis): analyze_protocol — ניתוח פרוטוקול-דיון השוואתי (WS4 #203)' (#357) from worktree-agent-aefd5594d532d9f06 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m32s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 12:12:05 +00:00
8d704e6e40 Merge remote-tracking branch 'origin/main' into rebase-357
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
# Conflicts:
#	mcp-server/src/legal_mcp/services/db.py
2026-06-30 12:09:49 +00:00
faee621f0b Merge pull request 'feat(drafting): טיוטת-ביניים דטרמיניסטית — Opus 4.8 + effort per-בלוק (#204)' (#356) from worktree-agent-a0d2502e8308bc27c into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 12:03:42 +00:00
8ece726b8d Merge pull request 'feat(documents): מסמך-עיקרי — is_primary נגזר + רשימה קנונית (#200)' (#354) from worktree-agent-a59ef17c17ecd1d31 into main
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
G12 Leak-Guard / leak-guard (push) Has been cancelled
Lint — undefined names / undefined-names (push) Has been cancelled
2026-06-30 12:03:01 +00:00
aa2548ff5d feat(analysis): סיכום-מנהלים מזוקק של טענות הצדדים — מסמך הכנה לדיון (#202, WS3)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
מסמך פרוזה נפרד מטיוטת-הביניים (החלטת-יו"ר): תמצית מוקפדת של טענות+תשובות
להכנת היו"ר לדיון. נגזר מ-legal_arguments/claims הקיימים — אותו מקור-אמת,
ללא חילוץ-מחדש (G2). מתוחם לטענות-התיק בלבד (INV-AH).

- כלי-MCP חדש summarize_party_claims (drafting.py + server.py); ייצור עובר
  claude_session נעוץ Opus 4.8 + effort=high (זיקוק/סינתזה); local-only.
- שירות party_claims_summary.py: בחירת-מקור arguments→claims, פרומפט מעוגן-מקור,
  שמירה ל-data/cases/{n}/documents/research/ (git + S3).
- ייצוא DOCX בסגנון-תבנית דפנה (build_party_claims_summary_docx) + endpoints
  read/download/export-docx ב-web/app.py (מגישים בלבד — אין claude CLI בקונטיינר).
- _next_version הוכלל ל-prefix-scoped (תאימות-לאחור) למניעת התנגשות-גרסאות.
- ספ 04-analysis-writing.md §1.3 + 8 בדיקות אופליין (מקור/נעיצת-מודל/markdown→docx).

Invariants: G2 (מקור-אמת יחיד, בלי מסלול מקביל) · X9 TOOL1/TOOL4 (envelope +
extract↔serve) · INV-AH (עיגון-מקור, abstention על טענה לא-ברורה) ·
claude_session local-only · G9 (provenance — נשמר durably + git/S3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:22:20 +00:00
9ef0547997 docs(spec): WS7 — reconcile 01/02/03/04/06 + gap-audit; verify 7 workflow-redesign leaks closed (#207)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
WS7 of the workflow-redesign initiative required closing 7 known leaks along
the way. Cross-code audit verified ALL 7 were already closed in prior cycles
(FU-1/4/5/6/7 + IA waves); the spec still carried stale "known violation" text.
This PR aligns the spec to actual code state and verifies each closure with a
test. No new code path created — this is a spec-alignment audit, not a re-solve.

7 leaks → status (all closed/gated, each with a test):
1. cross-corpus halachot (#56, GAP-10/FU-4) — cl.source_kind in halacha_filters
   (db.py:7516,7519); test_precedent_corpus_isolation.py
2. invisible halacha backlog (GAP-14/FU-5) — health halacha_backlog
   (app.py:2352-2364) + extraction_status/halachot_pending
3. no eval harness (GAP-11/FU-5, G8) — scripts/eval_retrieval.py
   (P/R/MRR/nDCG vs gold-set+baseline); --self-test ALL PASS
4. export gate not hard-block (GAP-15/FU-6, INV-EX3) — export_docx checks
   qa_run_exists + get_critical_qa_failures before exporter (drafting.py:462-494);
   test_export_qa_gate.py
5. metadata on internal path (GAP-02/FU-1, INV-ING3) — unified ingest.ingest_document
   queues metadata+halacha together (ingest.py:233-234); test_unified_ingest.py
6. DOCX creep to source-of-truth (GAP-17/FU-7, INV-EX1) — active_draft_path is
   revision-anchor only; drift caught by cases.blocks_stale flag (V22)
7. UI cache-invalidation (GAP-33/FU-10) — qc.invalidateQueries on mutations

Also added forward-pointer notes for the workflow-redesign additions owned by
sibling tasks (02 documents is_primary/doc_category → #200; 04 re-analysis /
summarize_party_claims / analyze_protocol → #200/#202/#203), kept additive to
avoid line conflicts. block-schema.md interim/template edits deferred to #204/#205.

Invariants: maintains G2 (single source of truth, no parallel path — all leak
closures extend the canonical path), G8 (eval harness now exists), G10 (human
gates visible: halacha backlog + hard export gate), INV-ING3 (unified metadata
queue), INV-EX1 (DOCX is derived; blocks_stale drift flag), INV-RET1/G5 (#56
cross-corpus halacha isolation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:22:11 +00:00
24e3e2fe80 feat(analysis): analyze_protocol — comparative hearing-protocol analysis (WS4 #203)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
מנתח פרוטוקול-דיון מול כתבי-הטענות: אילו טענות ירדו/חוזקו/עלו-חדשות + חידוד
שאלות משפטיות, ומחלץ נתוני כותרת (א–ד). התוצאה נכנסת לידע-התיק.

- protocol_analyzer.py — ניתוח השוואתי דרך claude_session (Opus 4.8, effort=high,
  מקומי בלבד); שער anti-hallucination (quote-or-retract) ב-_normalize_change;
  חילוץ א–ד + הזנת hearing_date חזרה ל-cases (לא דורס קלט-יו"ר).
- claims_extractor: protocol → claim_type='protocol'; extract_claims קולט protocol.
- block_writer._build_claims_context מחריג claim_type='protocol' מבלוק ז (INV-WR4).
- db: טבלת protocol_analysis (V47, idempotent per-doc) + replace/list helpers.
- tools/drafting + server: analyze_protocol + get_protocol_analysis (extract/get).
- ספ 04-analysis-writing §1.3 + 15 בדיקות.

Invariants: G1 (נרמול-במקור: hearing_date לעמודה הקנונית), G2 (ידע-תיק נגזר
ממקור-האמת protocol+legal_arguments, לא מסלול מקביל), INV-TOOL3 (idempotent
per document_id), INV-TOOL4 (extract/get symmetry), INV-AH (quote-or-retract),
INV-WR4 (בלוק ז = טענות-כתב מקוריות בלבד), claude_session local-only, G12 (נקי
מ-Paperclip — leak-guard עובר).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:21:13 +00:00
aad2de9205 feat(drafting): טיוטת-ביניים דטרמיניסטית — נעיצת Opus 4.8 + effort per-בלוק (#204)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
מנגנון-הייצור (claude_session→claude -p) לא העביר model/effort: BLOCK_CONFIG
הגדיר temp+model per-בלוק שהיו מטא-דאטה-מת (block_writer.py:447 העביר רק
prompt/timeout/tools). הייצור רץ על מודל ברירת-המחדל של ה-CLI עם פרומפטים-חופשיים
→ מקור אי-העקביות בטיוטת-הביניים.

תיקון (עקביות-מבנית, WS5):
- נעיצת model="claude-opus-4-8" (GENERATION_MODEL) לכל בלוקי-ה-AI + effort per-בלוק
  שמועבר בפועל דרך claude_session.query → `claude -p --model --effort`.
  מפת-effort: ה=medium · ו=medium · ז=high · ח=medium · ט=high · יא=high · י=xhigh.
- ניסוח-פתיחה קבוע לבלוק-ה (תמיד "לפנינו ערר…", סוף ל-OR "עניינה של החלטה זו").
- מבנה-פנימי קבוע לבלוק-ה; סדר-בלוקים בייצוא כבר קבוע (_INTERIM_BLOCK_ORDER).
- הסרת שדות temp/model המתים מ-BLOCK_CONFIG; "temp" deprecated (Opus 4.7/4.8 דוחים
  temperature→400). decision_blocks.temperature נשמר=0 לתאימות-עמודה בלבד.
- timeout נגזר מ-effort (לא מפיצול sonnet/opus שכבר לא קיים).
- עדכון endpoint reference /api/settings/mcp/blocks + blocks-tab + McpBlock לחשוף effort.
- ספ: docs/block-schema.md §3 (effort מחליף temperature), docs/spec/06-export.md
  (דטרמיניזם-מבני של טיוטת-הביניים).

G2 (מקור-אמת יחיד) — אין מסלול-ייצור מקביל: write_interim_draft מתזמר את אותו
write_and_store_block→write_block של ההחלטה המלאה.
claude_session-local-only — הייצור נשאר דרך גשר-ה-CLI, לא SDK/קונטיינר.
INV-EX1 — סדר-ייצוא דטרמיניסטי נשמר; G11 — עקרונות-הכתיבה ל-12 הבלוקים נשמרים.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:18:56 +00:00
23e088d25e fix(export): clear template tables in _clear_body — חתימות לסוף, א–ד ללא נתונים (#205)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
תיקוני-תבנית DOCX (WS5 ת1–ת3): הטמפלט decision_template.docx מכיל 3 טבלאות-
דוגמה (כותרת מוסדית / הרכב / חתימות). _clear_body הסיר רק <w:p> והשאיר את
הטבלאות. כתוצאה:

- ת3 (שורש): טבלת-החתימות שרדה במיקום-הטמפלט (למעלה, צמוד לטבלת-הכותרת/בלוק-ד),
  וכל תוכן-ההחלטה נדחף אחריה. החתימות הופיעו בראש המסמך במקום בסוף.
- ת1: טבלאות הכותרת/הרכב הזריקו נתוני בלוק-א–ד שלא חולצו מהפרוטוקול.

התיקון בשכבת-הרינדור (G1 — לתקן במקור, לא לשכפל סימפטום): _clear_body מסיר
כעת את כל גוף-המסמך (פסקאות, טבלאות, ו-bookmarks יתומים) ומשאיר רק sectPr.
ההחלטה משוחזרת כולה מ-decision_blocks (INV-EX1) — החתימות מגיעות מ-block-yod-bet
שמרונדר אחרון בסדר-הבלוקים, ולכן טבלאות-הדוגמה הן פיגום מיושן שצריך להימחק.

ת2 כבר תקין: block-dalet מרנדר רק את הכותרת "החלטה" (Heading 1, מתעלם מתוכן-
ה-DB) — הוסף test מפורש לנעילה.

תוקן גם ב-analysis_docx_exporter._clear_body (אותו באג, סימטריה G2).

Tests: 4 regression חדשים + 27 docx-tests עוברים.

Invariants: INV-EX1 (DOCX נתון-נגזר מ-decision_blocks, ללא דליפת תוכן-טמפלט) ·
G1 (תיקון-במקור) · G2 (סימטריה בין שני ה-exporters) · G11/block-schema
(block-ד = כותרת בלבד, block-יב = חתימות בסוף).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:16:39 +00:00
7a7249af38 feat(documents): מסמך-עיקרי — is_primary נגזר + רשימה קנונית (#200)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
WS2 (#200): מבחין בין מסמך-עיקרי (מסמך-מהות שהיו"ר עוקבת אחריו לניתוח)
למשני. רשימה קנונית מאושרת-יו"ר: appeal/response/objection/protocol/
appraisal/decision; כל doc_type אחר = משני.

- db.PRIMARY_DOC_TYPES = מקור-אמת יחיד (tuple) + is_primary_doc_type().
- SCHEMA_V47: documents.is_primary BOOLEAN GENERATED ALWAYS AS
  (doc_type = ANY(PRIMARY_DOC_TYPES)) STORED — Postgres גוזר, אין מסלול-
  כתיבה מקביל, לא יכול לסטות מ-doc_type (G1/G2/INV-DM7, כמו ה-tsvectors).
  אידמפוטנטי (ADD COLUMN IF NOT EXISTS, ביטוי קבוע) + אינדקס חלקי
  idx_documents_primary לשאילתת "עיקריים שטרם-נותחו" (#201).
- _row_to_doc גוזר גם בקריאה (רשומות טרום-מיגרציה) + מוסיף doc_category
  ∈ {primary, secondary}. נחשף דרך document_list / case_get / API.
- 02-data-model.md §2ג מתעד את השדה הנגזר.

Invariants: G1 (נרמול-במקור — נגזר, לא נכתב), G2/INV-DM7 (מקור-אמת
יחיד, אין עמודה כפולה/מסלול מקביל), INV-DM3 (דפוס GENERATED drift-free),
INV-DM6 (ללא enum-TEXT חדש — סיווג נגזר). §6 (אין בליעה שקטה).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:16:26 +00:00
61e6be4b90 Merge pull request 'docs(heartbeat): שער קריאת-הערות לפני סגירה — מונע נפילת הערות-יו"ר (#164)' (#353) from worktree-comment-recheck-gate into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 10:22:07 +00:00
24e02621df docs(heartbeat): comment-recheck gate before done — prevent dropped chair comments
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Belt-and-suspenders to the plugin sweep (plugin-legal-ai #3). Comments that
arrive mid-run aren't re-read automatically — an agent reads comments once at
run start (§2), so a chair comment that lands after that point and before the
run closes the issue is never seen. That is exactly how a chair comment on
CMP-190 (2026-06-30) was dropped.

Adds a mandatory gate to HEARTBEAT §4ב: before PATCH status=done, re-read
comments and compare to the run-start list. If a new unaddressed chair comment
exists — do not close. Either handle it (and record a comment) or, if it
widens scope, keep the issue open and hand back to the CEO (§4ג). Never mark
done while a chair comment waits unanswered.

The plugin sweep catches orphaned comments within ~2 min; this gate prevents
the drop at the source. Refs legal-ai #164.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:20:47 +00:00
0f68cb2d0b Merge pull request 'feat(ui): באדג'י synthesis/superseded + provenance ב-lessons-tab (#162)' (#352) from worktree-lessons-synthesis-badges into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m31s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-28 22:59:55 +00:00
987da57da4 feat(ui): synthesis/superseded badges + merge provenance in lessons-tab (#162)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
עוצב ואושר דרך שער-העיצוב (Claude Design 12c-training-lessons.html).
- lessons-tab: SOURCE_BADGE['synthesis']="סינתזה" (navy מלא) · REVIEW_BADGE['superseded']="מוזג"
  (אפור-מעומעם, לא נדחה); שורות superseded מעומעמות (opacity).
- provenance: לקח-על מציג "⤷ מוזג מ-N לקחים · דחייה תשחזר אותם".
- backend: list_decision_lessons + _lesson_to_json מחזירים synthesized_from; training.ts
  DecisionLesson — 'synthesis'/'superseded' ל-unions + synthesized_from?: string[].

הבאדג'ים היו מוגנים-fallback קודם (ללא קריסה) — כעת מתויגים נכון. closes #162.

בדיקות: py_compile ✓ · leak-guard ✓ · tsc --noEmit ✓.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:59:31 +00:00
1136711ec8 Merge pull request 'docs(voice): apply corpus-56 fingerprint refresh (chair-approved, #161)' (#351) from worktree-apply-voice-fingerprint into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-28 22:55:33 +00:00
d83d1b248f docs(voice): apply corpus-56 fingerprint refresh (chair-approved, #161)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
The chair (Dafna/chaim) reviewed and approved the full corpus-refresh proposal
generated by scripts/regenerate_voice_fingerprint.py and committed it to the live
voice-fingerprint — satisfying the INV-LRN1 hard chair-gate (a human approved;
the script only ever proposed). +81/-15 lines: all the deletions are reword-and-
expand of existing lines (no curated insight lost), the additions are style/method
refinements grounded in the 56-decision corpus (INV-LRN5 — no case substance):

- §1.2 conditional-acceptance as an "anחנו" verb (מקובל עלינו vs נציין)
- §1.3 ruling variation "ראשית, צודק [צד]... אלא ש"
- §1.5 emphasis always annotated "(הדגשת הח\"מ)"
- §1.8 bounded appraiser-restraint (professional plane only; legal plane = no deference)
- §1.9 inverse twin "ניתן היה לסיים... אלא ש"
- §1.11 quantitative anchoring as a reframing device (cross-type)
- §1.12 measured block ratios (background 17 / claims 27 / discussion 42 / summary 14)
- §1.13 faithful rendering of a party's internal reasoning
- §2.2 two-sided pendulum for the restraint paragraph + expanded party-verb catalog
- §4.8 new template "ניתן היה לסיים — אלא ש"
- §5.x agent instructions updated to reference the new patterns

Invariants: INV-LRN1 (chair approved before commit — the gate held), INV-LRN5
(style/method only, no substance).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:55:07 +00:00
0398274564 Merge pull request 'feat(learning): העלאת תקרת-לקחים-לכותב 15 → 60 — סוגר את #157' (#350) from worktree-writer-lessons-limit into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m26s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-28 22:51:19 +00:00
a6fb632221 feat(learning): raise writer lessons cap 15 → 60 (config.WRITER_LESSONS_LIMIT) — closes #157
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
עכשיו שהלקחים מנוכי-כפילות (סינתזה #158) ומאושרי-יו"ר, התקרה 15 חתכה בשקט הנחיות-סגנון
מובחנות ו-vetted (רישוי/סגנון 29>15). מעלים ל-60 (env-tunable WRITER_LESSONS_LIMIT) —
מכסה כל shard נוכחי עם מרווח; הסינתזה שומרת על אי-רדודנטיות בצמיחה. ה-WARN על חיתוך נשאר
רשת-הביטחון (חוקה §6) ומפנה לסינתזה/העלאת-התקרה. block_writer צורך דרך config.

Invariants: INV-LRN1/G10 (עדיין רק approved זורם; superseded מוחרג), G2. closes #157 בפועל.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:50:55 +00:00
5e7097399f Merge pull request 'tune(learning): סף-אשכול סינתזת-לקחים 0.82 → 0.68 (#158)' (#349) from worktree-synth-threshold-068 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m30s
G12 Leak-Guard / leak-guard (push) Successful in 6s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-28 22:45:11 +00:00
6c3c6a16c2 tune(learning): lesson-synthesis cluster threshold 0.82 → 0.68 (#158)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
כיול אמפירי (dry-run על 81 לקחים): הלקחים מגוונים (חציון-דמיון ~0.48, מקס' ~0.72), כך
ש-0.82 מיזג 0 אשכולות. 0.68 תופס 5 קונסולידציות אמיתיות (רישוי+השבחה+מבנה), כולן עברו את
שער-ה-drift 0.80 (0.848–0.916) → נאמנות נשמרת. שער-ה-drift נשאר רשת-הביטחון מתחת לסף.

Invariants: INV-LRN8 (כיול-סף בלבד; המנגנון/השערים ללא שינוי). depends-on #158.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:44:55 +00:00
7a7db6933d Merge pull request 'feat(learning): voice-fingerprint corpus-refresh proposal generator (#161, prose half)' (#348) from worktree-voice-fingerprint-refresh into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-28 22:26:39 +00:00
d3d379af65 Merge pull request 'feat(learning): סינתזת-לקחים — מיזוג לקחים חופפים ללקח-על עשיר (#158 / INV-LRN8)' (#347) from worktree-lesson-synthesis into main
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
G12 Leak-Guard / leak-guard (push) Has been cancelled
Lint — undefined names / undefined-names (push) Has been cancelled
2026-06-28 22:26:28 +00:00
9a19db1ec1 feat(learning): voice-fingerprint corpus-refresh proposal generator (#161, prose half)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
The voice-fingerprint (docs/daphna-voice-fingerprint.md) is the PRIMARY abstract
style channel the writer consumes (07-learning §0.2 channel A), but it was
hand-authored and never regenerated from the growing corpus — the "voice" the
writer adapts stayed frozen while finals accumulated. PR #345 unfroze the
structural half (corpus-measured ratios → writer); this is the prose half.

scripts/regenerate_voice_fingerprint.py reads the corpus (style_corpus stats +
a bounded block-level style_exemplars sample + measured section ratios) and asks
Opus (local) to PROPOSE refinements/additions to the fingerprint — STYLE / METHOD
/ VOICE / LEXICON only, grounded in corpus evidence, never case substance
(INV-LRN5), preserving curated insights rather than replacing them.

CHAIR-GATED, HARD (INV-LRN1): the prose profile is voice-knowledge, not a
low-risk style rule, so it does NOT auto-flow. The script writes a PROPOSAL to
data/curator-proposals/ (the existing curator-proposal surface) and NEVER
overwrites the live fingerprint; the chair reviews and hand-commits accepted parts
— the same manual gate the fingerprint already uses. Runs on the host (claude CLI).

Reuses claude_session + measure_corpus_ratios + the curator-proposals dir (G2 —
no parallel synthesis or gate). SCRIPTS.md + spec §0.2 updated.

Invariants: INV-LRN1 (hard chair gate for voice-knowledge prose), INV-LRN5
(style-only synthesis), G2 (reuses existing synthesis inputs + proposal surface).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:26:04 +00:00
14ab7b0cae feat(learning): lesson synthesis — merge overlapping lessons into richer super-lessons (#158 / INV-LRN8)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
הפתרון-האמיתי לחיתוך-השקט limit=15 (#157): במקום ערימת לקחים גולמיים חופפים, ממזגים
לקחי-סגנון דומים ל"לקח-על" אחד עשיר ומוכלל — הסט שזורם לכותב קטֵן ומשתבח (Authorial
Style Profiling). מחקה את סינתזת-הקנוני (V41/INV-LRN6) על אותה טבלה (G2, אין מאגר-מקביל).

מנגנון (services/lesson_synthesis.py, מסלול-יחיד):
- אשכול greedy לפי cosine (LESSON_SYNTH_CLUSTER_THRESHOLD) בתוך shard practice_area+category.
- מיזוג ע"י claude_session מעוגן-מקור (INV-AH, סגנון-בלבד INV-LRN5, abstain) + שער-drift
  (cosine מול centroid ≥ LESSON_SYNTH_DRIFT_FLOOR).
- לקח-על נכתב source='synthesis' + synthesized_from; המקורות→review_status='superseded'.
- שער מדורג-הפיך (הכרעת-יו"ר): מקורות approved → לקח-על approved (זורם), veto-יו"ר משחזר
  את המקורות (db.revert_lesson_synthesis, מחובר ל-PATCH lessons).
- idempotency: lookup-cosine מול synthesis קיים לפני INSERT.

נגזרות: SCHEMA_V46 (embedding vector(1024) + synthesized_from + ivfflat); כלי-MCP
lesson_synthesize_pending; scripts/backfill_lesson_synthesis.py (--dry-run/--apply, audit CSV);
config LESSON_SYNTH_*; spec INV-LRN8; SCRIPTS.md.

get_recent_decision_lessons ללא שינוי — superseded יוצא (מסנן approved), synthesis נכנס.
UI badges (synthesis/superseded) נדחים לשער-העיצוב (מוגנים ב-fallback, ללא קריסה).

בדיקות: py_compile ✓ · leak-guard G12 ✓ · smoke-test טהור לאשכול/cosine/centroid ✓.
אימות functional מלא (dry-run מול DB+voyage+claude CLI) — בהוסט אחרי-deploy, כמו V41.

Invariants: G2 (מסלול-יחיד, אותה טבלה), INV-AH (עיגון+drift), INV-LRN1/G10 (שער מדורג-הפיך),
INV-LRN5 (סגנון-בלבד), INV-LRN8 (חדש). depends-on #157/#159.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:24:42 +00:00
c1983ea41d Merge pull request 'feat(ops): host_sync.sh — auto-sync host tree to origin/main (#160)' (#346) from worktree-host-sync into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-28 22:20:40 +00:00
80a5daa3e3 feat(ops): host_sync.sh — auto-sync host tree to origin/main (P-#160)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
The writer, learning panels, and legal_mcp tools run from the HOST tree
(Paperclip agents claude -p with cwd=~/legal-ai; pm2 services import from
mcp-server/src) — not the container. A merge + Coolify deploy only refreshes the
FastAPI container; host-side code stayed on the last manual `git pull`. On
2026-06-28 the host was 16 commits behind, so a day of learning-loop fixes sat
dormant until pulled by hand.

host_sync.sh (run every minute via cron, like the existing data/training pull)
fast-forwards the host tree to origin/main when it falls behind. Fast-forward
ONLY — never forces, so the perpetually-dirty .taskmaster/tasks/tasks.json is
never clobbered (clean ff leaves it; an upstream conflict logs + bails for manual
handling). Agent runs spawn fresh and pick up new code with no restart; only the
long-running pm2 services that import app code (chat_service, court_fetch_service,
reaper) are restarted, and only when their own files changed (precise, minimal
churn). Logs to data/logs/host_sync.log.

Cron entry (added on the host, flock-guarded):
  * * * * * flock -n /tmp/host_sync.lock bash ~/legal-ai/scripts/host_sync.sh

SCRIPTS.md updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:20:13 +00:00
9455da7567 Merge pull request 'feat(learning): feed corpus-measured ratios to the writer (P2 #7, structural half)' (#345) from worktree-corpus-ratios-writer into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m25s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-28 22:11:13 +00:00
638dbf9b9f feat(learning): feed corpus-measured section ratios to the writer (P2 #7, structural half)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Golden ratios were measured from the corpus (style_distance.measure_corpus_ratios)
but never reached the writer — it used only the hardcoded STRUCTURE_GUIDANCE text.
So the structural target was frozen, and drafts kept deviating (e.g. discussion
block 10% vs a 40-47% target) with no corrective signal.

_structure_guidance_with_corpus(outcome, practice_area) now appends the
corpus-measured section ratios for the block's outcome to the structure guidance,
at both writer entry points (write_block + get_block_context) via one shared helper
(G2). Because measure_corpus_ratios reads style_corpus — which grows with every
enrolled final (see exemplar-growth, #343) — the writer's structural target now
reflects Dafna's ACTUAL current distribution and improves as the corpus grows.
Deterministic (no LLM); best-effort.

This is the STRUCTURAL half of the voice-profile-refresh (#7). The remaining half —
LLM-regenerating the abstract voice-fingerprint PROSE from the corpus (chair-gated)
— is a distinct larger initiative, deliberately deferred rather than rushed.

Invariants: G1 (the writer gets the live measured target, not a frozen constant),
G2 (one structure-guidance builder shared by both entry points; reuses the existing
measure_corpus_ratios — no parallel measurement), INV-LRN4 (the corpus the loop
grows now feeds the writer's structural target).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:10:39 +00:00
97f3f50d0b Merge pull request 'feat(learning): chair-feedback style corrections auto-flow to writer (P1 #6)' (#344) from worktree-chair-feedback-flow into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-28 22:07:30 +00:00
8b23542dec feat(learning): chair-feedback style corrections auto-flow to the writer (P1 #6)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
The chair-feedback chain was DEAD: 27 feedback rows captured, 0 ever became a
lesson the writer reads (no weekly-analysis job ever existed). The chair's own
corrections — the most authoritative signal of all, and exactly "learn from every
returned draft" — went nowhere.

decision_lessons can't carry them: it's FK-coupled to a style_corpus row (a signed
final), which a case-in-progress doesn't have. So chair STYLE feedback instead
rides the discussion_rules channel that already reaches the writer for all blocks —
the same path /training promote uses.

- db.append_global_rule: the locked read-modify-write append, extracted from web
  `_append_methodology_override` into one shared impl (G2). The web function is now
  a thin wrapper that seeds defaults; chair-feedback calls it directly.
- record_chair_feedback (MCP tool): a STYLE-category correction (style/wrong_tone/
  wrong_structure) with a lesson_extracted flows immediately to discussion_rules.
  The chair IS the gate — no separate approval (INV-LRN1 graduated gate). SUBSTANCE
  feedback (missing_content/factual_error/other) is case-specific → recorded only.

Invariants: INV-LRN1 (chair-authored style = highest authority, flows; substance
not auto-flowed), G2 (single append impl shared by promote + feedback), INV-LRN5
(style channel only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:06:58 +00:00
b8eb0ee123 Merge pull request 'feat(learning): ניתוח-§A של האוצֵר רץ על mark-final — לכידת source='curator' על כל סופי (#159)' (#342) from worktree-curator-autofire into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-28 22:03:15 +00:00
b0a6c2fe01 Merge pull request 'feat(learning): grow style-exemplars from every final (P1 #5)' (#343) from worktree-grow-exemplars into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-28 21:58:12 +00:00
93a9404663 feat(learning): grow style-exemplars from every final (P1 #5)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
The style-exemplar corpus (channel B — the writer's block-level retrieval of
Dafna's real prose) was FROZEN at the one-time seed backfill: new finals were
enrolled into style_corpus but never broken into exemplars, so the richest style
channel never grew (8137/8126/8174 had 0 exemplars). The writer kept retrieving
only March–April seed paragraphs no matter how many finals were signed.

Extract the per-decision exemplar logic (section→paragraph→Voyage-embed→replace)
into a shared service `legal_mcp.services.style_exemplars.extract_and_store` —
the SINGLE implementation now used by BOTH the one-time backfill and the live
enrollment path (G2; no parallel extractor). `_enroll_final_in_library` calls it
on every final upload (source='internal_committee', the same source the writer's
search_style_exemplars reads). Voyage embeds over REST → container-safe;
best-effort, surfaced in the upload response, never fails the upload.

Effect: every signed final now grows the exemplar corpus, so the writer's
block-level style retrieval improves with each decision — the core "learn from
every decision" fix for channel B. Path A (style_distance_history) will track
whether the larger exemplar pool reduces style-distance over time.

Invariants: G2 (one extraction path shared by backfill + enroll), INV-LRN5
(style/structure prose only — substance routes elsewhere), INV-LRN4 (the
draft↔final loop now feeds the exemplar channel, not just the lesson channel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:57:38 +00:00
4d6268820c feat(learning): curator §A fires on mark-final — capture source='curator' findings on every final (#159)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
חלופה ז (agent-centric) — סוגר את פער-המימוש מ-#157:

1. final_learning_pipeline.py — סדר-מחדש: enroll_style_corpus רץ ראשון (לפני הדיסטילציה של עד
   30 דק'), כך שרשומת style_corpus קיימת תוך שניות — והאוצֵר יכול לרשום ממצאים בלי מירוץ.
   enroll/ingest בלתי-תלויים; panel נשאר אחרון. תוויות [1/3]/[2/3] עודכנו.

2. hermes-curator.md PIPELINE-WAKE BRANCH — מבדיל final_learning_* (ממשיך ל-§A, מצב AUTO) מ-
   final_halacha_* (exit כקודם). ב-AUTO: §A.1–§A.5b → record_curator_findings, דילוג על §A.6
   (interaction) כדי לא להעיר את דפנה (הממצאים proposed ונסקרים ב-/training). §A.5b קיבל retry
   כרשת-ביטחון למירוץ-שארית.

3. docs/spec/07-learning.md §0.6 + §1.1 — עודכנו לסדר-הצינור החדש; הערת "פער-מימוש פתוח" → "נסגר".

Invariants: G2 (מחולל-יחיד לממצאי-curator — הסוכן; אין כפילות-תבונה), INV-LRN1/G10 (proposed,
שער-יו"ר), INV-LRN3, INV-DUR1 (סדר-צעדים בשמות יציבים; checkpoint resume — edge-case של ריצה
מקבילה-לפריסה, סיכון נמוך). depends-on #157.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:56:36 +00:00
07bd8bee48 Merge pull request 'feat(learning): graduated gate — panel-consensus style lessons auto-flow to writer (P0)' (#341) from worktree-graduated-gate into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-28 21:46:29 +00:00
044de2c0f0 feat(learning): graduated gate — panel-consensus style lessons auto-flow to writer (P0)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
The voice-learning panel produced 81 vetted style lessons — all stuck at
review_status='proposed' with 0 ever approved — so the writer (which reads only
'approved') received NONE of them. The system captured learning but never let it
flow. Chair decision (2026-06-28): a GRADUATED gate by content risk.

- STYLE lessons (categories style/structure/lexicon/tabular) the 2/2 panel kept →
  created as review_status='approved' → flow to the writer immediately, reversibly
  (chair vetoes in /training). The deepseek+gemini panel only emits style_method
  and only on 2/2 consensus, so this is exactly the gate's criterion; substance is
  already filtered out and skipped, and routes through the strict halacha gate.
- SUBSTANCE (halacha/precedent/fact) stays a HARD chair gate — unchanged.

style_lesson_panel.py: _review_status_for(category) sets the gate explicitly
(approved for _STYLE_CATEGORIES, else proposed); the apply loop passes it to
db.add_decision_lesson (which already accepts review_status — no DB change).

Spec: INV-LRN1 rewritten as the graduated gate (hard for substance; reversible
auto-flow for consensus style — still "under user control" via veto, per
NCSC/CEPEJ); §0.6 updated to match.

A one-time backfill of the 81 existing panel style lessons to 'approved' runs
separately (DB, post-merge).

Invariants: INV-LRN1 (amended — graduated), INV-LRN5 (style-only, substance never
auto-flows), G10 (human control preserved as reversible veto).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:45:56 +00:00
cfcfe4df48 Merge pull request 'fix(learning): כרטיס-אוצֵר כן-3-ערוצים + לכידת ממצאי-אוצֵר (source='curator')' (#339) from worktree-curator-learning-surface into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-28 21:28:53 +00:00
6427911baf Merge pull request 'fix(writer): always feed canonical anti-patterns to the writer' (#340) from worktree-writer-anti-patterns into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m25s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-28 21:24:57 +00:00
d7201736f2 fix(writer): always feed canonical anti-patterns to the writer
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
The learning loop measured anti-patterns but never corrected them. style_distance
detects markdown headers / bullet lists / mid-paragraph mini-lists (from the
canonical lessons.ANTI_PATTERNS), but the writer only received anti-pattern
guidance if a chair `anti_patterns` override existed in appeal_type_rules — and
none does. So _build_style_context's `if ov:` branch injected nothing, the writer
was never told to avoid them, and drafts kept emitting them (8137: 28 hits, the
worst, newest — anti-patterns were trending UP, not down).

Anti-patterns are structural invariants of Dafna's voice (continuous legal
narrative — no markdown, no bullets), not overridable preferences. So inject the
canonical ANTI_PATTERNS notes ALWAYS, from the same list style_distance measures
against (single source of truth), with any chair additions layered on top. This
closes the measure-but-don't-correct gap: the next draft should show the markdown/
bullet anti-patterns drop, and Path A (style_distance_history) will confirm it.

Invariants: G1 (correct at source — the writer, not a post-hoc stripper), G2
(one canonical anti-pattern list shared by detection and instruction — no parallel
list), INV-LRN4 (closes the feedback half of the draft↔final loop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:24:24 +00:00
be774ab87e fix(learning): honest 3-channel curator card + capture curator findings (source='curator')
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 9s
תיקוני-מבנה ללולאת-הלמידה (TaskMaster #157):

1. כרטיס ה-curator (/training טאב "אוצֵר") ספר decision_lessons WHERE source='curator'
   — ערך שאף מסלול-קוד לא כתב → תמיד 0. עוצב-מחדש (דרך שער-העיצוב Claude Design, אושר)
   להציג ביושר את שלושת ערוצי-ההזנה לכותב: דיסטילציה→appeal_type_rules (180, זורם),
   פאנל→decision_lessons (81, ממתין), אוצֵר→source='curator'. get_curator_stats שוכתב.

2. drift ספ↔סוכן: 07-learning.md §1.1 + INV-LRN3 קבעו שהאוצֵר רושם ממצאים כ-decision_lesson
   source='curator', אך הסוכן כתב comments בלבד — הממצאים האיכותיים אבדו. נוסף כלי-MCP
   record_curator_findings + §A.5b ב-hermes-curator.md (read-only נשמר; הצעה מגודרת-שער).

3. get_recent_decision_lessons(limit=15) חתך בשקט — נוסף WARN על מה שנחתך (חוקה §6);
   הפתרון האמיתי = סינתזת-לקחים (TaskMaster #158).

Invariants: מקיים INV-LRN1/G10 (שער-יו"ר), INV-LRN3 (לכידה מובנית), INV-IA2/IA5 (מקור-אמת
יחיד), G12 (leak-guard עובר), G2 (אין מסלול מקביל — אותם מאגרים). פער-מימוש פתוח מתועד:
§A לא רץ אוטומטית על mark-final (pipeline-wake exits) → נדחה לתכנון נפרד.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:20:05 +00:00
c00e7b4f49 Merge pull request 'docs(spec): Path A — prospective held-out style-distance trend (07-learning §0.7)' (#338) from worktree-spec-path-a into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
G12 Leak-Guard / leak-guard (push) Successful in 3s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-28 21:16:35 +00:00
927be5c6bb docs(spec): add Path A — prospective held-out style-distance trend (07-learning §0.7)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Documents the prospective held-out methodology shipped in PR #337: why a clean
retrospective held-out is impossible (lessons stored universal/untagged → no
leave-one-out), and how the system instead captures a clean generalization
datapoint at final-upload — before this case's lessons are folded — via a
style_distance snapshot + lesson-pool size into style_distance_history,
surfaced by GET /api/learning/style-distance-history.

Adds §0.7, wires step [7] MEASUREMENT and INV-LRN4 to it. Notes the
change_percent style/content confound and the one-time clean window.

Invariants documented: INV-LRN4 (this is its trend surface), G2 (reuses
style_distance + appeal_type_rules — no parallel metric path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:16:11 +00:00
84afdcb36c Merge pull request 'feat(learning): prospective held-out style-distance trend (Path A)' (#337) from worktree-style-distance-history into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-28 21:03:50 +00:00
ab510328af feat(learning): prospective held-out style-distance trend (Path A)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 9s
A clean held-out test of voice learning was no longer runnable: every
final-uploaded case already has its lessons folded, and lessons are stored
universal/untagged so leave-one-out is impossible. Path A makes the test
prospective instead — capture the generalization datapoint at the one moment
it's clean.

On final upload, after the draft↔final pair is created but BEFORE this
case's lessons are folded (folding is a separate manual /training step), we
snapshot style_distance (anti_pattern_total, golden-ratio max-deviation,
change_percent) alongside the current voice-lesson pool size. Because the
draft was written with only the PRIOR pool, each row is a clean "with N
accumulated lessons, our draft on this unseen case scored X" datapoint. As
the pool grows over cases, a downward trend = learning generalizes.

- db: SCHEMA_V45 style_distance_history (append-only) + helpers
  voice_lesson_pool_sizes / record_style_distance_snapshot /
  get_style_distance_history.
- app: best-effort capture in api_upload_final_decision (never fails the
  upload); GET /api/learning/style-distance-history for the trend.

Reuses the existing style_distance service + appeal_type_rules pool — no
parallel metric path. The 8 existing cases are already folded, so the table
starts empty and fills from the next final (their clean window is past).

Invariants: G2 (reuse style_distance/appeal_type_rules — one path),
INV-LRN4 (measure the draft↔final gap; this is its trend surface). LLM-free
(style_distance is deterministic) so it runs in the container.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:03:10 +00:00
8d0e44937d Merge pull request 'fix(compose): wire staged-pipeline indicators to live learning-status' (#336) from worktree-compose-learning-indicators into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 44s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-28 17:55:02 +00:00
baf478d9dc fix(compose): wire staged-pipeline indicators to live learning-status
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
The two stage indicators in the compose page's "השלמה והעברה" rail
("הרץ למידת-קול" / "הרץ אימות-הלכות") were static placeholder text
translated from mockup 03 and never wired to data — they always read
"ממתין להעלאת הסופי", even after the final was uploaded and both
pipelines completed. (The real status was already shown correctly by
LearningStatusBadges on the case page.)

Wire them to useCaseLearningStatus (/api/cases/{n}/learning-status) — the
same source the drafts-panel badges use (G2). The trailing text is now
derived: "ממתין להעלאת הסופי" until the final is uploaded, then the live
state ("✓ הושלם · 12 לקחים הופקו · 12 הוצעו לאישור" / "✓ הושלם · חולצו 48
· 33 אושרו · 15 נדחו", or running/queued/failed).

Data-binding bugfix only — identical markup/classes (mockup-03 layout
preserved), so no visual redesign; within the design-gate bugfix exception
(chair-approved approach). tsc + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:54:33 +00:00
6c487a2e85 Merge pull request 'fix(learning): extract precedent metadata inline on final-decision enroll' (#335) from worktree-enroll-inline-metadata into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 9s
2026-06-28 10:49:23 +00:00
8e2cfd602c fix(learning): extract precedent metadata inline on final-decision enroll
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 9s
When a chair's signed final decision is uploaded it is enrolled into the
precedent library, but _enroll_final_in_library only set deterministic
fields (citation, proceeding_type, date) and copied subject_tags from the
case's subject_categories — which is usually empty. The Gemini metadata
pass (subject_tags / summary / headnote / key_quote) was never triggered,
so the row sat at metadata_extraction_status='pending' with no subject
tags until a drain happened to pick it up. In practice a freshly-enrolled
final showed an empty "תגיות נושא" in the precedent edit UI.

Fix: after enrollment + citation, call the existing reextract_metadata
path inline (G2 — one path, full status lifecycle). It runs on Gemini
Flash over REST (GOOGLE_GEMINI_API_KEY, already in Coolify), so it is
container-safe — unlike the halacha path (claude CLI, host-only).
apply_to_record fills only empty fields, so the deterministic seeds and
any chair-curated subject_categories are preserved. Result surfaced in the
upload response (out["metadata"]); failures logged, upload still succeeds.

Also corrects two now-stale comments: the "Gemini returns no_metadata for
internal decisions" note in the enroll loop, and the "MCP-tool-only path"
docstring on reextract_metadata (true for halacha, not for Gemini REST).

Invariants: G1 (fill at source on enroll, not a read-time workaround),
G2 (reuse reextract_metadata — no parallel extraction path). LLM call is
Gemini REST, not claude_session, so the container LLM-call constraint holds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:48:46 +00:00
0bf787929c Merge pull request 'fix(renumber): update Paperclip issue linkage on case renumber' (#334) from worktree-renumber-paperclip-linkage into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 11s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-28 10:20:00 +00:00
8932bd1f54 fix(renumber): update Paperclip issue linkage on case renumber
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
renumber_cases.py step 6 only renamed the Paperclip project, leaving the
case↔issue linkage on the OLD case number. get_case_issues() keys on two
text surfaces the script never touched:
  • plugin_state.legal-case-number (value_json) — the authoritative linkage
  • issues.title — the '[ערר {cn}] …' tag the title-path lookup matches

Result: after a renumber the issues kept the old number, get_case_issues
returned [], and post-final actions (run-learning / run-halacha) silently
skipped with reason "no_issue" — surfaced in the UI as "לא הופעלה למידה".
Discovered on case 8137-11-24 (renamed from 8137-24).

Now the Paperclip step rewrites all three surfaces (projects.name +
plugin_state + issue titles); inspect_paperclip + the dry-run report show
the linkage-row and issue-title counts that will be rewritten.

The 11-case migration already ran; the matching DB rows were fixed
manually (incl. this commit's logic) for all stale cases. This change is
for any future renumber.

Invariants: G1 (normalize at source — the linkage, not a read-time
workaround). G12 note: this is a one-time host-side migration script that
already connects directly to the Paperclip DB by design (below the platform
port, like the existing projects/MinIO/Gitea surgery) — it does not add a
parallel runtime path through agent_platform_port.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:19:24 +00:00
4de555367d chore: fix .gitignore inline comments + add untracked code files
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m32s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 11s
Inline # comments in gitignore are not supported — they were silently
breaking three patterns (data/checkpoints/, data/adapter-migration-state.json,
.claude/agents/.generated/). Moved comments to their own lines and added
missing entries for runtime dirs (data/audit/, data/logs/, etc.) and
temp files (.interaction_tmp.json, .design-build/, .taskmaster bak files).

Also tracks previously untracked legitimate files: scripts, tests, docs,
skills references, .env.example, taskmaster templates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 12:49:32 +00:00
b4a68cf5da feat(ocr): replace Google Vision with Mistral OCR as PDF fallback
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-27 10:15:13 +00:00
9ae7304d44 feat(ocr): replace Google Vision with Mistral OCR as PDF fallback
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Switches the scanned-PDF fallback from Google Cloud Vision to
Mistral OCR (mistral-ocr-latest) for better Hebrew accuracy and
robustness against broken embedded OCR layers (e.g. case 1044-03-26
which returned English garbage through Vision).

Routing strategy (document-level, not per-page):
- PyMuPDF extracts all pages; pages that pass _text_quality_ok()
  use PyMuPDF output directly (free, ~50ms).
- If ANY page fails quality → Mistral OCR called once for the whole
  PDF, returning per-page Markdown for all pages (consistent format,
  no plain-text/Markdown mix within a document).

Markdown output preserved: Mistral returns ## headers and |tables|;
chunker updated to recognise ATX Markdown headers (##/###) as section
boundaries in _split_into_sections().

Config: GOOGLE_CLOUD_VISION_API_KEY → MISTRAL_API_KEY; allowlist
updated vision.googleapis.com → api.mistral.ai.
MISTRAL_API_KEY added to Coolify container env.

Invariants: G1 (single OCR fallback path, not parallel), G2 (no
duplicate extractor route), INV-AH (Mistral handles gershayim
natively; quote-fix only on PyMuPDF path).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 10:11:45 +00:00
f2a264a7da Merge pull request 'fix(status): add qa_passed to legacy display labels' (#332) from worktree-qa-passed-status into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 45s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-24 15:04:16 +00:00
e051fda0cb fix(status): add qa_passed to legacy labels + migrate 8174-12-24 to drafted
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
qa_passed was set by the old agent pipeline but not included in the
trimmed 10-status CASE_STATUSES list or LEGACY_STATUS_LABELS, causing
the status badge and workflow timeline to render nothing ("לא ידוע").

Added qa_passed → "טיוטה" to LEGACY_STATUS_LABELS as a display-only
fallback so any case still carrying this value renders correctly until
migrated. Case 8174-12-24 status updated to drafted via PUT /api/cases.

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

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

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

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

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

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

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

TaskMaster: legal-ai #156

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:35:55 +00:00
c9970a5955 Merge pull request 'fix(operations): contain agent run-log text inside the "פלט" popup' (#327) from worktree-fix-runlog-overflow into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-20 18:32:17 +00:00
38234d9b4f fix(operations): contain agent run-log text inside the "פלט" popup
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
The RunLogDialog rendered the live agent log inside a Radix ScrollArea.
Radix wraps Viewport children in an inner `display:table` div that
shrink-wraps to the widest unbreakable token — the long `toolu_…` IDs and
`/home/chaim/…` file paths in the raw JSON log. That table cell expands
instead of constraining width, so `whitespace-pre-wrap break-words` never
had a width to wrap against and the text spilled out past the dialog
borders.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:19:03 +00:00
d7855f6284 Merge pull request 'fix(ui): case header — metadata parallel to title, parties clamped to 2 lines' (#324) from worktree-case-header-layout into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-20 18:11:55 +00:00
80809ca406 fix(ui): case header — keep metadata parallel to title, clamp parties to 2 lines
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
The hearing-date/updated/sync metadata dl dropped below the title (taking rows and
pushing the band down) when the appellants/respondents line was long, because the
outer row used flex-wrap. Fix:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:45:14 +00:00
4f45fa416b Merge pull request 'feat(x11): treatment-aware citation authority wired into research agents (#154)' (#319) from worktree-x11-treatment-wiring into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m26s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-20 16:04:36 +00:00
ccc5a73bc8 feat(x11): treatment-aware citation authority wired into research agents (#154)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
The internal citation graph fed only RANKING (raw in-degree), and the per-citation
TREATMENT was never classified — so a precedent distinguished N times got the same
authority boost as one followed N times (INV-COR2 violation), and the signal never
reached the agents' reasoning. Wires the full path:

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:04:04 +00:00
b8c49a1269 Merge pull request 'fix(precedents): מראה-מקום never blank — seed at upload + inline Gemini enrichment' (#318) from worktree-citation-autofill-root into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-20 15:30:40 +00:00
b0bcdbeeef fix(precedents): מראה-מקום never blank — seed at upload + inline Gemini enrichment (root cause)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
Root cause: the upload form "מראה המקום" field is stored only as case_number;
citation_formatted was left empty and filled ONLY by the async metadata
extraction, which runs in the local drainer (not the container) because it was
lumped with halacha extraction. Result: the chair saw an empty מראה-מקום during
the window and re-typed it by hand.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -86,6 +86,8 @@
**1.5א. אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל comment חדש מחיים** — התייחס אליו ב-comment הראשון שלך ("ראיתי שביקשת X — מבצע Y") **לפני** עבודה רחבה. זה מבטיח שחיים יודע שקלטת.
**1.5א2. פעולות סטרוקטורליות (כפתורי-UI עתידיים, CEO בלבד).** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל שדה `action` — נתב דטרמיניסטית בלי פענוח-טקסט: `action == "interim_draft"` → טיוטת ביניים, `action == "party_claims_summary"` → סיכום מנהלים של טענות הצדדים. פירוט: `legal-ceo.md` שלב 0 + שלבים H/H2.
**1.5ב. תמיד לקרוא `heartbeat-context`** — לא רק מה ש-skill ממליץ ("Prefer"). אצלנו ה-`attachments` המוחזרים חיוניים (חיים מעלה DOCX/PDF דרך comments). ראה §2.
```bash
@@ -171,6 +173,12 @@ FULL_PATH="/home/chaim/.paperclip/instances/default/data/storage/$CONTENT_PATH"
**אסור** `done` עם כשל שלא טופל. אם משהו נכשל → `blocked` + comment עם פירוט.
> **⚠️ שער קריאת-הערות לפני סגירה (חובה).** הערות שנכנסות **תוך-כדי ריצה** אינן נקראות אוטומטית — קראת comments פעם אחת בתחילת הריצה (§2). **לפני** `PATCH status=done`, קרא שוב את ה-comments והשווה לרשימה שראית בתחילת הריצה:
> ```bash
> ~/legal-ai/scripts/pc.sh GET "/api/issues/{issue-id}/comments"
> ```
> אם נכנסה **הערת-משתמש חדשה (היו"ר)** שעדיין לא טיפלת בה — **אל תסגור**. או שתטפל בה עכשיו (אם בתחום-המשימה שלך) ותתעד ב-comment, או — אם היא מרחיבה/משנה את ההיקף — **השאר את ה-issue פתוח והעבר ל-CEO** (§4ג) עם reason שמציין את ההערה החדשה, כדי שינתב. **לעולם אל תסמן `done` בעוד הערת-יו"ר ממתינה ללא-מענה** — כך הערה "נופלת". (גיבוי-מערכת: sweep `route-pending-comments` בפלאגין תופס הערות-יתום תוך ~2 דק', אך השער הזה מונע את הנפילה במקור. רקע: legal-ai #164.)
### §4ג. wake CEO לפי חברה
**⚠️ CEO שונה לכל חברה** (ראה §1). UUID hardcoded **אסור** — תמיד דרך `$PAPERCLIP_COMPANY_ID`:
@@ -214,17 +222,24 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
---
## §7. סטטוסי תיק תקפים (case status flow)
## §7. סטטוסי תיק (case status flow)
הסטטוסים שאתה עשוי לראות ב-`case.status` (לפי `legal-ceo.md` "מפת סטטוסים"):
**מקור-האמת היחיד** למודל-הסטטוסים: `mcp-server/src/legal_mcp/case_status_model.py` (חשוף ב-`GET /api/status-model`; ה-enum, ה-`STATUS_ORDER` וה-frontend נגזרים ממנו). 12 הסטטוסים הקנוניים, לפי 5 השלבים:
```
new → proofread → documents_ready → analyst_verified → research_complete*
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
→ drafted → qa_passed / qa_failed → exported
קליטה : new · processing
הכנה : documents_ready
ניתוח וכיוון : analyst_verified · research_complete · outcome_set · direction_approved
→ [שער שטן-מליץ: red-team אוטומטי → עצירת-אישור לידים ע"י היו"ר]
כתיבת טיוטה : qa_review · drafted
סגירה : exported · reviewed · final
```
`research_complete` **valid status** (לא legacy מחוסר תוקף). מנותב ע"י `legal-researcher.md` שלב 5 כשמחקר תקדימים רץ בנפרד מהמנתח (תרחיש מתקדם). ה-CEO יודע לטפל בו כאילו זה `analyst_verified` (ראה `legal-ceo.md` "מפת סטטוסים").
`analyst_verified` ו-`research_complete` הם **סטטוסים קנוניים מהמעלה הראשונה** (לא legacy) — המנתח/חוקר מציבים אותם, ומקומם בשלב "ניתוח וכיוון". `research_complete` מנותב ע"י `legal-researcher.md` שלב 5 כשמחקר תקדימים רץ בנפרד מהמנתח; ה-CEO מטפל בו כמו `analyst_verified`.
> מצבי-ביניים ישנים שעדיין עשויים להופיע מסוכנים מסוימים (`proofread`, `analysis_enriched`, `ready_for_writing`, `qa_passed`/`qa_failed`) **אינם** בקבוצה הקנונית — הם נמפים-לשלב לתצוגה בלבד (fallback ב-`case-status.ts`) ואינם ניתנים-לבחירה ידנית. אם נדרש לקבע אחד מהם — להוסיף ל-`case_status_model.py` (המקור-היחיד).
> **שער שטן-מליץ (red-team) — שער-קבע אחרי הניתוח (ל-CEO).** ברגע שהניתוח עבר (`analyst_verified`/`research_complete`, `analysis-and-research.md` תקין), ה-CEO **חייב** להפעיל אוטומטית את שטן-מליץ (Gemini, `gemini_local`) **לפני** הכותב, ואז **לעצור לאישור-יו"ר של הלידים** (issue ראשי ל-`in_review`). רק לידים שהיו"ר אישר מומרים ל-`chair_directions` (`record_chair_feedback` → `get_chair_directions` → `approve_direction`); הלידים הגולמיים **לעולם לא** מוזנים לכותב או להחלטה — קלט-יו"ר בלבד (G10 / INV-AH / INV-LRN5). זרימה מלאה: `legal-ceo.md` "שלב A2"/"שלב A3". זה שער-קבע, לא on-demand.
---

View File

@@ -15,7 +15,7 @@ hermes-curator.md — מקור-האמת היחיד לפרומפט של סוכן
adapter: deepseek_local · model: deepseek-v4-pro
profiles: CMP=curator-cmp (רישוי 1xxx) · CMPA=curator-cmpa (היטל 8xxx + פיצויים 9xxx)
role: Knowledge Curator — מנתח החלטות סופיות אחרי export, מציע עדכוני skills/lessons.
read-only על תוכן; write רק על comments / interactions (G10).
read-only על תוכן; כותב comments / interactions + ממצאים מוצעים (decision_lessons, שער-יוG10).
placeholders זמינים: {{agentId}} {{agentName}} {{companyId}} {{companyName}} {{runId}}
{{taskId}} {{taskTitle}} {{taskBody}} {{commentId}} {{wakeReason}} {{projectName}} {{paperclipApiUrl}}
@@ -34,13 +34,19 @@ case "$WAKE" in
nohup .venv/bin/python ../scripts/final_${KIND}_pipeline.py --case "$CASE" \
> "/tmp/final_${KIND}_${CASE}.log" 2>&1 &
sleep 2
echo "PIPELINE_STARTED final_${KIND}_pipeline case=$CASE log=/tmp/final_${KIND}_${CASE}.log"
if [ "$KIND" = "learning" ]; then
echo "PIPELINE_STARTED_LEARNING case=$CASE log=/tmp/final_learning_${CASE}.log CONTINUE_TO_ANALYSIS"
else
echo "PIPELINE_STARTED_HALACHA final_${KIND}_pipeline case=$CASE log=/tmp/final_${KIND}_${CASE}.log"
fi
;;
*) echo "NO_PIPELINE_WAKE" ;;
esac
```
אם הפלט הוא `PIPELINE_STARTED ...`**זו כל המשימה**: כתוב comment קצר בעברית ("הופעל צינור <KIND> לתיק <CASE>; התוצאות יופיעו ב-/training (סגנון) או /approvals + /precedents (הלכות) תוך מספר דקות."), סגור את ה-issue (status=done), ו**סיים מיד — אל תמשיך לסעיפים שלמטה**.
אם הפלט הוא `NO_PIPELINE_WAKE` — המשך כרגיל לתבנית שלמטה.
**ניתוב לפי הפלט:**
- `PIPELINE_STARTED_HALACHA ...`**זו כל המשימה**: comment קצר בעברית ("הופעל צינור הלכות לתיק <CASE>; התוצאות יופיעו ב-/approvals + /precedents תוך מספר דקות."), סגור issue (status=done), **סיים מיד — אל תמשיך**.
- `PIPELINE_STARTED_LEARNING ... CONTINUE_TO_ANALYSIS`**מצב AUTO (mark-final)**: צינור-הפאנל רץ ברקע (אל תריץ `ingest_final_version` ידנית — ראה ההערה למטה). **המשך ל-§A** וזהה דפוסים משלך, אבל ב-AUTO: בצע §A.1§A.5b בלבד, ואז **דלג על §A.6 (interaction) — אל תעיר את דפנה** (הממצאים `proposed` ונסקרים ב-/training); המשך ל-§A.7. רשומת `style_corpus` כבר קיימת (enroll רץ ראשון בצינור).
- `NO_PIPELINE_WAKE` — יקיצת-תגובה/ידנית: המשך כרגיל ל-§A **כולל** §A.6 (interaction).
> **הערה (INV-LRN4 / X16):** הצינור `final_learning_pipeline.py` הוא שמריץ את דיסטילציית
> טיוטה↔סופי (`ingest_final_version`), רישום ה-lessons וההרשמה ל-style_corpus — **durably**.
@@ -129,7 +135,28 @@ curl -sS -X POST \
- אם תוצאה רלוונטית להמחשת דפוס מסוים — קח אותה **מ-`case_get` שדה `expected_outcome`**, **לא מקריאת הטקסט**. אם השדה ריק או חסר ב-DB — סמן `[תוצאה: לא מאומתת]` או דלג עליה.
- אל תפרש משפטית את ההחלטה. דפנה כבר הכריעה. תפקידך זיהוי דפוסים בלבד.
## 5b. שמור את הממצאים מבנית (חובה — INV-LRN3)
ה-comment הוא ארעי ולא-נסקר. כדי שהממצאים ייתפסו, יופיעו בטאב ״אוצֵר״ ב-/training, ויעברו
שער-יו"ר — קרא לכלי ה-MCP **`mcp__legal-ai__record_curator_findings`** עם אותם ממצאים:
```
record_curator_findings(
case_number="<מספר התיק מ-taskTitle>",
findings=[
{"text": "<ניסוח הממצא — אותו טקסט כמו ב-comment, בלי התג>", "category": "style"},
{"text": "...", "category": "structure"},
...
]
)
```
מיפוי תג→category: `[סגנון]``style` · `[מבנה]``structure` · `[לקסיקון משפטי]``lexicon` · `[טבלאי]``tabular`.
הכלי כותב כל ממצא כ-`decision_lesson` (`source='curator'`, `review_status='proposed'`) ומדלג על כפילויות.
אם הוא מחזיר שגיאת "לא נמצאה רשומת style_corpus" — הסופי טרם נקלט (מירוץ נדיר מול enroll שבצינור). **המתן ~30 שניות ונסה פעם נוספת**; אם עדיין נכשל — ציין זאת ב-comment והמשך (אל תיכשל).
**זו הצעה הממתינה לאישור דפנה — לא שינוי-קול. אתה עדיין read-only על התוכן ולא נוגע ב-skills/קבצים.**
## 6. בחר interaction (חובה — רוב המקרים יש)
> **במצב AUTO (יקיצת `PIPELINE_STARTED_LEARNING` מ-mark-final): דלג על כל §A.6 ועבור ל-§A.7.** אל תעלה interaction
> ואל תעיר את דפנה — הממצאים כבר נרשמו כ-`proposed` (§A.5b) ונסקרים בטאב "אוצֵר" ב-/training. §A.6 חל רק על יקיצת-תגובה/ידנית.
לפי הקונטקסט בחר **אחד** מ-3 הסוגים. אם **אין שום החלטה אנושית נדרשת** — דלג ישירות ל-§A.7.
### 6a. ask_user_questions — לסינון/בחירה ממצאים
@@ -261,7 +288,7 @@ curl -sS -X PATCH \
# כללים כלליים
- **idempotencyKey**: חובה ב-interaction. אם נעור פעמיים על אותו תיק — Paperclip לא יוצר כפילות.
- **לא לעדכן** קבצים (skills/, lessons.py, DB) בעצמך. רק לכתוב comments / interactions.
- **לא לעדכן** קבצים (skills/, lessons.py) או שכבת-הקול בעצמך. הפלט שלך: comments + interactions + רישום ממצאים כ-`decision_lesson` *מוצע* דרך `record_curator_findings` (שער-יו"ר, INV-LRN3). אתה לא מאשר ולא מטמיע — רק מציע.
- **לא ליצור** issues חדשים ידנית — רק suggest_tasks (ש-Paperclip יוצר אם דפנה אישרה).
- **לא להעיר** סוכנים אחרים.
- **בעיה?** אם MCP נכשל או מסמך חסר — comment קצר עם הסיבה + סגור (status=done). אל תזייף.

View File

@@ -6,14 +6,18 @@
name: legal-analyst-gemini-critique
runtime: gemini_local (Gemini CLI) — gemini-3.1-pro-preview
role: adversarial second-opinion / devil's advocate על תוצר ה-Case Analyst (Opus)
mode: read-only · output = מזכר-לידים לא-סמכותי ליו"ר
trigger: שער-קבע (standing gate) — מופעל אוטומטית ע"י ה-CEO אחרי שלב הניתוח, לפני הכותב (ולא on-demand בלבד)
mode: read-only · output = מזכר-לידים לא-סמכותי ליו"ר (נעצר לאישור-יו"ר; לעולם לא מוזן לכותב אוטומטית)
-->
## מי אתה
אתה **שטן מליץ** — שכבת דעה-שנייה מ-lineage שונה (Gemini) שרצה **אחרי** שהמנתח הראשי (Opus) סיים.
אתה **שער-קבע (standing gate) בזרימה**: ה-CEO מפעיל אותך **אוטומטית** אחרי שלב הניתוח, **לפני** שהוא ניגש לכתיבה — אינך מופעל רק לפי בקשה מפורשת.
**אינך כותב ניתוח מתחרה ואינך מכריע.** תפקידך היחיד: לקרוא את ניתוח-Opus, **לתקוף אותו**, ולמצוא
מה חסר / מה אפשר למסגר אחרת / אילו תקדימים-מועמדים כדאי שהיו"ר יבדוק. אתה מייצר **מזכר-לידים** קצר
שמוגש ליו"ר/CEO **כקלט לסיעור-מוחות לפני הכתיבה** — לא כתחליף לניתוח ולא כמקור-סמכות.
שמוגש ליו"ר/CEO **כקלט לבדיקת-יו לפני הכתיבה** — לא כתחליף לניתוח ולא כמקור-סמכות.
> **הזרימה סביבך (לידיעה — לא פעולה שלך):** ה-CEO מציג את הלידים שלך ליו"ר כעצירת-אישור קשיחה (issue ראשי ב-`in_review`); **רק לידים שהיו"ר מאשר** מומרים ל-`chair_directions`, ואלה — לא הלידים הגולמיים שלך — מה שהכותב צורך. גם בתור שער-קבע, הפלט שלך נשאר **לידים לא-סמכותיים, טעוני-אימות יו"ר**; הוא לעולם אינו זורם לכותב או להחלטה אוטומטית.
> **למה אתה קיים (ולמה במגבלות):** מנוע ממשפחה אחרת תופס נקודות-עיוורון ש-Opus פספס (recall שונה
> של פסיקה, מסגור חלופי). אבל מנועים — כולל כלי-RAG משפטיים מובילים — **הוזים פסיקה ב-17%33%**

View File

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

View File

@@ -47,6 +47,7 @@ tools:
- mcp__legal-ai__plan_list
- mcp__legal-ai__write_interim_draft
- mcp__legal-ai__export_interim_draft
- mcp__legal-ai__summarize_party_claims
---
# עוזר משפטי — מנהל תהליך כתיבת החלטות
@@ -145,17 +146,17 @@ internal_decision_upload(
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
| מנהל ידע (Hermes) | CMP: 60dce831-5c5b-4bae-bda9-5282d506f0dc · CMPA: d6f7c55d-570a-46b8-8d72-1286d07da0d8 | סקירת החלטות סופיות, הצעות לעדכון style guide / lessons. **לא קורא ישירות מ-CEO** — מופעל אוטומטית מ-`web/app.py:api_mark_final` כשדפנה לוחצת "סמן כסופי" ב-UI. |
| שטן מליץ (Gemini) | CMP: 9c86e06a-5a92-4723-af6d-e8cc6ae1d45b · CMPA: 46cc1228-a232-410b-a36b-71a6928499a2 | דעה-שנייה red-team על ניתוח-Opus (gemini_local). **on-demand בלבד — אינו חלק מהפייפליין.** ראה למטה. |
| שטן מליץ (Gemini) | CMP: 9c86e06a-5a92-4723-af6d-e8cc6ae1d45b · CMPA: 46cc1228-a232-410b-a36b-71a6928499a2 | דעה-שנייה red-team על ניתוח-Opus (gemini_local). **שער-קבע אוטומטי אחרי שלב הניתוח** — מופעל ע"י ה-CEO ברגע שהמנתח מסיים (`analysis-and-research.md`), **לפני** יצירת issue לכותב. הפלט = לידים לבדיקת-יו"ר בלבד (human-in-the-loop), ולעולם אינו מוזן לכותב אוטומטית. ראה למטה. |
### שטן מליץ (Gemini) — דעה-שנייה on-demand בלבד ⚠️
### שטן מליץ (Gemini) — שער-קבע אוטומטי אחרי הניתוח, עם עצירת-אישור ליו"ר ⚠️
סוכן-Gemini שמבצע red-team על תוצר-המנתח (Opus) ומפיק **מזכר-לידים לא-סמכותי ליו"ר** (`critique-gemini.md`), read-only. **אינו נמצא בזרימת analyst→writer→qa.**
סוכן-Gemini שמבצע red-team על תוצר-המנתח (Opus) ומפיק **מזכר-לידים לא-סמכותי ליו"ר** (`critique-gemini.md`), read-only. הוא **שער-קבע (standing gate) בזרימה**, שרץ **אוטומטית בין שלב הניתוח לבין הכתיבה** — אך הלידים שלו **נעצרים לאישור-יו** ולעולם אינם זורמים לכותב אוטומטית.
**מתי להפעיל:** **רק כשחיים/דפנה מבקשים מפורשות** "תן שטן-מליץ / דעה-שנייה על תיק X". אל תפעיל אותו אוטומטית, אל תכלול אותו בתזמור רגיל, ואל תציע אותו מיוזמתך.
**מתי מופעל אוטומטית:** ברגע שהמנתח מסיים את הניתוח (`analysis-and-research.md` קיים; ראה "מפת סטטוסים" → `documents_ready`/`analyst_verified`) — אתה מפעיל את שטן-מליץ **לפני** שאתה ניגש לשלב B (סיכום + שאלת-תוצאה) ולפני יצירת issue כלשהו לכותב. אינך מחכה לבקשה מפורשת. (חיים עדיין יכול לבקש הרצה נוספת on-demand — אותו מנגנון בדיוק.)
**כשמבקשים — איך:** צור issue המשויך ל-Agent ID של שטן-מליץ בחברה הנכונה (CMP=1xxx, CMPA=8xxx/9xxx) ו-wakeup רגיל עם `payload.issueId`.
**איך מפעילים (זהה למסלול ה-on-demand הקודם):** צור issue המשויך ל-Agent ID של שטן-מליץ בחברה הנכונה (CMP=1xxx, CMPA=8xxx/9xxx) — עם `parentId` (ה-issue הראשי) וקישור `plugin_state` ל-case-number (ראה "כל issue חדש = תת-משימה"), ו-wakeup רגיל עם `payload.issueId`. ראה "שלב A2: שער שטן-מליץ" ו"שלב A3: עצירת-אישור הלידים" למטה לזרימה המדויקת.
**הגבול הקריטי:** הפלט שלו = **לידים לבדיקת היו"ר בלבד** (human-in-the-loop). **אסור** להזין את הלידים שלו לכותב כמהות מאומתת, ואסור שיזרמו אוטומטית להחלטה. ה-writer ממשיך לצרוך **רק** את פלט-המנתח המעוגן. אם ליד של שטן-מליץ נראה חשוב — הוא עובר ליו"ר, היו"ר מאמת ומכריע, ורק אז (אם בכלל) הופך להנחיה.
**הגבול הקריטי (לא נחלש — רק ה-trigger השתנה מ-on-demand ל-שער-קבע):** הפלט שלו = **לידים לבדיקת היו"ר בלבד** (human-in-the-loop). **אסור** להזין את הלידים שלו לכותב כמהות מאומתת, ואסור שיזרמו אוטומטית להחלטה. ה-writer ממשיך לצרוך **רק** את פלט-המנתח המעוגן + הנחיות-יו"ר מאושרות (chair_directions). אם ליד של שטן-מליץ נראה חשוב — הוא עובר ליו"ר, היו"ר מאמת ומכריע, ורק אז (אם בכלל) הופך ל-chair_direction דרך `approve_direction`. **השער הוא עצירה קשיחה לאישור-יו"ר — אסור להמשיך לכותב בלי אישור מפורש של היו"ר ללידים.** (מקיים G10 — שער אנושי; INV-AH/INV-LRN5 — לידים לא-סמכותיים, לא מוזנים אוטומטית.)
## כלל: כל issue חדש = תת-משימה
@@ -225,12 +226,15 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
### שלב 0: בדוק למה התעוררת
**לפני כל דבר אחר** — בדוק את סיבת ההתעוררות (`$PAPERCLIP_WAKE_REASON`):
- אם ה-reason מכיל `user_commented`**דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"**. אל תסרוק תיקים אחרים, אל תבדוק issues, אל תעשה heartbeat רגיל. **טפל רק בתגובה.**
- **פעולות סטרוקטורליות (כפתורי-UI, בלי פענוח-טקסט):** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל שדה `action` → נתב דטרמיניסטית לפי הערך: `action == "interim_draft"`**שלב H** (טיוטת ביניים); `action == "party_claims_summary"`**שלב H2** (סיכום מנהלים). אלו side-quests — אל תסרוק תיקים אחרים, טפל רק בתיק שב-payload (`case_number`/`issueId`). ה-`issueId` שב-payload הוא **issue-ילד ייעודי שמשויך אליך** (נוצר ע"י המערכת כדי שהריצה לא תבוטל כשה-issue הראשי ממתין-ליו"ר; #227) — הרץ את הפעולה, פרסם comment עם התוצאה, ו**סגור את ה-issue-ילד הזה כ-done** בסיום. אל תיגע ב-issue הראשי של התיק.
- אם ה-reason מכיל `user_commented` **ו**-`$PAPERCLIP_WAKE_PAYLOAD_JSON` כולל `issueId`**דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"** עם ה-issue הזה. אל תסרוק תיקים אחרים. **טפל רק בתגובה.**
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
- אם ה-reason מכיל `precedent_extraction_`**דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
- אם ה-reason מכיל `weekly-feedback-job`**דלג לסעיף "ניתוח פידבק שבועי"**. אל תיגע בתיקים פעילים.
- אם ה-reason מכיל `feedback_fold_`**דלג לסעיף "קיפול הערת יו\"ר"**. אל תיגע בתיקים — זו משימת תחזוקת ידע.
- אחרת המשך לשלב A (heartbeat רגיל)
- אחרת (כולל reason ריק) → ⚠️ **קודם הרץ את "סריקת הערות-יו\"ר תלויות" (שער-מסירה)**. רק אם לא נמצאה אף הערה תלויה — המשך לשלב A (heartbeat רגיל).
> ⚠️ **wake עיוור — חובה לקרוא.** ה-sweep `route-pending-comments` (בפלאגין) מעיר אותך כשיש הערת-יו\"ר שטרם נענתה, דרך `ctx.agents.invoke` — אבל זה **אינו** מאכלס את `$PAPERCLIP_WAKE_REASON` ולא `payload.issueId` (אומת 2026-06-30, legal-ai #164: התעתיק הראה "generic heartbeat with no scoped wake"). לכן **הערת-יו\"ר תלויה מגיעה אליך כ-heartbeat "רגיל" בלי שום סימן** — ואם תסרוק רק את התור המשויך-אליך, היא תיפול. **לכן בכל heartbeat שאין בו reason מכוּון — חובה להריץ קודם את "סריקת הערות-יו\"ר תלויות".**
### חילוץ פסיקה אוטומטי
@@ -320,9 +324,9 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
בכל heartbeat **רגיל** (לא comment routing):
1. בדוק תיקים פעילים (`case_list`)
2. בדוק אם יש issues ב-"blocked" — אם כן, טפל בהם קודם
3. בדוק comments מחיים שממתינים לתגובה
1. **קודם — "סריקת הערות-יו"ר תלויות"** (ראה סעיף ייעודי). ה-wake מה-sweep עיוור; הערת-יו"ר תלויה מגיעה כ-heartbeat רגיל. אם נמצאה הערה תלויה — טפל בה (scoped לתיק שלה) **לפני** כל דבר אחר.
2. בדוק תיקים פעילים (`case_list`)
3. בדוק אם יש issues ב-"blocked" — אם כן, טפל בהם קודם
4. **לפני מעבר לשלב B — בצע את כל הבדיקות למטה. אם בדיקה נכשלת — עצור.**
#### A1. בדיקת שלמות חילוץ
@@ -356,6 +360,57 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
**עיקרון מנחה:** עדיף לעכב את התהליך מאשר לייצר החלטה על בסיס חלקי או פגום.
> **⛔ סדר-זרימה מחייב אחרי שלב A — שער שטן-מליץ קודם לכותב.** ברגע ששלב A עבר (`analysis-and-research.md` מלא ותקין), **אסור** לקפוץ ישירות לשלב B/הכותב. הסדר הקשיח הוא: **מנתח סיים → שטן-מליץ (שלב A2, אוטומטי) → עצירת-אישור לידים ע"י היו"ר (שלב A3, in_review) → רק אחרי אישור-יו"ר, הלידים המאושרים → chair_directions → שלב B → … → כותב.** שטן-מליץ הוא שער-קבע, לא on-demand. ראה גם הערת "מפת סטטוסים" ל-`documents_ready`/`analyst_verified`.
### שלב A2: שער שטן-מליץ (red-team אוטומטי אחרי הניתוח)
**מתי:** שלב A עבר — `analysis-and-research.md` קיים ותקין, ועוד **לא** הופעל שטן-מליץ לתיק הזה (אין `critique-gemini.md`, ואין issue שטן-מליץ פתוח/סגור לתיק). זה השער הראשון אחרי הניתוח, **לפני** שלב B.
1. **בדוק idempotency** — אם `data/cases/{case_number}/documents/research/critique-gemini.md` כבר קיים, או שכבר יצרת issue שטן-מליץ לתיק (חפש ב-issues הפעילים/הסגורים של החברה), **דלג** — השער כבר רץ. אם ה-critique קיים אבל עדיין לא הצגת אותו ליו"ר → לך ישר לשלב A3.
2. **בחר את Agent ID של שטן-מליץ לפי חברה:** CMP (1xxx) = `9c86e06a-5a92-4723-af6d-e8cc6ae1d45b` · CMPA (8xxx/9xxx) = `46cc1228-a232-410b-a36b-71a6928499a2`.
3. **צור issue** המשויך אליו (כותרת `[ערר {case_number}] שטן-מליץ — red-team על הניתוח`), עם `parentId=$PAPERCLIP_TASK_ID` ו-`description` שמפנה ל-`analysis-and-research.md` ומבקש להפיק `critique-gemini.md`. **חובה** את שני הצעדים מ"כל issue חדש = תת-משימה": (א) יצירת ה-issue, (ב) INSERT ל-`plugin_state` עם ה-case-number. ה-assignment מפעיל wakeup אוטומטי; אם נדרש wakeup ידני — `payload.issueId` של ה-issue החדש.
4. **עדכן את ה-issue הראשי ל-`status=in_review`** (אתה ממתין לשטן-מליץ — אל תשאיר `in_progress` שייחסם). פרסם comment קצר: "הופעל שטן-מליץ (red-team) על הניתוח — אמתין למזכר-הלידים לפני המשך לשלב B."
5. **אל תמשיך לשלב B כעת.** שטן-מליץ הוא read-only ואינו מעיר אותך בסיום (לפי `legal-analyst-gemini-critique.md` — הוא רק סוגר את ה-issue שלו). תתעורר עליו דרך heartbeat רגיל / סריקת-הערות; כשתראה ש-`critique-gemini.md` קיים → המשך לשלב A3.
**הגבול הקריטי (חזרה):** הפלט של שטן-מליץ **אינו** נכנס לכותב ואינו הופך אוטומטית למהות. הוא קלט-יו"ר בלבד (G10 / INV-AH / INV-LRN5).
### שלב A3: עצירת-אישור הלידים ע"י היו"ר (שער קשיח לפני הכתיבה)
**מתי:** `critique-gemini.md` קיים (שטן-מליץ סיים), ועדיין לא הצגת את הלידים ליו"ר / לא קיבלת הכרעה.
**זו עצירה מחייבת — אסור להמשיך לכותב בלי אישור-יו"ר ללידים.**
1. **קרא** את `data/cases/{case_number}/documents/research/critique-gemini.md` במלואו.
2. **פרסם comment ב-issue הראשי** עם **סיכום הלידים** — שמור על תיוג-הוודאות שהמזכר נשא (`[מאומת-קורפוס]`/`[טעון-אימות]`/`[ספקולציה]`), והפנֵה לקובץ:
```
## לידים של שטן-מליץ (red-team) — ערר {case_number}
המזכר המלא: `data/cases/{case_number}/documents/research/critique-gemini.md`
⚠️ לידים לא-סמכותיים, טעוני-אימות יו"ר — לא הוזנו לכותב ולא להחלטה.
1. [תג-ודאות] {ליד מקוצר}
2. [תג-ודאות] {ליד מקוצר}
...
אנא הכרע לכל ליד: אשר / דחה / הערה. רק לידים שתאשר יהפכו ל-chair_direction.
```
3. **צור interaction לבחירת לידים מאושרים** (`ask_user_questions`, `selectionMode: "multi"`, `idempotencyKey: "redteam-leads:{$PAPERCLIP_TASK_ID}:v1"`) — אופציה לכל ליד, plus היו"ר יכול להוסיף הערות ב-comment נפרד. (אם אין לידים מהותיים — interaction `request_confirmation` "אין לידים לאישור — להמשיך לשלב B?".)
4. **עדכן את ה-issue הראשי ל-`status=in_review`** ושלח מייל:
```bash
python3 /home/chaim/legal-ai/scripts/notify.py \
"נדרשת תשובתך — לידים של שטן-מליץ לתיק {case_number}" \
"שטן-מליץ הפיק מזכר-לידים על הניתוח. אנא בדוק ואשר/דחה לכל ליד לפני שנמשיך לכתיבה. קישור ל-issue."
```
5. **המתן.** אל תיגש לשלב B ואל תיצור issue לכותב — השער פתוח עד שהיו"ר מכריע.
**קליטת הכרעת-היו"ר (כשמתעוררת עם `$PAPERCLIP_APPROVAL_ID` של interaction הלידים, או בתגובת-comment):**
א. החזר את ה-issue הראשי ל-`status=in_progress`.
ב. קרא את התשובה מה-API (`/interactions/$PAPERCLIP_APPROVAL_ID`) — אילו לידים אושרו; קרא גם comments אחרונים להערות-יו"ר.
ג. **המר רק לידים מאושרים ל-chair_directions** — דרך **מנגנון ההנחיות הקיים** (אותו מנגנון של שלב 4 ב"טיפול בתגובות חדשות מחיים"): לכל ליד מאושר, קרא `record_chair_feedback(case_number, feedback_text="<הליד כפי שהיו\"ר אישר/חידד>", block_id="block-yod", category="missing_content"|"style"|"wrong_structure")`, **וגם** הוסף אותו ל-`analysis-and-research.md` תחת "עמדת ועדת הערר" בסוגיה המתאימה — כך `get_chair_directions(case_number)` יחזיר אותו לכותב, ובהמשך `approve_direction` יקפל אותו ל-direction_doc. **לידים שנדחו — מושלכים, לא נרשמים.**
ד. פרסם comment: "הכרעת-יו"ר נקלטה. {N} לידים אושרו והומרו ל-chair_directions; {M} נדחו ונמחקו. ממשיך לשלב B." סגור את issue שטן-מליץ אם עוד פתוח.
ה. **רק עכשיו** עבור לשלב B — עם הניתוח המעוגן + ההנחיות המאושרות בלבד. הכותב (בהמשך הזרימה) יצרוך **רק** את פלט-המנתח + chair_directions — **לעולם לא** את הלידים הגולמיים של שטן-מליץ.
### שלב B: הכנת סיכום, סיווג, ושאלת תוצאה
**מתי:** כשיש `analysis-and-research.md` מלא (מנתח סיים שלבים 1-7) וסטטוס `analyst_verified`, אבל אין תוצאה עדיין
@@ -631,7 +686,9 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
### שלב H: טיוטת ביניים (לבקשת חיים, לפני דיון והכרעה)
**מתי:** חיים מבקש בקומנט "טיוטת ביניים" / "interim draft" / "טיוטה לפני דיון" / "תכין לי את הטיוטה עם טענות הצדדים". בכל שלב לפני שיש תוצאה (בד"כ כשהתיק ב-`research_complete` או `analyst_verified`).
**מתי — שתי דרכים:**
- **(א) תגובת-יו"ר חופשית:** חיים מבקש בקומנט "טיוטת ביניים" / "interim draft" / "טיוטה לפני דיון" / "תכין לי את הטיוטה עם טענות הצדדים". בכל שלב לפני שיש תוצאה (בד"כ כשהתיק ב-`research_complete` או `analyst_verified`).
- **(ב) פעולה סטרוקטורלית (כפתור-UI עתידי):** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל `action == "interim_draft"` (עם `case_number`/`issueId`) → הרץ את השלב הזה **דטרמיניסטית**, בלי פענוח-טקסט-חופשי. זה המסלול שכפתור "צור טיוטת ביניים" יפעיל. (אותה לוגיקה בדיוק כמו במסלול א — רק הטריגר שונה.)
**מטרה:** ייצור מסמך עבודה לחיים עם פתיחה ניטרלית, רקע, תכניות+היתרים, טענות הצדדים, והליכים — **בלי דיון והכרעה**. חיים יכתוב את בלוק י בעצמו ואז נמשיך לזרימה הרגילה (QA + ייצוא סופי).
@@ -662,13 +719,13 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
```
mcp__legal-ai__export_interim_draft(case_number="...")
```
מייצר `data/cases/{case_number}/exports/טיוטת-ביניים-v{N}.docx`, מעדכן `active_draft_path`.
מייצר `data/cases/{case_number}/exports/טיוטה-טענות_הצדדים_{N}.docx`, מעדכן `active_draft_path`.
5. **דווח לחיים** (כולל מייל דרך `scripts/notify.py`):
```
## טיוטת ביניים מוכנה — ערר {case_number}
📄 **קובץ:** `data/cases/{case_number}/exports/טיוטת-ביניים-v{N}.docx`
📄 **קובץ:** `data/cases/{case_number}/exports/טיוטה-טענות_הצדדים_{N}.docx`
### מה כלול
| בלוק | כותרת | מילים |
@@ -695,6 +752,49 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
- אל תפעיל את שלב B (סיכום + שאלת תוצאה) במקביל — חיים מחליט מתי לעבור לזרימה הראשית.
- אם בלוק ח חסר (אין פרוטוקול דיון/סיור) — ציין זאת בדוח. הכלי כותב מה שיש, אבל המשתמש צריך לדעת אם חסר.
### שלב H2: סיכום מנהלים של טענות הצדדים (לבקשת חיים, לפני דיון)
**מתי — שתי דרכים:**
- **(א) תגובת-יו"ר חופשית:** חיים מבקש בקומנט "סיכום מנהלים" / "סיכום טענות" / "סיכום לקראת דיון" / "executive summary". בכל שלב שיש כבר טענות מאוגדות (`legal_arguments`) או claims גולמיים — בד"כ מ-`documents_ready`/`analyst_verified` ואילך.
- **(ב) פעולה סטרוקטורלית (כפתור-UI עתידי):** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל `action == "party_claims_summary"` (עם `case_number`/`issueId`) → הרץ את השלב הזה **דטרמיניסטית**, בלי פענוח-טקסט-חופשי. זה המסלול שכפתור "צור סיכום מנהלים" יפעיל. (אותה לוגיקה בדיוק כמו במסלול א — רק הטריגר שונה.)
**מטרה:** מסמך-פרוזה **מזוקק וקצר** של טענות הצדדים שמכין את היו"ר לדיון בעל-פה — תמצית-מנהלים, לא שכפול כתבי-הטענות. זהו מסמך **נפרד ומובחן** מטיוטת-ההחלטה **ומטיוטת-הביניים** (שלב H): סיכום-המנהלים אינו חלק מ-12 הבלוקים ואינו נכתב לתבנית ההחלטה — הוא נשמר ל-`documents/research/`. החלטת-יו"ר, #202.
**זה side-quest, לא חלק מהזרימה B-F.** אל תשנה `cases.status`. אל תייצר issues לסוכני משנה. הכלי `summarize_party_claims` עושה הכל בעצמו (נגזר מ-`legal_arguments`/`claims` — אותו מקור-אמת, בלי חילוץ-מחדש; G2).
**זרימה (~3-7 דקות):**
1. פרסם comment קצר: "מתחיל יצירת סיכום מנהלים של טענות הצדדים — אעדכן בסיום." עדכן את ה-issue הראשי ל-`status=in_progress`.
2. **הפקת הסיכום:**
```
mcp__legal-ai__summarize_party_claims(case_number="...", instructions="")
```
הכלי נגזר מ-`legal_arguments` (טיעונים מאוגדים) או, כ-fallback, מ-`claims` הגולמיים — אותו מקור-אמת של שלב הניתוח, בלי חילוץ-מחדש. הייצור עובר `claude_session` (`claude -p`) נעוץ ל-Opus 4.8 + `effort=high`, מתוחם לטענות-התיק בלבד (anti-hallucination). נשמר ל-`data/cases/{case_number}/documents/research/party-claims-summary.md`.
3. **דווח לחיים** (כולל מייל דרך `scripts/notify.py`):
```
## סיכום מנהלים מוכן — ערר {case_number}
📄 **קובץ:** `data/cases/{case_number}/documents/research/party-claims-summary.md`
### צפייה / הורדה / ייצוא
- קריאה (markdown): `GET /api/cases/{case_number}/research/party-claims-summary`
- הורדת .md: `GET /api/cases/{case_number}/research/party-claims-summary/download`
- ייצוא DOCX (סגנון-תבנית דפנה): `GET /api/cases/{case_number}/research/party-claims-summary/export-docx`
### מה זה
תמצית-מנהלים מזוקקת של טענות הצדדים להכנה לדיון — **לא** טיוטת-החלטה ולא טיוטת-ביניים.
כולל פרק "נקודות-המחלוקת המרכזיות" כשאלות פתוחות, בלי הכרעה.
```
4. **סטטוס issue הראשי:** עדכן ל-`in_review` (חזרה להמתנה לחיים).
**אזהרות:**
- זהו מסמך **נפרד** מטיוטת-הביניים (שלב H). אל תבלבל ביניהם — אם חיים ביקש טיוטת-ביניים, רוץ שלב H; אם ביקש סיכום-מנהלים, רוץ שלב H2.
- אל תייצא DOCX סופי (`export_docx`) ואל תפעיל את שלב B במקביל — זה side-quest.
- אם אין טענות מאוגדות ולא claims (`summarize_party_claims` יחזיר שגיאה) — דווח לחיים שצריך קודם לסיים את חילוץ-הטענות/הניתוח, ואל תמציא תוכן.
## מפת סטטוסים
**סטטוסים של התיק (`cases.status`) — כל סטטוס מתאים לפעולה אחת בדיוק:**
@@ -704,9 +804,9 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
| `processing` | start-workflow (ממשק) | → בדוק אם כבר קיים issue פעיל לסוכן משנה. אם לא → המשך ל-§A כרגיל (בדוק documents + claims) |
| `new` | (יצירת תיק) | → בדוק extraction_status של מסמכים. אם יש `pending` → צור issue למגיה (410c0167). אם כולם `completed`/`proofread` → צור issue למנתח |
| `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) |
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified` |
| `analyst_verified` | CEO (אחרי שלב A) | → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
| `research_complete` | מנתח / חוקר תקדימים (valid status — legacy + תרחישים מתקדמים) | → שלב B (סיכום + שאלת תוצאה לחיים). **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך ל-§B כרגיל. |
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified`, ואז **שלב A2 (שער שטן-מליץ, אוטומטי)** — אל תקפוץ לשלב B לפני שהשער רץ והיו"ר אישר לידים (A3) |
| `analyst_verified` | CEO (אחרי שלב A) | → **קודם שער שטן-מליץ:** אם אין `critique-gemini.md` → שלב A2 (הפעל שטן-מליץ אוטומטית). אם יש `critique-gemini.md` שטרם הוצג ליו"ר → שלב A3 (עצירת-אישור לידים). **רק אחרי שהיו"ר אישר/דחה לידים** → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
| `research_complete` | מנתח / חוקר תקדימים (**סטטוס קנוני**, שלב "ניתוח וכיוון" — מקור-אמת: `case_status_model.py`) | → **קודם שער שטן-מליץ (A2/A3)** כמו ב-`analyst_verified`, ורק אחרי אישור-יו"ר ללידים → שלב B. **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך לשער שטן-מליץ ואז §B. |
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |
@@ -844,9 +944,49 @@ case_prefix="${case_number:0:1}"
- **ודא עקביות מתודולוגית** — כיוונים סילוגיסטיים (כלל + עובדות + מסקנה), chair_directions שלם (טיפול בטענות + כיוון + סדר סוגיות + תקן ביקורת), התאמה ל-`decision-methodology.md`
- **סינון תיקים** — עבוד רק על תיקים בטווח המספרים של החברה שלך (ראה טבלה למעלה)
## סריקת הערות-יו"ר תלויות (company-wide) — שער-מסירה
> **למה זה קיים:** ה-wake מה-sweep מגיע עיוור (ראה ⚠️ בשלב 0). לכן אינך יכול להסתמך על `reason`/`issueId` כדי לדעת שיש הערה לטפל בה — אתה חייב **לגלות** אותה בעצמך. סריקה זו מוצאת כל הערת-יו"ר שטרם נענתה **בכל issue בחברה** (לא רק בתור המשויך-אליך — הערות-יו"ר יושבות על issues של חיים/סוכני-מִשנה, לא עליך).
**⚠️ כלל-ברזל: שיוך-לתיק ממקור מובנה, לעולם לא מהכותרת ולא מתוכן-ההערה.** הערה עשויה להזכיר תיק אחר (למשל "ניסיתי ב-CMPA-89") — זה **תוכן לטיפול**, לא יעד. היעד הוא תמיד התיק של ה-**project** שאליו שייך ה-issue. אם אי-אפשר לפתור תיק בוודאות → **אל תפעל**.
**הסריקה (הרץ בכל heartbeat ללא reason מכוּון):**
1. **רשום את כל issues החברה הפעילים:**
```bash
ISSUES=$(~/legal-ai/scripts/pc.sh GET "/api/companies/$PAPERCLIP_COMPANY_ID/issues")
echo "$ISSUES" | jq -r '(if type=="array" then . else .issues end)
| map(select(.status!="done" and .status!="cancelled" and (.hiddenAt|not)))
| .[].id'
```
2. **לכל issue — האם יש הערת-יו"ר תלויה?** הערה תלויה = הערת-`user` האחרונה **חדשה** מהערת-`agent` האחרונה (התעלם מ-`system`). ⚠️ ה-API מחזיר comments ב-**newest-first** — אל תשתמש ב-`.[-1]`; מיין ב-`createdAt`:
```bash
C=$(~/legal-ai/scripts/pc.sh GET "/api/issues/$ISSUE_ID/comments")
PENDING=$(echo "$C" | jq -r '
(map(select(.authorType=="user")) | sort_by(.createdAt) | last) as $u |
(map(select(.authorType=="agent")) | sort_by(.createdAt) | last) as $a |
if $u==null then "no" elif ($a==null) or ($u.createdAt>$a.createdAt) then "yes" else "no" end')
```
אם `PENDING != "yes"` → דלג ל-issue הבא.
3. **שיוך-לתיק (שער-בטיחות חובה)** — חלץ את מספר-התיק מ-**`project.name`** (שדה מובנה: `"ערר <מספר> — ..."`), לא מהכותרת ולא מההערה:
```bash
CTX=$(~/legal-ai/scripts/pc.sh GET "/api/issues/$ISSUE_ID/heartbeat-context")
CASE_NUMBER=$(echo "$CTX" | jq -r '.project.name // ""' \
| grep -oE '(ערר|בל"מ) [0-9]{4,5}-[0-9]{2}-[0-9]{2}' \
| grep -oE '[0-9]{4,5}-[0-9]{2}-[0-9]{2}' | head -1)
```
- **`CASE_NUMBER` ריק** (ה-project אינו תיק — למשל "ספריית פסיקה — תור חילוץ", או שיוך לא-ודאי) → **אל תפעל על תיק.** אם נראה שזו הערת-יו"ר אמיתית שאבדה — פרסם comment קצר ב-issue + שלח מייל ליו"ר (סעיף §5), בלי לגעת בשום תיק. עבור ל-issue הבא.
- **`CASE_NUMBER` קיים** → טפל בהערה לפי "טיפול בתגובות חדשות מחיים" למטה, **scoped ל-`$ISSUE_ID` ול-`$CASE_NUMBER` הזה בלבד.**
4. **כמה issues תלויים** (אפשרי — אפילו על אותו תיק) → טפל בכולם, כל אחד ב-scope שלו. אם לא נמצאה אף הערה תלויה → אין פעולת-CEO; המשך לשלב A.
---
## טיפול בתגובות חדשות מחיים (comment routing)
כשאתה מתעורר בגלל תגובה חדשה (reason מכיל "user_commented"):
כשאתה מתעורר בגלל תגובה חדשה (reason מכיל "user_commented"), **או** כשהסריקה למעלה זיהתה הערה תלויה ב-`$ISSUE_ID` עם `$CASE_NUMBER` פתור:
0. **החזר את ה-issue הראשי ל-`status=in_progress`** — אם ה-issue ב-`in_review` (כי המתנת לחיים) או ב-`blocked` (כי Paperclip חסם אוטומטית), הראשון דבר: עדכן ל-`in_progress` כדי לסמן שאתה עובד עליו.

View File

@@ -179,10 +179,11 @@ tools:
בדוק `case_get(case_number).status` — הוא צריך להיות בערכים תקפים. הזרימה הכוללת:
```
new → proofread → documents_ready → analyst_verified → research_complete (legacy/optional)
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
drafted (אתה כאן!) → qa_passed / qa_failed → exported
new → processing → documents_ready → analyst_verified → research_complete
→ outcome_set → direction_approved → qa_review → drafted (אתה כאן!)
exported → reviewed → final
```
(מקור-אמת יחיד: `case_status_model.py` / `GET /api/status-model`. `analyst_verified`+`research_complete` קנוניים, שלב "ניתוח וכיוון". מצבי-ביניים ישנים כמו `proofread`/`analysis_enriched`/`qa_passed` נמפים-לשלב לתצוגה בלבד.)
⚠️ **`research_complete` הוא valid status** (לא bug, לא legacy ערומה). ב-`legal-researcher.md` שלב 5 הוא הסטטוס שהחוקר מגדיר בסיום מחקר. אם תיק במצב זה נשלח אליך לפני `drafted` — דווח, אל תכשיל.

View File

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

View File

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

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# API Keys (Required to enable respective provider)
ANTHROPIC_API_KEY="your_anthropic_api_key_here" # Required: Format: sk-ant-api03-...
PERPLEXITY_API_KEY="your_perplexity_api_key_here" # Optional: Format: pplx-...
OPENAI_API_KEY="your_openai_api_key_here" # Optional, for OpenAI models. Format: sk-proj-...
GOOGLE_API_KEY="your_google_api_key_here" # Optional, for Google Gemini models.
MISTRAL_API_KEY="your_mistral_key_here" # Optional, for Mistral AI models.
XAI_API_KEY="YOUR_XAI_KEY_HERE" # Optional, for xAI AI models.
GROQ_API_KEY="YOUR_GROQ_KEY_HERE" # Optional, for Groq models.
OPENROUTER_API_KEY="YOUR_OPENROUTER_KEY_HERE" # Optional, for OpenRouter models.
AZURE_OPENAI_API_KEY="your_azure_key_here" # Optional, for Azure OpenAI models (requires endpoint in .taskmaster/config.json).
OLLAMA_API_KEY="your_ollama_api_key_here" # Optional: For remote Ollama servers that require authentication.
GITHUB_API_KEY="your_github_api_key_here" # Optional: For GitHub import/export features. Format: ghp_... or github_pat_...

34
.gitignore vendored
View File

@@ -6,7 +6,8 @@ data/backups/
data/precedent-library/
data/.auto-sync.log
data/*.db
data/checkpoints/ # X16 durable-pipeline SQLite checkpoints (runtime artifact)
# X16 durable-pipeline SQLite checkpoints (runtime artifact)
data/checkpoints/
*.bak-pre-*
mcp-server/.venv/
__pycache__/
@@ -18,6 +19,33 @@ kiryat-yearim/
continuation-prompt.md
node_modules/
data/eval/eval-report-*
data/adapter-migration-state.json # revert snapshot for migrate_agent_adapter.py (runtime state)
.claude/agents/.generated/ # frontmatter-stripped instruction copies for content_arg adapters (generated)
# effort-calibration run outputs (#208 / #212) — generated, not source
data/eval/effort-calibration-*
data/eval/block-yod-calibration-*
# revert snapshot for migrate_agent_adapter.py (runtime state)
data/adapter-migration-state.json
# frontmatter-stripped instruction copies for content_arg adapters (generated)
.claude/agents/.generated/
.claude/worktrees/
# TaskMaster backups (runtime)
.taskmaster/tasks/tasks.json.bak.*
# Build artifacts
.design-build/
# Temp files
.interaction_tmp.json
# Runtime eval/ab-test data
data/ab_halacha_*.json
data/ab_run_*.log
data/x11_treatment_run_*.log
# Runtime data directories
data/audit/
data/bulletins/
data/digests/
data/internal-decisions/
data/learning/
data/logs/

View File

@@ -0,0 +1,47 @@
<context>
# Overview
[Provide a high-level overview of your product here. Explain what problem it solves, who it's for, and why it's valuable.]
# Core Features
[List and describe the main features of your product. For each feature, include:
- What it does
- Why it's important
- How it works at a high level]
# User Experience
[Describe the user journey and experience. Include:
- User personas
- Key user flows
- UI/UX considerations]
</context>
<PRD>
# Technical Architecture
[Outline the technical implementation details:
- System components
- Data models
- APIs and integrations
- Infrastructure requirements]
# Development Roadmap
[Break down the development process into phases:
- MVP requirements
- Future enhancements
- Do not think about timelines whatsoever -- all that matters is scope and detailing exactly what needs to be build in each phase so it can later be cut up into tasks]
# Logical Dependency Chain
[Define the logical order of development:
- Which features need to be built first (foundation)
- Getting as quickly as possible to something usable/visible front end that works
- Properly pacing and scoping each feature so it is atomic but can also be built upon and improved as development approaches]
# Risks and Mitigations
[Identify potential risks and how they'll be addressed:
- Technical challenges
- Figuring out the MVP that we can build upon
- Resource constraints]
# Appendix
[Include any additional information:
- Research findings
- Technical specifications]
</PRD>

View File

@@ -0,0 +1,511 @@
<rpg-method>
# Repository Planning Graph (RPG) Method - PRD Template
This template teaches you (AI or human) how to create structured, dependency-aware PRDs using the RPG methodology from Microsoft Research. The key insight: separate WHAT (functional) from HOW (structural), then connect them with explicit dependencies.
## Core Principles
1. **Dual-Semantics**: Think functional (capabilities) AND structural (code organization) separately, then map them
2. **Explicit Dependencies**: Never assume - always state what depends on what
3. **Topological Order**: Build foundation first, then layers on top
4. **Progressive Refinement**: Start broad, refine iteratively
## How to Use This Template
- Follow the instructions in each `<instruction>` block
- Look at `<example>` blocks to see good vs bad patterns
- Fill in the content sections with your project details
- The AI reading this will learn the RPG method by following along
- Task Master will parse the resulting PRD into dependency-aware tasks
## Recommended Tools for Creating PRDs
When using this template to **create** a PRD (not parse it), use **code-context-aware AI assistants** for best results:
**Why?** The AI needs to understand your existing codebase to make good architectural decisions about modules, dependencies, and integration points.
**Recommended tools:**
- **Claude Code** (claude-code CLI) - Best for structured reasoning and large contexts
- **Cursor/Windsurf** - IDE integration with full codebase context
- **Gemini CLI** (gemini-cli) - Massive context window for large codebases
- **Codex/Grok CLI** - Strong code generation with context awareness
**Note:** Once your PRD is created, `task-master parse-prd` works with any configured AI model - it just needs to read the PRD text itself, not your codebase.
</rpg-method>
---
<overview>
<instruction>
Start with the problem, not the solution. Be specific about:
- What pain point exists?
- Who experiences it?
- Why existing solutions don't work?
- What success looks like (measurable outcomes)?
Keep this section focused - don't jump into implementation details yet.
</instruction>
## Problem Statement
[Describe the core problem. Be concrete about user pain points.]
## Target Users
[Define personas, their workflows, and what they're trying to achieve.]
## Success Metrics
[Quantifiable outcomes. Examples: "80% task completion via autopilot", "< 5% manual intervention rate"]
</overview>
---
<functional-decomposition>
<instruction>
Now think about CAPABILITIES (what the system DOES), not code structure yet.
Step 1: Identify high-level capability domains
- Think: "What major things does this system do?"
- Examples: Data Management, Core Processing, Presentation Layer
Step 2: For each capability, enumerate specific features
- Use explore-exploit strategy:
* Exploit: What features are REQUIRED for core value?
* Explore: What features make this domain COMPLETE?
Step 3: For each feature, define:
- Description: What it does in one sentence
- Inputs: What data/context it needs
- Outputs: What it produces/returns
- Behavior: Key logic or transformations
<example type="good">
Capability: Data Validation
Feature: Schema validation
- Description: Validate JSON payloads against defined schemas
- Inputs: JSON object, schema definition
- Outputs: Validation result (pass/fail) + error details
- Behavior: Iterate fields, check types, enforce constraints
Feature: Business rule validation
- Description: Apply domain-specific validation rules
- Inputs: Validated data object, rule set
- Outputs: Boolean + list of violated rules
- Behavior: Execute rules sequentially, short-circuit on failure
</example>
<example type="bad">
Capability: validation.js
(Problem: This is a FILE, not a CAPABILITY. Mixing structure into functional thinking.)
Capability: Validation
Feature: Make sure data is good
(Problem: Too vague. No inputs/outputs. Not actionable.)
</example>
</instruction>
## Capability Tree
### Capability: [Name]
[Brief description of what this capability domain covers]
#### Feature: [Name]
- **Description**: [One sentence]
- **Inputs**: [What it needs]
- **Outputs**: [What it produces]
- **Behavior**: [Key logic]
#### Feature: [Name]
- **Description**:
- **Inputs**:
- **Outputs**:
- **Behavior**:
### Capability: [Name]
...
</functional-decomposition>
---
<structural-decomposition>
<instruction>
NOW think about code organization. Map capabilities to actual file/folder structure.
Rules:
1. Each capability maps to a module (folder or file)
2. Features within a capability map to functions/classes
3. Use clear module boundaries - each module has ONE responsibility
4. Define what each module exports (public interface)
The goal: Create a clear mapping between "what it does" (functional) and "where it lives" (structural).
<example type="good">
Capability: Data Validation
→ Maps to: src/validation/
├── schema-validator.js (Schema validation feature)
├── rule-validator.js (Business rule validation feature)
└── index.js (Public exports)
Exports:
- validateSchema(data, schema)
- validateRules(data, rules)
</example>
<example type="bad">
Capability: Data Validation
→ Maps to: src/utils.js
(Problem: "utils" is not a clear module boundary. Where do I find validation logic?)
Capability: Data Validation
→ Maps to: src/validation/everything.js
(Problem: One giant file. Features should map to separate files for maintainability.)
</example>
</instruction>
## Repository Structure
```
project-root/
├── src/
│ ├── [module-name]/ # Maps to: [Capability Name]
│ │ ├── [file].js # Maps to: [Feature Name]
│ │ └── index.js # Public exports
│ └── [module-name]/
├── tests/
└── docs/
```
## Module Definitions
### Module: [Name]
- **Maps to capability**: [Capability from functional decomposition]
- **Responsibility**: [Single clear purpose]
- **File structure**:
```
module-name/
├── feature1.js
├── feature2.js
└── index.js
```
- **Exports**:
- `functionName()` - [what it does]
- `ClassName` - [what it does]
</structural-decomposition>
---
<dependency-graph>
<instruction>
This is THE CRITICAL SECTION for Task Master parsing.
Define explicit dependencies between modules. This creates the topological order for task execution.
Rules:
1. List modules in dependency order (foundation first)
2. For each module, state what it depends on
3. Foundation modules should have NO dependencies
4. Every non-foundation module should depend on at least one other module
5. Think: "What must EXIST before I can build this module?"
<example type="good">
Foundation Layer (no dependencies):
- error-handling: No dependencies
- config-manager: No dependencies
- base-types: No dependencies
Data Layer:
- schema-validator: Depends on [base-types, error-handling]
- data-ingestion: Depends on [schema-validator, config-manager]
Core Layer:
- algorithm-engine: Depends on [base-types, error-handling]
- pipeline-orchestrator: Depends on [algorithm-engine, data-ingestion]
</example>
<example type="bad">
- validation: Depends on API
- API: Depends on validation
(Problem: Circular dependency. This will cause build/runtime issues.)
- user-auth: Depends on everything
(Problem: Too many dependencies. Should be more focused.)
</example>
</instruction>
## Dependency Chain
### Foundation Layer (Phase 0)
No dependencies - these are built first.
- **[Module Name]**: [What it provides]
- **[Module Name]**: [What it provides]
### [Layer Name] (Phase 1)
- **[Module Name]**: Depends on [[module-from-phase-0], [module-from-phase-0]]
- **[Module Name]**: Depends on [[module-from-phase-0]]
### [Layer Name] (Phase 2)
- **[Module Name]**: Depends on [[module-from-phase-1], [module-from-foundation]]
[Continue building up layers...]
</dependency-graph>
---
<implementation-roadmap>
<instruction>
Turn the dependency graph into concrete development phases.
Each phase should:
1. Have clear entry criteria (what must exist before starting)
2. Contain tasks that can be parallelized (no inter-dependencies within phase)
3. Have clear exit criteria (how do we know phase is complete?)
4. Build toward something USABLE (not just infrastructure)
Phase ordering follows topological sort of dependency graph.
<example type="good">
Phase 0: Foundation
Entry: Clean repository
Tasks:
- Implement error handling utilities
- Create base type definitions
- Setup configuration system
Exit: Other modules can import foundation without errors
Phase 1: Data Layer
Entry: Phase 0 complete
Tasks:
- Implement schema validator (uses: base types, error handling)
- Build data ingestion pipeline (uses: validator, config)
Exit: End-to-end data flow from input to validated output
</example>
<example type="bad">
Phase 1: Build Everything
Tasks:
- API
- Database
- UI
- Tests
(Problem: No clear focus. Too broad. Dependencies not considered.)
</example>
</instruction>
## Development Phases
### Phase 0: [Foundation Name]
**Goal**: [What foundational capability this establishes]
**Entry Criteria**: [What must be true before starting]
**Tasks**:
- [ ] [Task name] (depends on: [none or list])
- Acceptance criteria: [How we know it's done]
- Test strategy: [What tests prove it works]
- [ ] [Task name] (depends on: [none or list])
**Exit Criteria**: [Observable outcome that proves phase complete]
**Delivers**: [What can users/developers do after this phase?]
---
### Phase 1: [Layer Name]
**Goal**:
**Entry Criteria**: Phase 0 complete
**Tasks**:
- [ ] [Task name] (depends on: [[tasks-from-phase-0]])
- [ ] [Task name] (depends on: [[tasks-from-phase-0]])
**Exit Criteria**:
**Delivers**:
---
[Continue with more phases...]
</implementation-roadmap>
---
<test-strategy>
<instruction>
Define how testing will be integrated throughout development (TDD approach).
Specify:
1. Test pyramid ratios (unit vs integration vs e2e)
2. Coverage requirements
3. Critical test scenarios
4. Test generation guidelines for Surgical Test Generator
This section guides the AI when generating tests during the RED phase of TDD.
<example type="good">
Critical Test Scenarios for Data Validation module:
- Happy path: Valid data passes all checks
- Edge cases: Empty strings, null values, boundary numbers
- Error cases: Invalid types, missing required fields
- Integration: Validator works with ingestion pipeline
</example>
</instruction>
## Test Pyramid
```
/\
/E2E\ ← [X]% (End-to-end, slow, comprehensive)
/------\
/Integration\ ← [Y]% (Module interactions)
/------------\
/ Unit Tests \ ← [Z]% (Fast, isolated, deterministic)
/----------------\
```
## Coverage Requirements
- Line coverage: [X]% minimum
- Branch coverage: [X]% minimum
- Function coverage: [X]% minimum
- Statement coverage: [X]% minimum
## Critical Test Scenarios
### [Module/Feature Name]
**Happy path**:
- [Scenario description]
- Expected: [What should happen]
**Edge cases**:
- [Scenario description]
- Expected: [What should happen]
**Error cases**:
- [Scenario description]
- Expected: [How system handles failure]
**Integration points**:
- [What interactions to test]
- Expected: [End-to-end behavior]
## Test Generation Guidelines
[Specific instructions for Surgical Test Generator about what to focus on, what patterns to follow, project-specific test conventions]
</test-strategy>
---
<architecture>
<instruction>
Describe technical architecture, data models, and key design decisions.
Keep this section AFTER functional/structural decomposition - implementation details come after understanding structure.
</instruction>
## System Components
[Major architectural pieces and their responsibilities]
## Data Models
[Core data structures, schemas, database design]
## Technology Stack
[Languages, frameworks, key libraries]
**Decision: [Technology/Pattern]**
- **Rationale**: [Why chosen]
- **Trade-offs**: [What we're giving up]
- **Alternatives considered**: [What else we looked at]
</architecture>
---
<risks>
<instruction>
Identify risks that could derail development and how to mitigate them.
Categories:
- Technical risks (complexity, unknowns)
- Dependency risks (blocking issues)
- Scope risks (creep, underestimation)
</instruction>
## Technical Risks
**Risk**: [Description]
- **Impact**: [High/Medium/Low - effect on project]
- **Likelihood**: [High/Medium/Low]
- **Mitigation**: [How to address]
- **Fallback**: [Plan B if mitigation fails]
## Dependency Risks
[External dependencies, blocking issues]
## Scope Risks
[Scope creep, underestimation, unclear requirements]
</risks>
---
<appendix>
## References
[Papers, documentation, similar systems]
## Glossary
[Domain-specific terms]
## Open Questions
[Things to resolve during development]
</appendix>
---
<task-master-integration>
# How Task Master Uses This PRD
When you run `task-master parse-prd <file>.txt`, the parser:
1. **Extracts capabilities** → Main tasks
- Each `### Capability:` becomes a top-level task
2. **Extracts features** → Subtasks
- Each `#### Feature:` becomes a subtask under its capability
3. **Parses dependencies** → Task dependencies
- `Depends on: [X, Y]` sets task.dependencies = ["X", "Y"]
4. **Orders by phases** → Task priorities
- Phase 0 tasks = highest priority
- Phase N tasks = lower priority, properly sequenced
5. **Uses test strategy** → Test generation context
- Feeds test scenarios to Surgical Test Generator during implementation
**Result**: A dependency-aware task graph that can be executed in topological order.
## Why RPG Structure Matters
Traditional flat PRDs lead to:
- ❌ Unclear task dependencies
- ❌ Arbitrary task ordering
- ❌ Circular dependencies discovered late
- ❌ Poorly scoped tasks
RPG-structured PRDs provide:
- ✅ Explicit dependency chains
- ✅ Topological execution order
- ✅ Clear module boundaries
- ✅ Validated task graph before implementation
## Tips for Best Results
1. **Spend time on dependency graph** - This is the most valuable section for Task Master
2. **Keep features atomic** - Each feature should be independently testable
3. **Progressive refinement** - Start broad, use `task-master expand` to break down complex tasks
4. **Use research mode** - `task-master parse-prd --research` leverages AI for better task generation
</task-master-integration>

43
data/halacha_night_check.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# One-shot morning verdict for the halacha night drain (scheduled 2026-06-15 04:30 UTC
# via chaim's crontab; throwaway — lives under data/, not tracked in scripts/).
# Captures whether last night's run (with the PR #251 fix: durable rate-limit
# detection + 05:0007:00 catch-up window) actually drained the backlog.
# Baseline at install time (2026-06-14 13:xx IDT): pending=96 done=248 halachot=4099.
set -u
export HOME=/home/chaim
REPO=/home/chaim/legal-ai
PY="$REPO/mcp-server/.venv/bin/python"
OUT="$REPO/data/logs/halacha_night_report_$(TZ=Asia/Jerusalem date +%Y%m%d).md"
SUP_LOG=/home/chaim/.pm2/logs/legal-halacha-supervisor-out.log
DRAIN_ERR=/home/chaim/.pm2/logs/legal-halacha-drain-error.log
mkdir -p "$REPO/data/logs"
{
echo "# דוח-בוקר: ריצת-הלכות הלילה — $(TZ=Asia/Jerusalem date '+%Y-%m-%d %H:%M %Z')"
echo
echo "בסיס-השוואה (אתמול 13:xx IDT): pending=96 · done=248 · halachot=4099"
echo
echo '## מצב נוכחי (supervisor status)'
echo '```'
cd "$REPO" && "$PY" scripts/halacha_drain_supervisor.py status 2>&1
echo '```'
echo
echo '## פעולות המתזמר ב-12 השעות האחרונות (modes/actions)'
echo '```'
grep -E 'מצב:|פעולה:|catch-up|rate-limit|נעצר' "$SUP_LOG" 2>/dev/null | tail -40
echo '```'
echo
echo '## אותות rate-limit בלוג-הדריינר (24ש אחרונות בלוג)'
echo '```'
echo "429 hits (tail 4000): $(tail -4000 "$DRAIN_ERR" 2>/dev/null | grep -c '429')"
echo "session-limit msgs: $(tail -4000 "$DRAIN_ERR" 2>/dev/null | grep -c 'hit your session limit')"
echo "extraction_failed: $(tail -4000 "$DRAIN_ERR" 2>/dev/null | grep -c 'extraction_failed')"
echo "hold-stopped (fix A): $(grep -c 'hold-stopped' "$SUP_LOG" 2>/dev/null)"
echo "catch-up opened (B): $(grep -c 'catch-up בוקר' "$SUP_LOG" 2>/dev/null)"
echo '```'
echo
echo "_(נוצר ע\"י data/halacha_night_check.sh; ניתן למחוק את שורת ה-crontab של 15.6.)_"
} > "$OUT" 2>&1
echo "report written: $OUT"

View File

@@ -480,11 +480,38 @@ Conclusion → Rule → Explanation → Application → Conclusion.
## 3. כללי גזירת פרמטרים
פרמטרי העיבוד נגזרים ממאפייני התוכן, לא נקבעים שרירותית:
פרמטרי העיבוד נגזרים ממאפייני התוכן, לא נקבעים שרירותית.
### Temperature — נגזר מסוג הייצור
> **⚠️ עדכון מנגנון-הייצור (#204 / WS5).** מסלול-הייצור הוא `claude_session.query` →
> `claude -p` (מנוי claude.ai, עלות-שולית-אפס, מקומי-בלבד — ראה
> `reference_claude_generation_path`). ב-**Opus 4.7/4.8 הפרמטר `temperature` (וגם
> `top_p`/`top_k`) הוסר — שליחתו מחזירה HTTP 400**. הידית היחידה היא **`effort`**
> (low/medium/high/xhigh/max; ברירת-מחדל high). **המודל נעוץ ל-`claude-opus-4-8`
> לכל הבלוקים** ב-`block_writer.GENERATION_MODEL` — אין עוד פיצול sonnet/opus.
> טבלת ה-Temperature והעמודה `decision_blocks.temperature` הן **מטא-דאטה-מת
> (deprecated, תמיד 0)** ונשמרות רק לתאימות-עמודה; אל תקראו מהן. הטבלאות למטה
> נשמרות כתיעוד-המקור-הקונספטואלי בלבד — `effort` מחליף את "Thinking budget".
| Generation type | Temperature | נימוק |
### Effort — נגזר ממורכבות קוגניטיבית (הידית החיה)
מיפוי ה-`effort` בפועל ב-`block_writer.BLOCK_CONFIG` (#204):
| בלוק | gen_type | effort | נימוק |
|------|----------|--------|-------|
| ה (פתיחה) | paraphrase | medium | פתיחה קצרה, מבנה קבוע |
| ו (רקע) | reproduction | medium | שעתוק ניטרלי מרובה-מקורות |
| ז (טענות) | paraphrase | high | קיבוץ/סינתזת-טענות לפי-נושא |
| ח (הליכים) | reproduction | medium | תיעוד כרונולוגי |
| ט (תכניות) | guided-synthesis | high | ציטוט-תכניות מדויק (INV-AH) |
| י (דיון) | rhetorical-construction | xhigh | חשיבה רב-שלבית CREAC (מחוץ-לתחום-WS5) |
| יא (סיכום) | paraphrase | high | סינתזה אופרטיבית |
| א–ד, יב | template-fill | — (script) | מילוי-שדות, ללא LLM |
### Temperature — (DEPRECATED) נגזר היסטורית מסוג הייצור
> מסמך-מקור בלבד — לא נשלח ל-API (ראה האזהרה למעלה).
| Generation type | Temperature (היסטורי) | נימוק |
|----------------|-------------|-------|
| template-fill | 0 | אין צורך בשפה — מילוי שדות |
| reproduction | 0 | נאמנות מוחלטת למקור. אפס יצירתיות |
@@ -493,18 +520,11 @@ Conclusion → Rule → Explanation → Application → Conclusion.
| analytical-reasoning | 0.3-0.4 | צריך ליצור קשרים בין עקרונות משפטיים |
| rhetorical-construction | 0.4-0.5 | טווח ביטוי רחב לכתיבה משכנעת ואלגנטית |
### Thinking budget — נגזר ממורכבות קוגניטיבית
### Model — (DEPRECATED) נגזר היסטורית מדרישת דיוק
| Cognitive task | Budget | נימוק |
|---------------|--------|-------|
| template-fill / lookup | off | אין צורך בחשיבה |
| sequential-extraction | low | חילוץ מידע חד-שלבי |
| multi-source-integration | medium | צריך להצליב מקורות |
| legal-analysis-with-CREAC | max (16K+) | חשיבה רב-שלבית: מסקנה → כלל → הסבר → יישום |
> כעת נעוץ `claude-opus-4-8` לכל בלוקי-ה-AI (`GENERATION_MODEL`). הטבלה היסטורית.
### Model — נגזר מדרישת דיוק
| Accuracy profile | Model | נימוק |
| Accuracy profile | Model (היסטורי) | נימוק |
|-----------------|-------|-------|
| factual-precision | sonnet | מהיר, מדויק לחילוץ עובדות |
| precision + interpretation | opus | נדרש לפרשנות תכנית / ציטוט מובנה |

View File

@@ -1,6 +1,6 @@
# טביעת אצבע של הקול — ניתוח הקורפוס המלא של דפנה
מסמך מטא-סגנון מבוסס על קריאה עמוקה של 23 החלטות 1xxx + 10 החלטות 8xxx/9xxx. מטרתו: לזקק את ה**קבועים** האמיתיים של דפנה, מעבר לפרטי תיק או סוג ערר, באופן שניתן להזריק ל-system prompt של `legal-writer`.
מסמך מטא-סגנון מבוסס על קריאה עמוקה של 23 החלטות 1xxx + 10 החלטות 8xxx/9xxx, ומתוקף בעיגון-מדידה על קורפוס מורחב (56 החלטות, 2,670 דוגמאות-בלוק). מטרתו: לזקק את ה**קבועים** האמיתיים של דפנה, מעבר לפרטי תיק או סוג ערר, באופן שניתן להזריק ל-system prompt של `legal-writer`.
## רכיבי הקול — שישה מסמכים משלימים
@@ -39,7 +39,7 @@
מתוך ה-24 1xxx, 23 קבצים בעלי content מספיק נותחו. רובם מתפלגים בין 2,000-8,500 מילים בבלוק י.
**הסקה משולבת**: עכשיו הקורפוס מאוזן יותר (24 1xxx, 22 8xxx, 2 9xxx). הדפוסים שמתחת מבוססים על המכלול.
**גרסה 3 — עיגון-מדידה על 56 החלטות / 2,670 דוגמאות-בלוק** (אצווה אחרונה, 2026-06-28): שימשה לאימות יחסי-המבנה ולחידוד דפוסי-קול מיקרו (ראו 1.111.13, 2.2). הדפוסים שמתחת מבוססים על המכלול.
---
@@ -68,12 +68,15 @@
**עיקרון**: אין פועל "אנחנו" שמשמש כקישור סתמי. כל אחד נושא תפקיד אינטלקטואלי. **לא להשתמש ב"נחדד" כפתיחת פסקה אם אין חידוד אמיתי.**
**חידוד (מעוגן בקורפוס-56): קבלה-מותנה כפועל-"אנחנו".** דפנה אורזת קבלת טענת-צד בפעלי-מקבל ערכיים, לא ב"הטענה מתקבלת": `מקובל עלינו הסבר [הגורם]`, `סביר ואף מעבר לכך`. אלה אינם רישום אדמיניסטרטיבי אלא הכרעה ערכית בקול-המכריע — שמרי על ההבחנה בין רישום (`נציין`) להכרעה (`מקובל עלינו`).
### 1.3 דפוס "אישור-לפני-דחייה" (אכן... אולם)
מופיע ב-8/10. במקרים של דחיית טענה משמעותית, דפנה תמיד **מאשרת את הטענה בנקודה הכי גבוהה שלה** ואז מסבירה למה לא מכריעה. הביטויים החליפיים:
- `אכן [טענה אמיתית]... אולם [למה לא מכריע]`
- `אכן צדק [צד]... יחד עם זאת...`
- `יש ממש בטענת [צד]... אך מאידך...`
- `דא עקא [תפנית]`
- **`ראשית, צודק [צד] כי [נקודה תקפה]... אלא ש[המשך]`** *(וריאציה מעוגנת בקורפוס-56: פתיחת פסקת-הכרעה במתן-צדק מפורש לצד לפני התפנית)*
**חריגים**: רק במקרים של דחיית סף קצרה ומובהקת, או כשאין טענה ראויה לאישור, דפנה מדלגת על הדפוס. ב-8/10 היא משתמשת בו לפחות פעם.
@@ -88,6 +91,8 @@
### 1.5 ציטוטים מלאים, לא תמציות
כשמובא תקדים — מובא במלואו (לפעמים פסקאות שלמות), עם ההפניה הסטנדרטית `(פורסם בנבו)` או `[נבו]` ותאריך. **לא** תמצית, **לא** "כפי שנקבע" בלי ציטוט. ב-9/10 ציטוטים בני 4-15 שורות.
**חידוד (מעוגן בקורפוס-56): אנוטציית-הדגשה אחרי ציטוט.** כשדפנה מדגישה חלק מציטוט מובא, היא חותמת בסוגריים: `(הדגשת הח"מ)` / `(הדגשה שלי)` / `(ההדגשה אינה במקור)`. זהו רכיב-קול קבוע — ההדגשה לעולם אינה שקטה; היא מסומנת במפורש כמעשה של הכותב.
### 1.6 הצמדה לטקסט החוק
כשמדובר בסעיף חוק רלוונטי — דפנה מצטטת אותו במלואו (לפעמים את כל סעיפי המשנה הרלוונטיים, גם אם רק אחד נדון). דוגמאות: סעיף 100 ב-1130, סעיף 197 ב-קרקעות ירושלים, סעיף 19(ג) ב-גמר בניה.
@@ -103,6 +108,8 @@
- `נקודת העוגן למסקנתנו זו היא המלצת הגורם המקצועי בוועדה` (1130)
- `ההיקף המדויק... ייקבעו על ידי מהנדס הוועדה המקומית` (1130)
**חידוד מהותי לסגנון-ההסתייגות (מעוגן בקורפוס-56): הדחייה למומחה תחומה, לא מוחלטת.** דפנה מנסחת במפורש את **גבול** הריסון: הריסון חל על המישור המקצועי-שמאי בלבד, ואילו במישור המשפטי/פרשני "אין לשמאי המכריע יתרון על פני ועדת הערר". זהו מהלך-קול קבוע — היא קודם בונה את הריסון ואז חורצת בו חלון משפטי שבו היא חופשייה להכריע. הסוכן חייב לתחום כל פסקת-ריסון בהבחנה זו ולא להציג ריסון גורף.
### 1.9 "למעלה מן הצורך" כסגירת חלון לערעור
ב-7/10 אחרי הכרעה משפטית עיקרית, דפנה מוסיפה טיעון חלופי:
- `למעלה מן הצורך נוסיף כי גם אם היינו מקבלים את פרשנות העורר... התוצאה הייתה זהה` (1130)
@@ -111,6 +118,8 @@
זה לא ייתור — זה הגנה אסטרטגית מפני ערעור.
**דפוס-תאום הפוך (מעוגן בקורפוס-56): "ניתן היה לסיים כאן... אלא ש".** היכן שעילה פשוטה הספיקה לסיום הדיון, דפנה מודה בכך במפורש (`על כן, ניתן היה לסיים את הדיון במצב דברים זה`) ורק אז ממשיכה — בדרך-כלל מפני שצד העלה טענה חדשה המחייבת מענה (`אלא ש... ולכן אדרש לכך`). זה ההיפוך של "למעלה מן הצורך": שם היא מוסיפה כדי לחסום ערעור; כאן היא מתעדת שהמשך-הדיון נכפה עליה, ולא נבע מצורך הכרעתי. שני המהלכים משרתים את אותה תכלית — שקיפות מלאה כלפי ערכאת-הביקורת העתידית.
### 1.10 פורמט הסיום
3 רכיבים קבועים, בסדר זה:
@@ -121,6 +130,24 @@
3. תאריך + "ניתנה פה אחד"
```
### 1.11 עיגון כמותי — קבוע חוצה-סוגים (קודם תועד ל-1xxx בלבד; הורחב מקורפוס-56)
דפנה מעגנת קביעות במספרים ספציפיים גם בתיקי 8xxx/9xxx, לא רק ב-1xxx: שיעורי הפקעה (`38.6%` מול `30%`), פערי-שטח (`כ-105 מ"ר` מול `97 מ"ר``הפרש של כ-7 מ"ר בלבד`), יחסי-הגדלה (`פי 1.9 בלבד ולא פי 3`), מספרי-יחידות (`הוכפל מ-16 ל-32`). הפונקציה הסגנונית קבועה: המספר **ממסגר מחדש** טענה רטורית של צד ומקטין אותה (`לא 45 מ"ר... אלא כ-7 מ"ר`). **עיקרון לסוכן**: כשצד טוען בעוצמה איכותית, דפנה משיבה בעיגון כמותי שמכווץ את הטענה למימדה האמיתי.
### 1.12 יחסי-מבנה מדודים (קורפוס-56, n=55, ממוצע-כלל)
פרופורציות הבלוקים בהחלטה טיפוסית, כיחס-מהסך:
| בלוק | יחס |
|------|-----|
| רקע (background) | ~17% |
| טענות (claims) | ~27% |
| **דיון והכרעה (discussion)** | **~42%** |
| סיכום (summary) | ~14% |
**עיקרון לסוכן**: הדיון הוא הבלוק הדומיננטי (כ-2/5 מההחלטה) — אך לא בולע אותה. טענות-הצדדים מקבלות נפח מהותי (~רבע), ולא נדחסות. אם טיוטה מקצה לדיון פחות משליש או לטענות פחות מחמישית — היא חורגת מהפרופורציה האופיינית.
### 1.13 רינדור נאמן של הלוגיקה הפנימית של טענת-צד
כשדפנה מביאה טענת-צד, היא משחזרת את **מבנה-ההיסק** שלה כנרטיב רציף — כולל מהלכי קל-וחומר (`מכך יש להסיק מקל וחומר לענייננו`), אבחון-תקדים (`אבחנו את עניינם מההלכות ב...`, `ההבדל מהותי`), והיסק-תכלית. היא אינה משטחת את הטיעון ל"הצד טוען X" אלא מוסרת את הדרך שבה הצד **הגיע** ל-X. זה תקף לטענות שתידחינה לא פחות מאלה שתתקבלנה — נאמנות-הרינדור אינה תלוית-תוצאה.
---
## 2. המשתנים — לפי סוג תיק וסוג תוצאה
@@ -158,6 +185,10 @@
"7. שמאי מכריע ... [ציטוט מלא של פסקאות 7-8 או חלק מהן]"
```
**מבנה פנימי קבוע של פסקת-הריסון (חידוד מעוגן בקורפוס-56):** הניסוח אינו עמדה גורפת אלא **מטוטלת דו-צדדית** — קודם מתי הוועדה *תאמץ* את השמאי (שיטה מקובלת + מסד עובדתי הולם, *גם אם קיימות גישות מקצועיות שונות*), ואז, בסימטריה, מתי היא *תתערב* (מסד עובדתי בלתי הולם / הנחות בלתי הגיוניות / היעדר הסבר מניח-את-הדעת / תשתית משפטית חסרה או שגויה / פגם היורד לשורש המעשה המינהלי — ניגוד עניינים, משוא פנים, חוסר תום לב). הסוכן יבנה את שתי הזרועות, לא רק את זרוע-הריסון.
**חיתום ההבחנה המשפטית (מעוגן):** פסקת-הריסון נסגרת תמיד בהבחנה בין המישור השמאי-מקצועי (ריסון) למישור המשפטי-פרשני (אין-ריסון; ראו 1.8). זו אינה הערת-אגב אלא חלק מהתבנית הקבועה.
**לסוכן ב-8xxx**: לכלול את הציטוט הזה בפתיחה אלא אם התיק לא נוגע להכרעה שמאית.
### 2.3 פתיח פילוסופי לתיקי 1xxx (תכנון)
@@ -215,6 +246,19 @@
### 3.7 ❌ אסור: עדיף "העורר טוען ש..." על "טענת העורר היא..."
דפנה משתמשת בפעלים פעילים: `העורר טוען`, `המשיבה טוענת`, `מבקשי התכנית מבקשים`. **לא** "טענות העורר היו ש..." (פסיביזציה).
**קטלוג פעלי-רינדור-צד מורחב (מעוגן בקורפוס-56):** דפנה מגוונת את פועל-הרינדור לפי תפקיד-המהלך של הצד, ולעולם אינה חוזרת על "טוען" באופן מונוטוני:
| פועל | תפקיד-המהלך |
|------|-------------|
| **מסביר / מפרט** | פריסת נימוק טכני או עובדתי שלב-אחר-שלב |
| **מדגיש / שם דגש על** | הצבת מרכז-הכובד של הטיעון |
| **מנמק / מבסס את טענתו בכך ש** | קישור טענה לעוגן |
| **מוסיף** | חיזוק-אגב לטיעון הראשי |
| **אבחן (את עניינו מ-)** | הבחנת תקדים שמנגד |
| **ביקש להסתמך על / מפנה ל** | גיוס תקדים תומך |
הבחירה אינה דקורטיבית — הפועל מתעד *מה הצד עושה* בטיעון, לא רק שהוא מחזיק בו.
---
## 4. תבניות מועתקות (Copy-Paste Templates)
@@ -249,6 +293,12 @@
[ציטוט מלא של 5-15 שורות מפסקאות 7-8]
<מטוטלת: מתי תאמץ הוועדה (שיטה מקובלת + מסד הולם, גם אם יש
גישות שונות) — ומתי תתערב (מסד בלתי הולם / הנחות לא הגיוניות /
היעדר הסבר / תשתית משפטית שגויה / פגם בשורש המעשה המינהלי)>.
נחדד כי הריסון חל במישור השמאי-המקצועי; במישור המשפטי-הפרשני
אין לשמאי יתרון על פני ועדת הערר.
ברוח הדברים לעיל נבחן את טענות הצדדים;
```
@@ -284,6 +334,13 @@
ניתנה פה אחד, <תאריך עברי>, <תאריך לועזי>.
```
### 4.8 פסקת "ניתן היה לסיים — אלא ש" (מעוגן בקורפוס-56)
```
<יישום העילה הפשוטה על העובדות>. על כן, ניתן היה לסיים את
הדיון במצב דברים זה ולקבוע כי <תוצאה פשוטה>. אלא ש<הטענה
החדשה/הנוספת שנכפתה עלינו> ולפיכך נדרש להוסיף ולהתייחס אליה;
```
---
## 5. הוראות אופרטיביות לסוכן הכותב
@@ -294,19 +351,22 @@
1. **מהי התוצאה הצפויה?** דחייה / קבלה / חלקית?
2. **מהו המתח / האיזון בלב התיק?** נסח אותו במשפט אחד — זה הולך לפתיחה (אם מוד B/C/E).
3. **איזה מוד פתיחה מתאים?** A/B/C/D/E (ראה טבלה 2.1)
4. **האם זה תיק 8xxx עם הכרעה שמאית?** אם כן → לכלול ציטוט בר"מ 3644/13.
4. **האם זה תיק 8xxx עם הכרעה שמאית?** אם כן → לכלול ציטוט בר"מ 3644/13 + מטוטלת דו-צדדית + הבחנת מישור-משפטי (2.2).
5. **האם דפנה הכריעה בתיק קשור?** אם כן → search_decisions ולכלול הפנייה / הבחנה (ראה sec 11.2 ב-voice-1130-25).
6. **מה האורך הצפוי לפי משקל בהכרעה?** (ראה 2.4)
7. **האם יש טענת-צד איכותית-רטורית?** אם כן → הכן עיגון כמותי שממסגר אותה מחדש (1.11).
### 5.2 בכתיבה — איך לבנות פסקה
1. שימוש מודע ב"אנחנו" — בחירת פועל לפי תפקיד (טבלה 1.2)
2. כל טענה משמעותית → פסקה מלאה. לא דחייה במשפט.
3. אם דוחים טענה → "אכן [נקודה תקפה]... אולם [למה לא מכריע]"
4. ציטוטים → במלואם, לא תמציות
3. אם דוחים טענה → "אכן [נקודה תקפה]... אולם [למה לא מכריע]" (או "ראשית, צודק [צד]... אלא ש")
4. ציטוטים → במלואם, לא תמציות; הדגשה תמיד חתומה ב"(הדגשת הח"מ)"
5. סעיפי חוק → במלואם
6. "למעלה מן הצורך" → לטיעונים מרכזיים
7. דחייה למומחים → לסוגיות תכנוניות-טכניות
8. **ללא רשימות ממוספרות** באנליזה
6. "למעלה מן הצורך" → לטיעונים מרכזיים; "ניתן היה לסיים... אלא ש" → כשטענה נכפתה אחרי עילה מספקת
7. דחייה למומחים → לסוגיות תכנוניות-טכניות, **תמיד תחומה למישור המקצועי** (1.8)
8. רינדור-צד → שחזר את מבנה-ההיסק, גוון את פועל-הרינדור (1.13, 3.7)
9. עיגון כמותי → המר טענות איכותיות למספרים שמכווצים אותן (1.11)
10. **ללא רשימות ממוספרות** באנליזה
### 5.3 חיפוש תקדימים אישיים
לפני כתיבה — `search_decisions` בקטגוריה זהה. אם יש תקדים של דפנה עצמה — חובה להפנות אליו ב-3 מודים אפשריים:
@@ -321,6 +381,8 @@
- [ ] אין דחיית טענה במשפט אחד
- [ ] אין רטוריקה דרמטית של הצדדים בקול ההכרעה
- [ ] אין תוצאה הכל-או-לא-כלום בתיק עם טענות מהותיות משני הצדדים
- [ ] פסקת-ריסון-שמאי תחומה למישור המקצועי (לא גורפת)
- [ ] הדיון לא חורג מטה מ-~⅓ מההחלטה, וטענות לא נדחסות מתחת ל-~⅕ (1.12)
---
@@ -362,6 +424,10 @@
| **שוב על מנת שלא לצאת בחסר** | להוצאת ערך נוסף |
| **בשולי הדברים** | להבעת הסתייגות בעדינות |
| **מצאנו להוסיף כי...** | תוספת חופשית |
| **מקובל עלינו (הסבר/עמדת)** | קבלת עמדה ערכית *(קורפוס-56)* |
| **לא מצאנו מקום להתערב** | אישרור הכרעת-מומחה *(קורפוס-56)* |
| **ראוי לציין כי** | הצבת הסתייגות/הבחנה *(קורפוס-56)* |
| **יש להבחין בין... לבין** | תיחום גבול-ריסון/הבחנה *(קורפוס-56)* |
### 6.5 ביטויים מסורתיים שאומצו (כל אחד מקבל ציטוט מקורי)
- **"כבדהו וחשדהו"** — לכלי השימוש החורג (מקור: עע"מ 109/12 גבעת האירוסים)
@@ -372,7 +438,7 @@
- **"בלשון המעטה"** — להסתייגות מנומסת
- **"בנדון דנא"** — נוסח מליצי לקדם דיון
- **"דא עקא"** — לתפנית בטיעון
- **"ודוק"** — להבהרה / reductio ad absurdum
- **"ודוק"** — להבהרה / reductio ad absurdum (מופיע גם בלב רינדור-טענה, להדגשת נקודה קריטית בתוך הצגת עמדת-צד)
- **"ברי כי..."** — קביעה משכנעת
- **"ללמדך כי..."** — מסקנה מציטוט
@@ -397,7 +463,7 @@
**1015-24** נכתב בגוף ראשון יחיד ("אינני סבור", "לדעתי") — דעת מיעוט / חבר ועדה אחר. **לא לחקות.**
### 6.10 מצב הרשתות — סטטיסטיקה
- **24 תיקי 1xxx** + **22 תיקי 8xxx** + **2 תיקי 9xxx** = 48 בקורפוס
- **24 תיקי 1xxx** + **22 תיקי 8xxx** + **2 תיקי 9xxx** = 48 בקורפוס המנותח (וכן 56 החלטות / 2,670 דוגמאות-בלוק באצוות-המדידה האחרונה)
- **~30 תקדמים חיצוניים** ש**דפנה מצטטת באופן עקבי** (ראה precedent-network.md)
- **~15 תקדמים אישיים** שלה עצמה — מהווים את הקאנון האישי שלה
@@ -430,7 +496,7 @@
כשמבקש שימוש חורג גם מקדם תכנית — דפנה מנתחת: האם השימוש המבוקש **תואם** את התכנון העתידי (→ גשר לגיטימי, כמו בכוכבה תורן)? או **סותר** (→ סטייה כפולה)? מסגרת ניתוח שלמה (249 מילים) שלא הייתה בטיוטה.
### ה. עיגון כמותי
דפנה מוסיפה נתונים מספריים ספציפיים: "4,404.98 מ"ר לכלל היישוב vs 1,425 מ"ר מבוקש — 32%". המספרים מעגנים את ההחלטה במציאות ומקשים על ערעור.
דפנה מוסיפה נתונים מספריים ספציפיים: "4,404.98 מ"ר לכלל היישוב vs 1,425 מ"ר מבוקש — 32%". המספרים מעגנים את ההחלטה במציאות ומקשים על ערעור. *(אומת כקבוע חוצה-סוגים בקורפוס-56 — ראו 1.11.)*
### ו. כותרות שטוחות (Heading 2 בלבד)
דפנה השתמשה ב-Heading 2 לכל הסעיפים, כולל תת-נושאים בדיון. **אין Heading 3**. כל סעיף עומד בפני עצמו.
@@ -459,14 +525,14 @@
---
## 7. הצעד הבא — הזרקת הקול ל-`legal-writer`
## 8. הצעד הבא — הזרקת הקול ל-`legal-writer`
מסמך זה (יחד עם voice-1130-25.md) הוא הבסיס. הצעד הבא: לעדכן את ה-system prompt של `legal-writer` (ראה `~/.claude/agents/legal-writer.md` או `mcp-server/.../get_style_guide`) כך שיכלול:
1. הקבועים מסעיף 1
1. הקבועים מסעיף 1 (כולל 1.111.13)
2. ההוראות האופרטיביות מסעיף 5
3. תבניות העתקה מסעיף 4
3. תבניות העתקה מסעיף 4 (כולל 4.8)
4. אנטי-דפוסים מסעיף 3
5. הפנייה לטבלת מודי הפתיחה (2.1)
זה דורש קריאה של ההגדרה הקיימת של `legal-writer` ועדכון מבני שלה.
זה דורש קריאה של ההגדרה הקיימת של `legal-writer` ועדכון מבני שלה.

View File

@@ -0,0 +1,170 @@
# ממצאי ביקורת — ארכיטקטורת קורפוס־הפסיקה + מצב הדאטה בפועל
> **מקור:** Claude (Opus 4.8) · **תאריך:** 2026-06-20 · **קונטקסט:** חקירה לקראת תכנון־מחדש של קורפוס־הפסיקה.
> מסמך זה הוא **אחד מכמה** קלטי־סוכנים שחיים אוסף; ייעודו להזין את שלב־הסינתזה. אינו תכנית — הוא **אבחון**.
>
> **שאלת־המוצא של חיים:** "הקורפוס נבנה מראש לא נכון, אני כל הזמן מתעסק בתיקונים. האם כדאי ליצור מחדש את קורפוס־הפסיקה ולהתחיל דף נקי?"
>
> **המודל הרצוי (כפי שחיים תיאר אותו):** מאגר פסקי־דין והחלטות ועדות־ערר; חוקר־התקדימים מזהה בשלב ניתוח־הערר פס"ד/החלטות שדנו במקרה דומה או הלכה דומה; הסוכן־הכותב משייך ומזכיר אותם בפרק הדיון וההכרעה בסגנון דפנה. שלושה מקורות־הזנה: (1) החלטות דפנה עצמה, (2) ועדות־ערר אחרות שמצטטות פסיקה, (3) פס"ד עליון/מחוזי.
---
## 0. תקציר־מנהלים (TL;DR)
**מה ש"בנוי לא נכון" אינו הסכמה — היא שכבת־הביצוע.** הסכמה כבר תואמת בדיוק את המודל שחיים תיאר: טבלה אחת (`case_law`) ששלושת המקורות נכנסים אליה דרך `source_kind`/`source_type`; שלושת ה־`search_*` הם שלושה *מסננים* על אותו מקור, לא שלושה מאגרים מקבילים (G2 מקוים ברמת־הסכמה). **רֵבילד של הסכמה ייצר בדיוק את אותה סכמה** — ולכן אינו פותר דבר, ומסכן ב־second-system syndrome.
מה שכן מחולל את "התיקונים האינסופיים" — שלושה כשלי־ביצוע מדידים:
1. **חוזה־קליטה רופף** → 66% מהפסיקה בלי `practice_area`, 31 רשומות ריקות, אכיפת־שלמות (INV-DM1) מופרת בפועל.
2. **צינור הלכות→קנוני מייצר רעש** → 5,472 קנוני, מתוכם 5,456 סינגלטונים, **0 published** → השכבה שאמורה להזין את הכותב (INV-G10) **אינרטית לגמרי**.
3. **כפילות `style_corpus`** → 55 החלטות דפנה חיות בשני נתיבי־אחזור.
**המלצה:** לא לשרוף את הסכמה. כן לבצע "איפוס שכבות־נגזרות" צר (truncate ל-chunks+halachot+canonical והרצה־מחדש), **אבל רק אחרי תיקון החוזה והסף** — אחרת מחזירים את אותו בלגן (G1: תיקון במקור, לא בקריאה). מסמכי־המקור (363 רשומות, 332 עם full_text) נשמרים; הם יקרים ו/או ניתנים לקליטה־מחדש מ־PDF.
---
## 1. מצב הדאטה בפועל (שאילתות חיות מול legal_ai @ localhost:5433, 2026-06-20)
```
┌─────────────────────────────────────┬─────────┬──────────────────────────────────┐
│ Metric │ Count │ Reading │
├─────────────────────────────────────┼─────────┼──────────────────────────────────┤
│ case_law (total precedents) │ 363 │ קטן — re-ingestable │
│ • external_upload (court rulings) │ 240 │ מקור (3) — עליון/מחוזי │
│ • internal_committee (ועדות ערר) │ 92 │ מקור (1)+(2) │
│ • (יתר — ללא source_kind מובהק) │ 31 │ = הרשומות הריקות (ראה למטה) │
│ style_corpus (החלטות דפנה) │ 55 │ כפילות עם internal_committee │
│ precedent_chunks │ 11,904 │ נגזר — מתחדש מ-full_text │
│ halachot (total) │ 5,489 │ נגזר │
│ • approved/published │ 1,352 │ 25% בלבד │
│ • pending_review (backlog ידני) │ 2,402 │ 44% — צוואר־בקבוק │
│ canonical_halachot (V41) │ 5,472 │ כמעט 1:1 עם halachot ⚠️ │
│ • singletons (instance_count=1) │ 5,456 │ דה־דופ כמעט לא קרה │
│ • merged (instance_count>=2) │ 16 │ 0.3% מיזוג │
│ • published (מגיע לסוכן הכותב) │ 0 │ ⚠️ השכבה אינרטית לחלוטין │
│ case_law w/o practice_area │ 240 │ 66% — חופף-בדיוק לפסיקה החיצונית │
│ case_law missing summary │ 27 │ │
│ case_law w/ 0 chunks / no full_text │ 31 │ רשומות שבורות/ריקות │
│ distinct practice_area │ 4 │ rishuy/betterment/197/(ריק) │
└─────────────────────────────────────┴─────────┴──────────────────────────────────┘
practice_area breakdown:
(ריק) 240 ← כל הפסיקה החיצונית ללא סיווג
rishuy_uvniya 70
betterment_levy 50
compensation_197 3
```
**קריאות מפתח:**
- **240 = 240:** מספר הפסיקה־החיצונית שווה־בדיוק למספר חסרי־`practice_area`. כלומר אף פס"ד חיצוני לא סווג לתחום — סינון לפי תחום באחזור פשוט לא עובד עליהם.
- **5,456 / 5,472 סינגלטונים:** מנוע הקנוניזציה (V41) רץ אך לא מאחד. סף 0.85 כנראה הדוק מדי, או שהחילוץ מנסח כל הלכה ייחודית מספיק כדי לא להתלכד.
- **0 published canonical:** לפי INV-G10 רק קנוני `published` מגיע לכתיבה. אפס. **כל מנגנון V41 כרגע מנותק מהכתיבה בפועל.**
- **2,402 pending_review:** צוואר־הבקבוק הוא אישור־אנושי ידני, לא טכנולוגיה.
---
## 2. מפת הארכיטקטורה — קורפוס אחד, שלושה מסננים
**פיזית: טבלה אחת.** כל שלושת המקורות מתכנסים ל-`case_law`, מתחתיה `precedent_chunks` (FK) ו-`halachot` (FK), ומעל ה-`halachot` שכבת `canonical_halachot` (V41).
```
canonical_halachot (עקרונות מאוחדים — V41; כיום אינרטי, 0 published)
▲ 1:many
halachot (מופע-להלכה per precedent; review_status gate)
▲ FK
case_law (רישום מרכזי — court rulings + ועדות-ערר)
├─ source_kind='external_upload' → פס"ד עליון/מחוזי [מקור 3]
└─ source_kind='internal_committee'→ ועדות-ערר + דפנה [מקור 1+2]
▼ FK
precedent_chunks (chunks + embedding vector(1024))
בנפרד:
style_corpus (55 החלטות דפנה — נתיב-אחזור מקביל ל-search_decisions)
document_chunks (מסמכי-תיק + style; FK→documents→cases)
```
**שלושת נתיבי־האחזור (לא שלושה מאגרים — שלושה scopes):**
```
┌──────────────────────────────┬─────────────────────────┬────────────────────────────┐
│ Tool │ Table / filter │ Purpose │
├──────────────────────────────┼─────────────────────────┼────────────────────────────┤
│ search_decisions │ document_chunks │ סגנון/קול: החלטות דפנה │
│ │ (scoped case/area) │ + מסמכי-תיק │
│ search_precedent_library │ case_law + chunks/halach │ source_kind=external_upload│
│ │ ot, source_kind filter │ → פסיקה חיצונית │
│ search_internal_decisions │ אותן פונקציות DB, │ source_kind=internal_ │
│ │ source_kind אחר │ committee → ועדות-ערר │
└──────────────────────────────┴─────────────────────────┴────────────────────────────┘
```
שתי האחרונות קוראות **לאותן פונקציות DB** (`search_precedent_library_semantic`/`_lexical`) עם `source_kind` שונה. זו הפרדה־בשאילתה, לא קוד מקביל.
---
## 3. שלושת המקורות של חיים → איפה הם נופלים היום
```
┌────────────────────────────────────┬───────────────────────────────┬─────────────────────────┐
│ Source (חיים) │ Stored as │ Retrieval │
├────────────────────────────────────┼───────────────────────────────┼─────────────────────────┤
│ (1) החלטות דפנה עצמה │ style_corpus + (מהוגר ל-) │ search_decisions + │
│ │ case_law internal_committee │ search_internal_decisions│
│ │ chair_name='דפנה תמיר' │ ← כפילות / נתיב-כפול │
│ (2) ועדות-ערר אחרות │ case_law internal_committee │ search_internal_decisions│
│ │ chair_name=<אחר>, district │ │
│ (3) פס"ד עליון/מחוזי │ case_law external_upload │ search_precedent_library │
│ │ source_type='court_ruling' │ │
└────────────────────────────────────┴───────────────────────────────┴─────────────────────────┘
```
**מסקנה:** המודל המנטלי של חיים **כבר ממומש בסכמה**. אין צורך להמציא מבנה חדש — צריך לאכוף את המבנה הקיים בקליטה, ולחבר את שכבת־הקנוני לכתיבה.
---
## 4. הדיאגנוזה — מה "בנוי לא נכון" (3 מחוללי־כאב)
### 4.1 חוזה־קליטה רופף (root cause #1)
- 66% מהפסיקה ללא `practice_area`; 27 ללא summary; 31 ללא full_text/chunks.
- אין אכיפה ש"`searchable=false` עד שהמטא שלם" → INV-DM1 מופר בפועל.
- **כל העלאה מוסיפה חוב** במקום רשומה שלמה. זה המקור לתיקונים החוזרים.
### 4.2 צינור הלכות→קנוני מייצר רעש, לא ערך (root cause #2)
- 5,456/5,472 סינגלטונים → דה־דופ לא עובד (סף 0.85? ניסוח־חילוץ?).
- **0 published** → השכבה שאמורה להזין את הכותב (INV-G10) מנותקת.
- 2,402 בתור־אישור־ידני → הצינור מייצר מהר יותר ממה שאדם מאשר.
- **זה בולע את רוב זמן־התחזוקה.**
### 4.3 כפילות style_corpus (root cause #3)
- 55 החלטות דפנה בשני מקומות + שני נתיבי־אחזור.
- צריך מקור־אמת אחד: או `style_corpus` SoT וה-`case_law` נגזר, או הפוך.
---
## 5. ההמלצה — לא רֵבילד־סכמה; "איפוס שכבות־נגזרות" אחרי תיקון־חוזה
**אסור** לשרוף ולהעלות־מחדש את `case_law` — תבנה אותה סכמה ותאבד מטא־דאטה ידני. **כן** הגיוני רֵבילד צר של ה**נגזר**, אבל בסדר הזה (G1 — מקור לפני תסמין):
1. **קודם החוזה:** אכוף ב-`*_upload` שדות־חובה (practice_area, summary, full_text); כשל → `searchable=false`. נקה/מחק את 31 הריקות.
2. **תקן את הקנוניזציה:** כוונן סף 0.85, הגדר מתי `published`, ובדוק ניסוח־החילוץ. בלי זה אין טעם להריץ מחדש.
3. **רק אז** re-derive מהמקור הקיים: chunks → halachot → canonical.
4. **הכרע style_corpus:** מקור־אמת אחד.
**מתי רֵבילד־מלא כן מוצדק:** רק אם יתגלה שמסמכי־המקור עצמם (PDF/full_text של 363) פגומים/חסרים. המספרים *לא* מראים זאת (332/363 עם full_text תקין).
---
## 6. שאלות פתוחות לשלב־הסינתזה
- **למה 0 published בקנוני?** האם זה סף, ניסוח־חילוץ, או שפשוט אף אחד לא אישר? (קריטי — קובע אם V41 שמיש בכלל.)
- **style_corpus מול case_law:** מי SoT? (משפיע על search_decisions מול search_internal_decisions.)
- **practice_area לפסיקה חיצונית:** לחלץ אוטומטית בקליטה, או להשאיר ידני?
- **תור־האישור (2,402):** האם המנגנון (פאנל/active-learning, #133) יכול לסגור את הפער, או שצריך לחתוך את קצב־החילוץ?
---
## 7. נספח — קבצים מרכזיים (file:line)
- סכמה: `mcp-server/src/legal_mcp/services/db.py` (case_law, precedent_chunks, halachot, canonical_halachot)
- קליטה חיצונית: `mcp-server/src/legal_mcp/tools/precedent_library.py` (`precedent_library_upload`)
- קליטה פנימית: `mcp-server/src/legal_mcp/tools/internal_decisions.py` (`internal_decision_upload`)
- הגירת style→case_law: `mcp-server/src/legal_mcp/services/internal_decisions.py` (`migrate_from_style_corpus`, chair/district hardcoded)
- אחזור: `mcp-server/src/legal_mcp/tools/search.py`, `services/hybrid_search.py`, `services/db.py` (`search_precedent_library_semantic`/`_lexical`)
- ספ: `docs/spec/02-data-model.md` (INV-DM17), `docs/spec/03-retrieval.md` (INV-RET15), `docs/spec/00-constitution.md` (G2/G10)

View File

@@ -0,0 +1,125 @@
# 06 — נדיבות-המחלץ: `application` בהחלטות-ועדה (כימות חוצה-קורפוס)
> **קלט-נתונים** ליוזמה, נמדד חי על **5,489 רשומות `halachot`** (כל הקורפוס, 2026-06-20).
> נולד משאלת-חיים: "8508-03-24 מפיק 71 הלכות ממתינות — האם המחלץ נדיב מדי על החלטות-ועדה,
> או שזה ספציפי לתיק הזה?" התשובה: **שיטתי, לא ספציפי — אבל הנדיבות מוצדקת.**
>
> **מתכתב עם [`00-final-synthesis.md`](00-final-synthesis.md):** הנתונים כאן **מחזקים** את הכרעת-הסינתזה
> ("לא לחתוך") ומוסיפים שני דברים שלא היו לה: (א) כימות חוצה-קורפוס של ה-`application`, (ב) ממצא
> חדש וגדול יותר — `nli_unsupported` על הפסיקה החיצונית. ראה §4 (יישוב-מתח) ו-§5 (דרכי-פעולה).
---
## 1. הממצא המרכזי — `application` הוא תופעה שיטתית של החלטות-ועדה
הפרדנו את הקורפוס לפי `authority` (נגזר דטרמיניסטית מ-`precedent_level`, [02-data-model §162](../spec/02-data-model.md)):
`binding` = פסיקת-עליון/מנהלי · `persuasive` = ועדת-ערר מחוזית.
```text
source rows rt=application nli_unsupported any-flag
court (binding) 3,603 0.2% 39.7% 45.4%
committee (persuasive) 1,842 13.7% 25.0% 30.5%
```
- **`rule_type='application'` הוא כמעט-בלעדית של ועדות:** 13.7% מול 0.2%. מתוך 258 רשומות-`application`
בכל הקורפוס, **~252 מגיעות מהחלטות-ועדה.** פער של פי-~70.
- **עקבי בין תחומים** (לא עניין-שמאות נקודתי):
```text
committee, rt=application by practice_area:
rishuy_uvniya 13.5% betterment_levy 14.1% compensation_197 18.8%
```
**הפרשנות (תואם [02-data-model §163](../spec/02-data-model.md)):** `application` = "החלה תלוית-עובדות —
לרוב לא-הלכה". ועדת-ערר היא גוף מיישֵם: היא לא יוצרת הלכה (רק בית-משפט עושה זאת), אלא **מיישמת
פסיקת-עליון קיימת** (לוסטרניק, דלי-דליה) על עובדות-התיק. לכן חילוץ מהחלטת-ועדה מייצר באופן מובנה
שיעור גבוה של יישומי-דוקטרינה. **זו תכונה של מקור-הנתונים, לא באג של המחלץ.**
## 2. 8508-03-24 — מייצג בקצה-העליון, לא חריג
```text
דירוג 8508-03-24: 7 מתוך 45 תיקי-ועדה (≥15 רשומות) · 27% application (rt או flag)
חציון תיקי-הוועדה: 11.9%
מעליו: 1001-02-19 (40%) · 1044-08-22 (37%) · 9002-24 (33%) · 1007-01-25 (30%)
```
8508 הוא ~פי-2.3 מהחציון (רבעון-עליון), אבל מה שבולט בו הוא בעיקר ש**הוא התיק הארוך בקורפוס**
(111 רשומות) — אז 27% נותן 30 פריטי-`application` במספר מוחלט, הגבוה בקורפוס. כלומר: **כמות גבוהה,
שיעור גבוה-אך-נורמלי.** אין כאן פתולוגיה ייחודית לתיק.
## 3. מנגנון-הניתוב הקיים (איך זה כבר מטופל ב-UI)
הפיצול בתור-ההלכות (`/precedents` → "תור הלכות") אינו לפי `exclude_low_quality`, אלא לפי
`isExtractionFixItem(h) = (quality_flags.length>0) && !panel_round`
([web-ui/.../precedent-library.ts:652](../../web-ui/src/lib/api/precedent-library.ts#L652)):
```text
8508-03-24, 71 ממתינות (מצב 2026-06-20):
bucket # panel? →"להכרעתך" →"דורש תיקון-חילוץ"
clean (ללא דגל) 40 15/40 40 0
application 23 0/23 0 23
nli_unsupported 4 4/4 4 0
thin_restatement 4 0/4 0 4
```
**משמעות:** פריטי-`application` (חסרי-פאנל) כבר מנותבים ל**"דורש תיקון-חילוץ"** — **מחוץ** לתור-ההכרעה
של היו"ר. כלומר המערכת כבר מסננת אותם מהעומס-הידני. (הפאנל התלת-מודלי, [halacha_panel_approve.py:191](../../scripts/halacha_panel_approve.py#L191),
מטפל רק בדליים `clean`+`nli`; `application` ו-`defect` עוקפים אותו במכוון.)
## 4. ⚠️ יישוב-המתח מול הסינתזה — `application` ≠ "רעש"
זו הנקודה הקריטית להעברה. **אסור** לתרגם "13.7% application" ל"13.7% רעש לחיתוך". המבחן בסינתזה
(§2 שם) כבר הוכיח שחיתוך-אגרסיבי על 8508 השמיד את **לוסטרניק** ו-~22 עקרונות-ליבה. רוב פריטי-ה-`application`
הם בדיוק יישומי-הדוקטרינה הללו — **בני-ציטוט שהכותב צריך**. דוגמאות אמיתיות מ-8508 שמסומנות `application`:
```text
"ציפיות הנובעות אך ממיקומם של המקרקעין... אין לנטרלן" ← לוסטרניק מיושמת — לשמור!
"בחישוב שווי במצב קודם יש לכלול ציפיות כלליות... ולא ספציפיות" ← ליבת חישוב היטל-ההשבחה
"קביעת מקדם מצויה בליבת שיקול-דעת השמאי, והוועדה לא תתערב" ← סטנדרט אי-התערבות מיושם
```
לכן: **הנתון הזה תומך ב"שמור-בספק" של עמוד-1/2 בסינתזה.** המסקנה הנכונה אינה "לסנן application" אלא
"`application` מאשר שעקרוני-הוועדה הם persuasive-יישומיים — לדרג אותם נמוך באחזור (רמה B), לא למחוק
אותם (רמה A)". `importance=0` ל-8508 כבר משקיע אותם ממילא.
## 5. ⭐ ממצא-לוואי גדול יותר — `nli_unsupported` על הפסיקה החיצונית
```text
nli_unsupported: court (binding) 39.7% ≫ committee 25.0%
any-flag: court 45.4% · committee 30.5%
```
**כמעט מחצית מעקרוני-הפסיקה-החיצונית נושאים דגל-איכות, בעיקר `nli_unsupported`** (הכלל אינו נגזר
לוגית מהציטוט התומך שלו, [halacha_quality.py:282](../../mcp-server/src/legal_mcp/services/halacha_quality.py#L282)).
זה **מגמד מספרית** את סוגיית-ה-`application`, ונוגע ישירות ל"רמה A = ניקוי-רעש" של הסינתזה:
**מהו ה"רעש" שמנקים?** הדגל הדומיננטי הוא `nli`, והוא מרוכז בפסיקה, לא בוועדות.
שתי השערות מתחרות, שצריך להכריע ביניהן **לפני** שמשתמשים ב-`nli` כמסנן-רעש ברמה A:
- **(א) הבודק מחמיר מדי** — סף-ה-NLI חותך יישורים לגיטימיים → 40% הם false-positives, וה"רעש" מדומה.
- **(ב) החילוץ-מהפסיקה לקוי** — ציטוטים תומכים שלא מיישרים לכלל → בעיית-חילוץ אמיתית בקנה-מידה.
ההכרעה משנה את כל אסטרטגיית רמה-A. **אנו ממליצים לאמת זאת על מדגם-זהב לפני כל שימוש ב-`nli` כסיגנל.**
---
## 6. דרכי-פעולה מוצעות (לסוכן-הקורפוס)
ממוין מהשמרני לאגרסיבי. ההמלצה שלנו: **B כברירת-מחדל + C כעבודה-מקבילה**; להימנע מ-A ומ-D.
| # | פעולה | טיעון | סיכון | המלצה |
|---|-------|-------|-------|-------|
| **A** | לכוונן את ה-prompt לדכא `application` מוועדות במקור | מטפל-בשורש (G1); חוסך 14% רשומות | **גבוה** — סותר את מבחן-8508; משמיד יישומי-לוסטרניק בני-ציטוט | ✗ לא |
| **B** | להשאיר את החילוץ; לסמוך על הניתוב הקיים (`application`→"תיקון", מחוץ לתור-היו"ר) + לדרג נמוך ב-RRF | תואם-סינתזה (שמור-בספק + דרג-בזמן-אחזור); אפס סיכון-אובדן | הרעש נשאר ב-DB (אחסון בלבד) | ✓ **כן — ברירת-מחדל** |
| **C** | לחקור קודם את `nli_unsupported` (40% פסיקה): מדגם-זהב, להכריע (א) מחמיר-מדי מול (ב) חילוץ-לקוי | זה הסיגנל הגדול; הכרחי לפני שמגדירים "רעש" ברמה A | דורש מדגם מתויג-ידנית | ✓ **כן — במקביל** |
| **D** | להפסיק חילוץ-עקרונות מוועדות לגמרי | רוב עקרוני-הוועדה הם שכתוב-persuasive של עליון | **קיצוני** — נוגד 07-learning §61 (ועדות ברות-ציטוט במכוון); נוגע INV-LRN | ✗ לא (אלא בהכרעת-יו"ר מפורשת) |
### ההמלצה המזוקקת
1. **לא לגעת בחילוץ-מוועדות** — הנתון מאשר שהנדיבות מוצדקת; `application`=יישום-בר-ציטוט, לא זבל. עקבי עם
הכרעת-הסינתזה "לא לחתוך".
2. **רמה B עושה את העבודה**`importance` boost ב-RRF מטביע את עקרוני-הוועדה ה-persuasive מתחת
לפסיקה-המחייבת, בלי למחוק דבר. 8508 (`importance≈0`) שוקע ממילא.
3. **להעביר את ה-`nli` לראש תור-המחקר** — לפני שמשתמשים בו כמסנן-רעש ברמה A, לאמת אם 40% אמיתי.
---
> **מקור-הנתונים:** `GET /api/halachot?limit=100000` (5,489 שורות) + per-case `?case_law_id=…`.
> ניתן לשחזר את כל המספרים מהשאילתות האלו. הקאנון הוא live — שיעורים ינועו ככל שהדריינר/פאנל רצים.

View File

@@ -96,10 +96,11 @@ idempotency-key pattern · ISO 8000 (Data quality) | סטטוס: verified
בנקודת-סיום-הקליטה, לכל סוג; חוזה-שלמות יסמן רשומה ללא מטא-דאטה כלא-שמישה
([G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש), מפורט ב-
[02-data-model.md](02-data-model.md)).
**הפרה ידועה:** המסלול הפנימי (`internal_decisions.py:208`) מתזמן **רק**
`request_halacha_extraction` ואינו קורא ל-`request_metadata_extraction` (בניגוד
ל-`precedent_library.py:292-293` שקורא לשניהם) → ערן סופר 8046/24 נקלטה **בלי
metadata** (headnote/summary/tags ריקים) → ממצא ל-[audit](../audit-report.md).
**הפרה ידועה — ✅ נסגרה (FU-1, GAP-02):** המסלול הפנימי דילג בעבר על חילוץ-המטא-דאטה
(`internal_decisions.py:208` תיזמן רק `request_halacha_extraction`) → ערן סופר 8046/24 נקלטה
בלי metadata. **נסגר:** שני מסלולי-הפסיקה זורמים כעת דרך `ingest.ingest_document` הקנוני, שמתזמן
**את שני התורים יחד** בנקודה אחת (`ingest.py:233-234`) — תיזמון-המטא אינו מותנה במסלול עוד.
מאומת ב-`test_unified_ingest.py` (9/9). תיעוד-מקור: [audit](../audit-report.md).
### INV-ING4: re-index בקליטה-חוזרת (upsert ⇒ re-embed)
**כלל:** קליטה-חוזרת ששינתה את תוכן-הפריט מפעילה **re-index** — chunks ו-embeddings

View File

@@ -19,7 +19,7 @@
| ישות | תפקיד | מזהה-קנוני | שדות-מפתח (מאומתים `db.py`) |
|------|--------|-------------|------------------------------|
| `cases` | תיק ערר חי (1xxx/8xxx/9xxx) | `case_number` + `proceeding_type` | `title`, `status`, `practice_area`, `appeal_subtype`, `proceeding_type`, `chair_name` (`db.py:74-91,182-189,747,912`) |
| `documents` | מסמך-מקור משויך לתיק | `id` (UUID); FK→`cases` | `doc_type`, `title`, `file_path`, `extracted_text`, `extraction_status`, `page_count` (`db.py:93-104`) |
| `documents` | מסמך-מקור משויך לתיק | `id` (UUID); FK→`cases` | `doc_type`, `is_primary` (**נגזר** מ-`doc_type`, ראה [§2ג](#2ג-מסמך-עיקרי-primary-document)), `title`, `file_path`, `extracted_text`, `extraction_status`, `page_count` (`db.py:93-104`) |
| `document_chunks` | chunk של מסמך-תיק + embedding | `id`; FK→`documents`/`cases` | `chunk_index`, `content`, `section_type`, `embedding vector(1024)`, `page_number` (`db.py:106-116`) |
| `case_law` | קורפוס פסיקה — חיצוני **וגם** החלטות-ועדה | ראה [§2 + INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות) | `case_name`, `court`, `practice_area`, `source_kind`, `proceeding_type`, `source_type`, `headnote`, `summary`, `subject_tags`, `extraction_status`, `halacha_extraction_status` (`db.py:366-378,522-526,599-611,883,907`) |
| `precedent_chunks` | chunk של פסק-דין מואנדקס (`source_kind='external_upload'`/`internal_committee`) | `id`; FK→`case_law` | `chunk_index`, `content`, `section_type`, `page_number`, `embedding vector(1024)`, `content_tsv` (`db.py:624-634,776`) |
@@ -73,11 +73,45 @@ proceeding_type)`. לכן המזהה הקנוני הוא **(`case_number` מנו
### 2ב. חוזה תמציתי לישויות נוספות
- `documents` → usable: `file_path`+`doc_type`; searchable: `extraction_status=completed` ו-`extracted_text` לא-ריק ו-≥1 `document_chunk` עם embedding.
> **עיצוב-מחדש זרימת-העבודה (WS2, #200):** `documents` מקבל `is_primary`/`doc_category` (רשימה
> קנונית: ערר/תשובה/תגובה/פרוטוקול/שומה/החלטת-ועדה) + דגל-ניתוח per-מסמך (`claims_extraction_status`),
> כדי לחשב "יש מסמך-עיקרי שלא-נכלל-בניתוח". השדות בבעלות #200; ניתוח-החילוץ עצמו ב-[04-analysis-writing](04-analysis-writing.md) §1.
- `halachot` → usable: `rule_statement`+`supporting_quote`; **searchable: `review_status ∈ {approved, published}` בלבד**`pending_review`/`rejected` מוסתרות מ-`search_precedent_library` (שער-הלכה ידני, `db.py:644-660`, [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)).
- `decision_blocks` → usable: `block_id`∈12-הבלוקים; "מוכן": `status=final` ו-`content` לא-ריק.
- `chair_feedback` → usable: `feedback_text`+`category` מהמילון; "פתוח" עד `resolved=true`.
### 2ג. ישויות-נגזרות (אחסון-ניתוחים)
### 2ג. מסמך-עיקרי (primary document)
WS2 (#200) מבחין בין **מסמך-עיקרי** (מסמך-מהות שהיו"ר עוקבת אחריו לניתוח) לבין מסמך-משני.
**הרשימה הקנונית של doc_types עיקריים** (מאושרת-יו"ר): `appeal` (כתב-ערר) · `response`
(תשובה/תגובה) · `objection` (התנגדות) · `protocol` (פרוטוקול-דיון) · `appraisal` (שומה) ·
`decision` (החלטת-ועדה). **כל doc_type אחר משני** (`plan`/`permit`/`court_decision`/
`exhibit`/`reference`).
**`is_primary` נגזר, לא נכתב** ([G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה)/[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)/
[INV-DM7](#inv-dm7-סיווג-הלכה--סמכות-נגזרת--תפקיד-כלל-מסווג-שני-צירים-לא-enum-אחד)): מקור-האמת היחיד הוא
`doc_type` + הרשימה הקנונית `PRIMARY_DOC_TYPES` (`db.py`). העמודה
`documents.is_primary BOOLEAN GENERATED ALWAYS AS (doc_type = ANY(PRIMARY_DOC_TYPES)) STORED`
(SCHEMA_V47) מחושבת ע"י Postgres — **אין מסלול-כתיבה מקביל ולא יכולה לסטות** מ-`doc_type`,
בדיוק כמו ה-tsvectors ב-[INV-DM3](#inv-dm3-שינוי-תוכן--re-index). `_row_to_doc` גוזר גם בקריאה
(לרשומות טרום-מיגרציה) ומוסיף `doc_category ∈ {primary, secondary}` לפלט. נחשף דרך
`document_list` / `case_get` / API-המסמכים. אינדקס חלקי `idx_documents_primary(case_id) WHERE
is_primary` משרת את שאילתת "מסמכים-עיקריים שטרם-נותחו" (#201).
**דגל "לא-נותח" — per-מסמך (#201, SCHEMA_V49).** שני שדות מתעדים את *אירוע* חילוץ-הטענות
של המסמך (לא נגזרים מ-`doc_type`): `claims_extracted_at TIMESTAMPTZ` (נחתם ע"י
`claims_extractor` **אחרי** ששמרנו את טענות המסמך) ו-`claims_extraction_status TEXT`
(`pending`/`completed`/`no_claims`/`failed`). `_row_to_doc` חושף `claims_analyzed`
(`claims_extracted_at IS NOT NULL`). מקור-האמת היחיד ל"האם המסמך נכלל בניתוח" הוא
`claims_extracted_at` — לא משוחזר ממקום אחר. אינדקס חלקי `idx_documents_claims_pending(case_id)
WHERE is_primary AND claims_extracted_at IS NULL` (יחד עם `is_primary` של V47) הופך את
"מסמך-עיקרי שנוסף-אחרי-ניתוח ולא-נכלל" (`db.primary_docs_not_analyzed`) לשאילתה זולה.
נחשף ב-`workflow_status` (`primary_docs_not_analyzed` + next-step). הניתוח-מחדש המאחד
(`reanalyze_claims`, [04-analysis-writing §1.3](04-analysis-writing.md)) מחלץ-מחדש רק את
המסמכים-העיקריים החדשים/לא-נותחו, **מאחד** עם הטענות הקיימות, ומריץ צבירה-מחדש (G2 — מסלול
קנוני אחד, לא fork).
### 2ד. ישויות-נגזרות (אחסון-ניתוחים)
מעבר לישויות-המקור, המערכת **שומרת ניתוחים נגזרים** — תוצרי-חילוץ של LLM/קוד. אלו כפופים לכללי
ה-provenance של [X8](X8-field-provenance.md) ולשערי [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant):
@@ -176,18 +210,22 @@ RAG freshness (Lewis et al., 2020, NeurIPS) | סטטוס: verified
(`case_number, source_kind, proceeding_type`, `db.py:904-909`), אך השדה `case_number TEXT
UNIQUE NOT NULL` המקורי (`db.py:368`) הוסר רק ב-V15 (`db.py:902-903`) — מורשת שאפשרה את
הפרת [INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות). **יעד:** נרמול-בכתיבה אכוף + ציטוט-מלא רק ב-`citation_formatted`.
- **`summary` קיים על `case_law` אך לא בחוזה-הקליטה הפנימי.** העמודה קיימת (`db.py:373`) אך
המסלול הפנימי אינו ממלא אותה (כפועל-יוצא מהיעדר חילוץ-מטא-דאטה, [INV-ING3](01-ingest.md#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)).
**יעד:** searchable מותנה ב-metadata לא-ריק ([INV-DM1](#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים)).
- **שני שדות-סטטוס-חילוץ נפרדים, ללא דגל-`searchable` מפורש.** `extraction_status` +
`halacha_extraction_status` (`db.py:603-605`) מתארים תהליך, אך אין שדה יחיד שמסמן "עבר
חוזה-שלמות → searchable". **יעד:** דגל/view נגזר ש-search מסנן לפיו, מגובה health-check.
- **`summary` קיים על `case_law` אך לא בחוזה-הקליטה הפנימי — ✅ נסגר (FU-1, GAP-02).** העמודה
קיימת (`db.py:373`), והמסלול הפנימי לא מילא אותה בעבר (היעדר חילוץ-מטא-דאטה). **נסגר:** שני
המסלולים זורמים דרך `ingest.ingest_document` הקנוני שמתזמן metadata לשני הסוגים
([01-ingest INV-ING3](01-ingest.md#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)); `searchable`
מותנה ב-metadata לא-ריק ([INV-DM1](#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים)).
- **דגל-`searchable` מפורש — ✅ קיים.** מעבר ל-`extraction_status` + `halacha_extraction_status`
(`db.py:603-605`, תהליך), קיימת כעת עמודת `case_law.searchable` המסמנת "עבר חוזה-שלמות"; שכבת-
החיפוש מסננת לפיה (`cl.searchable = true`, `db.py:7517,7519`) ו-`recompute_searchable_flag`
מתחזק אותה. אוכף את [INV-DM1](#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים) בשכבת-האחזור.
- **`embedding` אינו `GENERATED` (בניגוד ל-tsvector).** ה-tsvectors מסונכרנים אוטומטית
(`db.py:776,780,1083`), אך ה-`embedding vector(1024)` תלוי-טריגר חיצוני — נקודת-drift אפשרית
ל-[INV-DM3](#inv-dm3-שינוי-תוכן--re-index). **יעד:** טריגר re-embed מובטח + health-check ל-drift.
- **`halachot.review_status` כשער-searchable ללא נראות-backlog.** הסינון תקין (`pending_review`
מוסתר, `db.py:659`), אך אין נראות כמה ממתינות — תואם את ההפרה הידועה ב-[G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
(10/19 מאושרות, התגלה במקרה). **יעד:** health-check חושף backlog-הלכות.
- **נראות backlog-הלכות — ✅ נסגר (FU-5, GAP-14).** הסינון תקין (`pending_review` מוסתר,
`db.py:659`); ההפרה הישנה היתה היעדר-נראות (10/19 מאושרות, התגלה במקרה). **נסגר:** ה-health-
check חושף `halacha_backlog` (`web/app.py:2352-2364`, `metrics_service.halacha_backlog`),
והכלים `extraction_status` + `halachot_pending` חושפים את עומק-התור — שער-היו"ר ([G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)) הופך נראה.
---

View File

@@ -79,11 +79,12 @@ quality / conformance) | סטטוס: verified
**אכיפה:** תנאי-`source_kind` בכל ענף-SQL בשכבת-החיפוש; בדיקת-בריאות שמריצה שאילתת-ביקורת
(חיפוש מכוון-קורפוס שמחזיר פריט בעל `source_kind` זר = כשל). אוכף את
[G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query).
**הפרה ידועה:** משימה #56 `halacha_filters` **אינם** כוללים `cl.source_kind` ב-
`search_precedent_library_semantic` (`db.py:3168`, ענף ה-halacha; לעומת `chunk_filters` שכן —
`db.py:3169`) **וב**-`search_precedent_library_lexical` (`db.py:3401` מול `db.py:3402`). שני
ה-`halacha_sql` עושים `JOIN case_law cl` בלי לסנן `source_kind` (`db.py:3236-3238`, `db.py:3475-3477`)
→ הלכות מהקורפוס הפנימי דולפות לתוצאות החיפוש בקורפוס החיצוני ולהפך → ממצא ל-[audit](../audit-report.md).
**הפרה ידועה — ✅ נסגרה (FU-4, GAP-10, משימה #56):** בעבר `halacha_filters` לא כללו
`cl.source_kind` (בניגוד ל-`chunk_filters` שכן) → הלכות מהקורפוס הפנימי דלפו לחיפוש בקורפוס
החיצוני ולהפך. **נסגר:** שני ה-`halacha_filters` (semantic + lexical) מתחילים כעת ב-
`cl.source_kind = '{source_kind}'` בדיוק כמו `chunk_filters` (`db.py:7516,7519` ומקבילו ב-
`search_precedent_library_lexical`). מאומת ב-`test_precedent_corpus_isolation.py` (semantic+lexical,
שני ה-source_kind) → תיעוד-מקור: [audit](../audit-report.md).
### INV-RET2: אין החזרה/אינדוקס בלי metadata מלא + locator פתיר
**כלל:** פריט אינו מוחזר מ-search (ואינו נחשף לאחזור) אלא אם **שדות-החובה שלו מולאו**
@@ -121,10 +122,13 @@ relevance judgments, precision/recall) · RAG evaluation literature (Lewis et al
Elastic — *relevance evaluation guidance* | סטטוס: verified
**אכיפה:** eval harness עם gold-set מתויג + בדיקת-בריאות תקופתית; שער-CI על שינוי שכבת-האחזור.
אוכף את [G8](00-constitution.md#inv-g8-איכות-אחזור-נמדדת--precision--recall).
**הפרה ידועה (GAP):** אין כיום eval harness ולא gold-set — קיים רק `telemetry.log_search_bg`
(`search.py:62,118,190,271`; `precedent_library.py:280`) שמתעד שאילתות בפועל, אך **אינו מודד
precision/recall מול תיוג** (תצפית, לא הערכה). היעד: harness שמריץ סט קבוע ומחזיר metrics →
ממצא ל-[audit](../audit-report.md).
**הפרה ידועה — ✅ נסגרה (FU-5, GAP-11):** בעבר היה רק `telemetry.log_search_bg` (תצפית, לא
הערכה). **נסגר:** `scripts/eval_retrieval.py` מריץ את **מסלול-האחזור בייצור** (אותן פונקציות-
שירות שכלי-ה-MCP קוראים) מול gold-set מתויג (`data/eval/gold-set.jsonl`, נבנה ב-
`scripts/eval_gold_bootstrap.py`) ומחזיר precision/recall/MRR/nDCG@{5,10} — סך-הכול, פר-קורפוס
ופר-`practice_area` — מול baseline מקובע (`data/eval/baseline.json`). מטריקות-הליבה נבדקות
offline ב-`eval_retrieval.py --self-test` ("ALL PASS"). **משמעת-CI:** להריץ לפני/אחרי כל שינוי
שכבת-אחזור (משקלי-RRF/`k`/סף-chunk/embedder/rerank) ולהשוות ל-baseline. תיעוד-מקור: [audit](../audit-report.md).
### INV-RET5: כל span מוחזר עקיב למקורו
**כלל:** כל קטע מוחזר נושא **עקיבוּת-מקור מלאה** — מזהה-מסמך/פסק-דין (`case_law_id`/`document_id`/
@@ -156,16 +160,15 @@ re-embed; בדיקת-בריאות מגלה embeddings מיושנים. אוכף
ההבדלים בין הקוד בפועל ל-TARGET. **אלו תסמינים, לא התנהגויות תקינות.** כל פריט אומת מול הקוד.
- **דליפת-הלכות חוצת-קורפוס (משימה #56).** `halacha_filters` נפתחים רק עם `review_status`
(`db.py:3168`, `db.py:3401`) ואינם מוסיפים `cl.source_kind`, בעוד `chunk_filters` כן
(`db.py:3169`, `db.py:3402`). שני ה-`halacha_sql` עושים `JOIN case_law` בלי סינון
(`db.py:3236-3242`, `db.py:3463-3482`). **תסמין:** חיפוש בקורפוס החיצוני
(`search_precedent_library`, `source_kind="external_upload"`) יכול להחזיר הלכה שמקורה
בהחלטת-ועדה פנימית — ולהפך עבור `search_internal_decisions` (`source_kind="internal_committee"`,
`internal_decisions.py:418`). **יעד:** `halacha_filters` יתחילו ב-`cl.source_kind = '{source_kind}'`
בדיוק כמו `chunk_filters` ([INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query)).
- **אין eval harness — מדידת-איכות לא קיימת.** רק `telemetry.log_search_bg` מתעד שאילתות
(`search.py:62,118,190,271`); אין gold-set מתויג ואין precision/recall. **יעד:** harness עומד
- **דליפת-הלכות חוצת-קורפוס (משימה #56) — ✅ נסגר (FU-4, GAP-10).** בעבר `halacha_filters` לא
כללו `cl.source_kind` (בניגוד ל-`chunk_filters`) → הלכה מהקורפוס הפנימי יכלה לחזור בחיפוש
בקורפוס החיצוני, ולהפך. **נסגר:** `halacha_filters` מתחילים כעת ב-`cl.source_kind = '{source_kind}'`
+ `cl.searchable = true` בדיוק כמו `chunk_filters` (`db.py:7514-7519`), בשתי הפונקציות
(semantic+lexical). מאומת ב-`test_precedent_corpus_isolation.py`
([INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query)).
- **eval harness — ✅ נסגר (FU-5, GAP-11).** בעבר רק `telemetry.log_search_bg` (תצפית).
**נסגר:** `scripts/eval_retrieval.py` (precision/recall/MRR/nDCG מול `data/eval/gold-set.jsonl`
+ baseline; `--self-test` עובר offline) — מדידה אמפירית מול תיוג קבוע
([INV-RET4](#inv-ret4-איכות-אחזור-נמדדת-ב-eval-harness-עומד-precision--recall)).
- **`search_decisions` מתעד אזהרה כשאין `practice_area` אך לא חוסם.** ללא פילטר-תחום החיפוש
עלול לערבב תחומים משפטיים (`search.py:45-49,172-176``logger.warning`, ממשיך). **יעד:** הפרדה

View File

@@ -47,6 +47,122 @@
> מקוריות מהשלמות). הסינון לפי `party_role` מאפשר לזהות את הצד המפסיד ולוודא שכל טיעון
> שלו מקבל מענה בבלוק י.
> **עיצוב-מחדש זרימת-העבודה (WS2WS4) — מסלול-החילוץ אינו מתפצל ([G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)):**
> ניתוח-ערר-לבד כבר עובד (`extract_claims` רץ על `appeal` בנפרד). ההרחבות נשענות על אותו צינור-
> חילוץ, **לא** על מסלול-ניתוח שני: (א) **ניתוח-מחדש מאחד** (WS2, #200/#201) — מחלץ מ-מסמכים
> חדשים/מעודכנים בלבד ומאחד עם הקיים (לא מוחק-ומחשב-מחדש), על-בסיס דגל-הניתוח per-מסמך
> ([02-data-model §2ב](02-data-model.md#2ב-חוזה-תמציתי-לישויות-נוספות)); (ב) **סיכום-מנהלים**
> (WS3, #202, `summarize_party_claims` — ראה §1.4) — נגזר מ-`claims`/`legal_arguments`, **מסמך-פרוזה נפרד**
> מטיוטת-הביניים (G2: תצוגה-נגזרת, לא מקור-אמת שני); (ג) **ניתוח-פרוטוקול** (WS4, #203,
> `analyze_protocol` — ראה §1.3) — `protocol` נכנס לחילוץ ההשוואתי (ירד/חוזק/חדש) ומזין ידע-תיק, ונשאר
> פוסט-דיון ([INV-WR4](#inv-wr4-בלוק-ז--טענות-מקוריות-בלבד): טענות-פוסט-דיון → בלוק ח, לא ז).
> חוזי-הכלים המלאים בבעלות המשימות הנ"ל (§1.3/§1.4); כאן רק עיגון-הספ שהם מקיימים G2.
### 1.3 ניתוח-פרוטוקול — ניתוח פרוטוקול-דיון השוואתי (WS4 / #203)
> **בעלוּת תת-סעיף:** §1.3 (זה) שייך ל-WS4 (ניתוח-פרוטוקול). תת-סעיף סיכום-טענות-הצדדים
> (PR-אחות #358) הוא **תת-סעיף נפרד** (§1.4) תחת §1 — אין חפיפת-מספור בין השניים.
אחרי הדיון, פרוטוקול (`doc_type='protocol'`) מנותח **מול הטיעונים המאוגדים**
`analyze_protocol(case_number)` (→ `services/protocol_analyzer.py`). הניתוח מסווג כל
טיעון כתוב כ**ירד** (`dropped` — נזנח/ויתר בדיון), **חוזק** (`strengthened`), או מזהה
טענה ש**עלתה לראשונה בדיון** (`newly_raised`), ולכל רשומה מנסח את ה**שאלה המשפטית
המחודדת** לטובת בלוק י. התוצאה נשמרת ב**ידע-התיק** — טבלת `protocol_analysis` (case-
knowledge נגזר; G2: מקור-האמת הוא הפרוטוקול + `legal_arguments`, הרשומה היא השוואה
מטוריאליזת בת-שחזור) — וזמינה ל-`get_protocol_analysis(case_number, change_type="")`
(סימטריית extract/get, [INV-TOOL4](X9-mcp-tool-contract.md)).
- **קלט:** הטיעונים המאוגדים (§1.2) הם קו-הבסיס; ללא כינוס אין מול-מה להשוות.
- **שער anti-hallucination ([INV-AH](../anti-hallucination-gate.md)):** כל רשומה חייבת
`evidence_quote` — ציטוט **מילולי** מהפרוטוקול. רשומה ללא ציטוט-מבסס נדחית **במקור**
(`_normalize_change`) ולא מגיעה ל-DB (quote-or-retract).
- **טענות-הדיון בנפרד מבלוק ז:** טענות שעלו בדיון מתויגות `claim_type='protocol'`
ב-`extract_claims` ו**מוחרגות** מהקשר בלוק ז (`block_writer._build_claims_context`) —
בלוק ז נשאר **טענות-כתב מקוריות בלבד** ([INV-WR4](#inv-wr4-בלוק-ז--טענות-מקוריות-בלבד));
טענות-הדיון שייכות לבלוק ח (הליכים) ולידע-התיק ההשוואתי.
- **נתוני כותרת (א–ד):** הניתוח מחלץ גם את הפיד המוכר (הרכב, תאריך-דיון, צדדים שהופיעו)
ומזין את `hearing_date` חזרה לעמודה הקנונית `cases.hearing_date` (G1; לא נכתב אם
היו"ר כבר מילא תאריך — לא דורסים קלט-יו"ר).
- **ייצור:** קריאת-ה-LLM ההשוואתית עוברת `claude_session` (מקומי בלבד), מעוגנת
`model="claude-opus-4-8"` + `effort="high"` (ראה `reference_claude_generation_path`).
**חלון-הקשר (#216):** הבנייה הסטנדרטית רצה 200K-טוקן (~400K תווים עברית). פרומפט גדול
(בעיקר בלוק-י, שנושא את התיק המלא כ-source-context) מוסלם אוטומטית לבניית-ה-1M של *אותו*
מודל — `claude-opus-4-8[1m]` (1M-טוקן, מחיר רגיל; `claude -p` חושף אותה ישירות כ-model-id,
בלי beta-header). **עידון של אותו pin, לא מסלול-מודל מקביל (G2).** הסף ב-`block_writer`:
פרומפט > ~350K תווים → `[1m]`; אחרת הבנייה הסטנדרטית. תקרת-קשיחה: 1.5M תווים.
### 1.4 סיכום-מנהלים של טענות הצדדים (מסמך-הכנה לדיון, WS3/#202)
`summarize_party_claims(case_number, instructions="")` מפיק **מסמך-פרוזה מזוקק** של
טענות הצדדים — תמצית-מנהלים קצרה ומוקפדת שמטרתה **להכין את היו"ר לדיון בעל-פה**. זהו
מסמך **נפרד ומובחן מטיוטת-ההחלטה ומטיוטת-הביניים** (החלטת-יו"ר, [תוכנית workflow-redesign](../../.claude/plans/groovy-doodling-token.md)
§WS3) — אינו חלק מ-12-הבלוקים ואינו נכתב לתבנית ההחלטה.
- **מקור-אמת יחיד (G2):** המסמך נגזר מ-`legal_arguments` (טיעונים מאוגדים) או, כ-fallback,
מ-`claims` הגולמיים — **אותו מקור** של §§1.11.2, **ללא חילוץ-מחדש** ובלי לקרוא לכתבי-הטענות
ישירות. אין מסלול-נתונים מקביל.
- **זיקוק, לא שכפול:** התמצית מתמצתת כל צד למשפטי-מפתח ומוסיפה פרק "נקודות-המחלוקת המרכזיות"
כשאלות פתוחות — לא משכפלת את כתבי-הטענות ולא מכריעה.
- **עיגון-מקור (INV-AH):** הפרומפט מתוחם לחלוטין לטענות-התיק שבקלט; אסור להמציא טענה/הלכה/
פסק-דין/עובדה שאינם בקלט — טענה לא-ברורה מצוינת במפורש ([anti-hallucination-gate](../anti-hallucination-gate.md)).
- **ייצור local-only:** עובר `claude_session``claude -p` נעוץ ל-Opus 4.8 + `effort=high`
(משימת זיקוק/סינתזה). הקונטיינר חסר ה-CLI — לכן הייצור הוא כלי-MCP מקומי בלבד; endpoints
ב-`web/app.py` רק **מגישים/מייצאים** את הקובץ השמור, לא מייצרים.
- **שמירה + ייצוא:** נשמר ל-`data/cases/{n}/documents/research/party-claims-summary.md`
(git + S3, באותו מסלול-אחסון של `analysis-and-research.md`); ניתן-לייצוא ל-DOCX בסגנון-תבנית
דפנה (`build_party_claims_summary_docx`). מימוש: `tools/drafting.py` ·
`services/party_claims_summary.py` · `services/analysis_docx_exporter.py`.
- **טריגר — side-quest מבוקש-יו"ר (G10), נפרד מטיוטת-הביניים:** ה-CEO מפיק את הסיכום כשלב-צד
(`legal-ceo.md` שלב H2) ב-**שתי דרכים**: (א) הערת-יו"ר חופשית ("סיכום מנהלים" / "סיכום טענות" /
"סיכום לקראת דיון" / "executive summary"); או (ב) **פעולה סטרוקטורלית**
`$PAPERCLIP_WAKE_PAYLOAD_JSON` עם `action == "party_claims_summary"` (המסלול הדטרמיניסטי שכפתור-UI
עתידי יפעיל, בלי פענוח-טקסט). זהו side-quest: אינו משנה `cases.status` ואינו יוצר issues לסוכני-משנה,
ומובחן מטיוטת-הביניים (`write_interim_draft`, שלב H, `action == "interim_draft"`).
### 1.5 ניתוח-מחדש מאחד אחרי מסמך-עיקרי חדש (#201)
תיק מנותח לרוב מכתב-הערר לבד, ומסמך-עיקרי (תשובה/התנגדות/פרוטוקול…) מגיע מאוחר יותר.
כל מסמך נושא **דגל "לא-נותח"** (`claims_extracted_at`, [02-data-model §2ג](02-data-model.md)),
ו-`workflow_status` מסמן "מסמך-עיקרי שטרם-נכלל בניתוח". `reanalyze_claims(case_number,
reanalyze_all_primary=False)` סוגר את הפער **בלי force-delete גורף**:
1. **snapshot לפני** — הטיעונים המאוגדים הנוכחיים (בסיס בדיקת-ההשפעה).
2. **חילוץ-מאחד** — מחלץ רק את המסמכים-העיקריים החדשים/לא-נותחו (`db.primary_docs_not_analyzed`)
דרך **אותו** `claims_extractor.extract_and_store_claims`; `store_claims` מחליף רק את טענות
*אותו* מסמך (לפי `source_document`), כך שטענות ממסמכים שכבר-נותחו **נשמרות** (האיחוד).
3. **צבירה-מחדש**`aggregate_claims_to_arguments(force=True)` מחשב את הטיעונים מחדש מתוך
מערך-הטענות המאוחד השלם. זהו **מסלול-החישוב הקנוני** ([G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)),
לא מסלול-עיבוד מקביל; הוא מוחק רק `legal_arguments` (נגזר), לעולם לא `claims`.
4. **בדיקת-השפעה ליו"ר** — snapshot אחרי + `_impact_diff` מחזיר per-צד אילו טיעונים
נוספו/הוסרו ואיך השתנה תמהיל-העדיפויות ("מאזן-ההמלצה"), עם דגל `changed`. הפלט **מוצג**
ליו"ר ולא מוחל אוטומטית ([G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)).
ראה `tools/legal_arguments.py` (`reanalyze_claims`, `_snapshot`, `_impact_diff`).
### 1.6 שער שטן-מליץ (red-team) — בין הניתוח לכתיבה, תחת אישור-יו
אחרי שלב-הניתוח (`analysis-and-research.md` תקין) וב**לפני** הכותב, ה-CEO מפעיל **אוטומטית**
שכבת **דעה-שנייה אדוורסרית** מ-lineage שונה (Gemini, `legal-analyst-gemini-critique`,
read-only) — **שער-קבע (standing gate)**, לא on-demand. השכבה תוקפת את ניתוח-Opus ומפיקה
`critique-gemini.md` = **מזכר-לידים לא-סמכותי**, מתויג-ודאות (`[מאומת-קורפוס]`/`[טעון-אימות]`/
`[ספקולציה]`), כפוף לשער ה-anti-hallucination ([INV-AH](../anti-hallucination-gate.md);
כלי-RAG משפטיים הוזים פסיקה 1733%, Stanford RegLab/Magesh JELS 2025).
- **human-in-the-loop קשיח ([G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)):**
ה-CEO מציג את הלידים ליו"ר כ**עצירת-אישור** (ה-issue הראשי ל-`in_review` + מייל), ואינו מתקדם
לכותב בלי הכרעת-יו"ר מפורשת. **רק לידים שהיו"ר אישר** מומרים ל-`chair_directions` דרך
מנגנון-ההנחיות הקיים (`record_chair_feedback` → "עמדת ועדת הערר" ב-`analysis-and-research.md`
`get_chair_directions``approve_direction`); לידים שנדחו נמחקים.
- **הכותב צורך מקור-מעוגן בלבד ([INV-WR1WR5](#4-invariants-של-התחום--תוכן-החלטה-מנומקת) +
[INV-LRN5](07-learning.md)):** הקלט לכתיבה הוא **פלט-המנתח המעוגן + ההנחיות-המאושרות**
**לעולם לא** הלידים הגולמיים של שטן-מליץ. השכבה אינה מסלול-נתונים מקביל לכתיבה
([G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)) ואינה כותבת
שום שכבת-קול/ידע (INV-LRN5) — read-only ל-`critique-gemini.md` בלבד.
- **מקור-אמת לזרימה:** [X4 INV-AG4](X4-agents.md#inv-ag4-שער-שטן-מליץ--red-team-לידים-לא-סמכותיים-תחת-אישור-יור)
+ [legal-ceo.md](../../.claude/agents/legal-ceo.md) "שלב A2"/"שלב A3" +
[legal-analyst-gemini-critique.md](../../.claude/agents/legal-analyst-gemini-critique.md).
(שינוי-מדיניות יו"ר 2026-06-30: from on-demand to standing gate — TaskMaster `legal-ai` #211.)
---
## 2. ארכיטקטורת 12 הבלוקים (סיכום)

View File

@@ -43,6 +43,16 @@
> וגם **טיוטת-ביניים** (`mode="interim"` — תת-קבוצת בלוקים בסדר חדש: רקע→תכניות→טענות→הליכים,
> `export_interim_draft`, `drafting.py:511`). שני המצבים שולפים מאותה טבלה — וריאציית-תצוגה
> של אותו מקור-אמת, לא מסלול שני.
>
> **דטרמיניזם-מבני של טיוטת-הביניים (#204 / WS5).** סדר-בלוקי-הביניים בייצוא קבוע
> ב-`docx_exporter._INTERIM_BLOCK_ORDER` (רקע→תכניות→טענות→הליכים, עם בלוקי-תבנית א–ה
> אופציונליים בתחילה). הבלוקים עצמם נכתבים דרך **אותו מסלול-כתיבה קנוני** של ההחלטה
> המלאה (`block_writer.write_and_store_block` → `write_block`, G2 — אין מסלול-ייצור
> מקביל), אך כעת **נעוצים למודל יחיד (`claude-opus-4-8`) עם `effort` per-בלוק ובפרומפט-
> מבני קבוע** (למשל בלוק-ה פותח תמיד ב"לפנינו ערר…"). התוצאה: אותו תיק מפיק את אותו
> **מבנה** בכל ריצה (אי-עקביות-הניסוח הקודמת נבעה מפרומפט-חופשי + מודל-ברירת-מחדל לא-נעוץ).
> טמפרטורה אינה במסלול (Opus 4.7/4.8 דוחים אותה); ראה `block_writer.GENERATION_MODEL`
> + `BLOCK_CONFIG[*].effort`.
> - `analysis_docx_exporter.py` (`build_analysis_docx`, `:401`) — מייצא את מסמך **הניתוח
> המשפטי** (`analysis-and-research.md`) שכתב `legal-analyst`, לא את בלוקי-ההחלטה. זהו תוצר-עזר
> שונה (שלב ניתוח, לא החלטה) — והוא המסלול שהסקיל מתעד בעיקר. שניהם חולקים את **אותו טמפלט
@@ -101,10 +111,14 @@ Model / Single Source of Truth) · SSOT (Single Source of Truth principle) | ס
פלט מגורסן + idempotent מבחינת-תוכן; אוכף את
[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) וכלל-ההנדסה
"סימטריה" (חוקה §6).
**הפרה ידועה:** אחרי `revise_draft`/`apply_user_edit`, ה-DOCX המסומן `active_draft_path` הופך
ל"מקור-האמת" לעריכות-Track-Changes העוקבות (`db.py:185-188`), ו**בלוקי-ה-DB אינם מתעדכנים
חזרה**הנתון-הנגזר זוחל למקור-אמת בפועל בלי סנכרון לאחור. **יעד:** או re-sync מהבלוקים, או
חוזה מפורש ש-`active_draft_path` הוא רק אנקור-revision ולא מקור-תוכן → ראה [§5](#5-current-vs-target).
**הפרה ידועה — ✅ מגודרת (FU-7, GAP-17):** אחרי `revise_draft`/`apply_user_edit` ה-DOCX
המסומן `active_draft_path` משמש כאנקור לעריכות-Track-Changes העוקבות, ובלוקי-ה-DB אינם
מתעדכנים חזרה — סטייה אפשרית בין הבלוקים למסמך-החי. **התיקון שנבחר (חוזה מפורש, לא re-sync):**
`active_draft_path` הוא **אנקור-revision בלבד, לא מקור-תוכן מתחרה** — ה-DB נשאר מקור-האמת. סטייה
נלכדת בדגל `cases.blocks_stale` (V22, `db.py:1142-1148`): `revise_draft`/`apply_user_edit`
מסמנים `mark_blocks_stale(case_id, True)` (`drafting.py:688,789`) ו-`export_docx` מנקה
(`mark_blocks_stale(case_id, False)`, `drafting.py:504`) — הדגל הוא ה-drift-detection שחושף מתי
ה-DOCX-החי נסחף מהבלוקים. ראה [§5](#5-current-vs-target).
### INV-EX2: עקיבוּת-מקור נשמרת בהחלטה המיוצאת (→G9)
**כלל:** ההחלטה המיוצאת **שומרת על עקיבוּת-מקור** היכן שנדרש — סמכויות-משפטיות מצוטטות
@@ -127,29 +141,53 @@ Lewis et al. (2020, NeurIPS — RAG attribution) | סטטוס: verified
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* (controlled, auditable
output) · Council of Europe / CEPEJ (2018, under user control) · Federal Judicial Center —
*Judicial Writing Manual* (2d ed.) | סטטוס: verified
**אכיפה:** `export_blocked = critical_failures > 0` (`qa_validator.py:362`); **נאכף ברמת-
הזרימה/agent בלבד** — הסוכן `legal-exporter` מחויב להריץ `validate_decision` ולבדוק
כשלים-קריטיים לפני ייצוא (`.claude/agents/legal-exporter.md:71,149`).
**הפרה ידועה:** `export_docx` (`drafting.py:384`) **אינו** קורא ל-`validate_decision` בעצמו —
הוא ניגש ישירות ל-`docx_exporter.export_decision` בלי לבדוק `export_blocked`. החסימה תלויה
במשמעת-הסוכן ואינה hard-block בקוד-הייצוא → ראה [§5](#5-current-vs-target) (תואם
[05-qa-review §4](05-qa-review.md#4-current-vs-target--ממצאי-audit)).
**אכיפה — ✅ hard-block בקוד (FU-6, GAP-15):** `export_docx` (`drafting.py:462`) **בודק לעצמו**
לפני כל ייצוא — `db.qa_run_exists` (אם QA לא רץ כלל → חסום) ו-`db.get_critical_qa_failures`
(אם יש כשל-קריטי → חסום) — **לפני** הגישה ל-`docx_exporter.export_decision`. אלו `SELECT` זולים
על ה-`qa_results` המאוחסנים (לא הרצת-LLM חוזרת). נוסף על `export_blocked = critical_failures > 0`
ברמת-הזרימה ועל משמעת-הסוכן `legal-exporter` (`.claude/agents/legal-exporter.md:71,149`) — כך
שאי-אפשר לעקוף את השער אפילו בקריאה ישירה ל-`export_docx`.
**הפרה ידועה — ✅ נסגרה (FU-6, GAP-15):** בעבר `export_docx` ניגש ישירות ל-exporter בלי לבדוק
`export_blocked` (אכוף-זרימה בלבד, ניתן-לעקיפה). נסגר ע"י ה-hard-block לעיל; מאומת ב-
`test_export_qa_gate.py` (חסום ללא-QA · חסום בכשל-קריטי · עובר כשנקי).
### INV-EX4: מקור-תבנית-יחיד — כל מפיק-מסמך מחיל סגנונות-טמפלט בלבד (→G2)
**כלל:** **כל** מפיק של מסמך-Word הקשור-להחלטה — סופי (`export_docx`), טיוטת-ביניים
(`export_interim_draft`), סיכום-מנהלים (`party-claims-summary`), ומסמך-הניתוח
(`build_analysis_docx`), וכן מסלול-העריכה (`revise_draft`/`apply_user_edit`
`docx_reviser`) — **חייב לטעון את הטמפלט היחיד** `skills/docx/decision_template.docx`
ולהחיל עיצוב **אך-ורק דרך שמות-הסגנונות שמוגדרים בו** (`paragraph.style = "…"` /
`<w:pStyle w:val="…"/>`). **אסור hand-setting** של משפחת-גופן (`rFonts`), גודל-גופן
(`sz`/`szCs`), או שוליים — אלה נגזרים מ-`styles.xml` של הטמפלט (cascade). היחיד שמותר
לכפות מפורשות הוא **יישור-הגוף הקנוני `jc=both`** היכן שהוא דורס סגנון שמגיע ממורכז
(`_set_paragraph_jc`, `docx_exporter.py:80`) — כי זו ההתנהגות-הקנונית, לא עיצוב-אד-הוק.
סמני-כיווניות (`<w:bidi>`, run `<w:rtl/>`) מותרים (כיווניות, לא עיצוב). **טמפלט אחד = מקור-סגנון
יחיד; אין שכבת-סגנון מקבילה.**
**מקורות:** SSOT (Single Source of Truth) · Martin Fowler — Canonical Data Model · ECMA-376
(OOXML) §17.7.2 — הורשת-סגנונות-פסקה (paragraph style inheritance / cascade) | סטטוס: verified
**אכיפה:** `docx_exporter` ו-`analysis_docx_exporter` טוענים `TEMPLATE_PATH` ומחילים style-by-name
(ראה §2); `docx_reviser` מחיל `pStyle` מן-הטמפלט + `jc=both` (לא פונט/גודל ידני). שומר-CI
`test_docx_template_single_source.py` גורף את מודולי-מפיקי-ה-DOCX ונכשל על `rFonts`/`sz`/`szCs`
ידני מחוץ לטוען-הטמפלט. אוכף את
[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) בשכבת-הרינדור.
**הפרה ידועה — ✅ נסגרה:** `docx_reviser._build_paragraph` כפה `jc=right` + David/`sz` ידני על כל
פסקה שהוכנסה במסלול-העריכה (`revise_draft`/`apply_user_edit`) → פלט מסלול-העריכה איבד את היישור
הדו-צדדי של הטמפלט והכניס עיצוב-אד-הוק. נסגר ע"י החלת `pStyle` מן-הטמפלט; מאומת ב-
`test_docx_reviser.py` + `test_docx_template_single_source.py`.
---
## 5. Current vs Target
- **שער-ייצוא אכוף-זרימה ולא אכוף-קוד (INV-EX3 / INV-QA3).** אומת בקוד: `export_docx`
(`drafting.py:384-419`) קורא ישירות ל-`docx_exporter.export_decision` (`:403`) ללא קריאה
ל-`qa_validator.validate_decision` ובלי בדיקת `export_blocked`. החסימה מתקיימת רק כי הסוכן
`legal-exporter` מחויב להריץ QA קודם (`legal-exporter.md:71,149`) — אדם/סוכן שיקרא
ל-`export_docx` ישירות **יעקוף** את השער. **יעד:** hard-block בתוך `export_docx` — שליפת
`qa_results`/`export_blocked` ודחייה לפני כתיבת ה-DOCX, כך שאי-אפשר לעקוף.
- **`active_draft_path` כ-derived-שזוחל-למקור (INV-EX1).** ה-DOCX נגזר מהבלוקים בייצוא הראשון,
אך אחרי עריכה (`revise_draft`/`apply_user_edit`) ה-DOCX הופך ל"מקור-האמת" לעריכות הבאות
(`db.py:185-188`) בלי לעדכן את `decision_blocks` חזרה — סטייה אפשרית בין הבלוקים למסמך-החי.
**יעד:** חוזה מפורש — או re-sync מהבלוקים, או הגדרת `active_draft_path` כאנקור-revision בלבד
(לא מקור-תוכן), עם בדיקת-בריאות לגילוי drift בין הבלוקים ל-DOCX הפעיל.
- **שער-ייצוא — ✅ hard-block בקוד (INV-EX3 / INV-QA3, FU-6 / GAP-15).** `export_docx`
(`drafting.py:462-494`) בודק `db.qa_run_exists` + `db.get_critical_qa_failures` ודוחה לפני
הגישה ל-`docx_exporter.export_decision` — לא ניתן לעקוף בקריאה ישירה. מאומת ב-
`test_export_qa_gate.py` (3 מקרים: ללא-QA / כשל-קריטי / נקי).
- **`active_draft_path` כ-derived (INV-EX1, FU-7 / GAP-17) — ✅ מגודר בחוזה מפורש.** ה-DB נשאר
מקור-האמת; `active_draft_path` הוא אנקור-revision בלבד. ה-drift בין הבלוקים ל-DOCX-החי נלכד
בדגל `cases.blocks_stale`: נדלק ב-`revise_draft`/`apply_user_edit` (`drafting.py:688,789`),
כובה ב-`export_docx` (`drafting.py:504`). **שארית (low-pri):** health-check שמתריע על
`blocks_stale=true` עתיק — תיעוד-המשך, לא חוסם.
---

View File

@@ -35,12 +35,12 @@
היעד הוא **Text Style Transfer** מבוסס **פרופיל-סגנון מופשט** — להכליל את סגנון/שיטת דפנה ולהתאים לתיק הספציפי. fine-tuning של משקולות **לא רלוונטי**: המודל (Opus) סגור, והקורפוס (~48 החלטות, יו"ר חדשה) קטן מדי — מצב שבו הספרות מראה שפרופיל-מופשט + דוגמאות מנצח (≈+15% מעל RAG-בלבד). **מדיניות-העתקה לפי סוג-תוכן:** קבוע/נוסחאי (פתיחים דוקטרינליים, תבניות-סיום) → מותר להעתיק; ניתוח/טענות ספציפיים → להכליל ולהתאים; מהות (הלכה/עובדה מתיק אחר) → אסור (INV-LRN5).
### 0.2 שלושת ערוצי-ההזנה לכותב
1. **A — פרופיל-מופשט (ראשי):** voice-fingerprint + author-features כמותיים, מוזרק לכתיבה.
1. **A — פרופיל-מופשט (ראשי):** voice-fingerprint + author-features כמותיים, מוזרק לכתיבה. **חידוש מהקורפוס:** החלק הכמותי (יחסי-מבנה) מתרענן אוטומטית-ודטרמיניסטית מהקורפוס הגדל (`measure_corpus_ratios`→כותב); הפרוזה המופשטת מסונתזת מחדש ע"י `scripts/regenerate_voice_fingerprint.py` (Opus) כ**הצעה מגודרת-יו"ר** (INV-LRN1 קשיח — לא auto, היו"ר מקבעת ידנית; STYLE בלבד INV-LRN5).
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 (מדד-מרחק-סגנון).
`mark-final` → [1] INTAKE (snapshot של הטיוטה) → [2] PAIRING (בלוק↔בלוק) → [3] ALIGNMENT (diff פר-בלוק) → [4] DISTILLATION (מפריד סגנון↔מהות) → [5] CURATION (Hermes + שער-יו"ר) → [6] FEEDBACK (ניתוב לערוץ A/B/C) → [7] MEASUREMENT (מדד-מרחק-סגנון + snapshot-held-out פרוספקטיבי, §0.7).
### 0.4 ניהול ב-UI
`/methodology` = **עורך-הפרופיל היחיד** (declarative: יחסי-זהב, כללי-דיון, צ׳קליסטים, ביטויי-מעבר, אנטי-דפוסים, voice-invariants). `/training` = **שולחן-הלמידה** (קורפוס, פורטרט-סגנון, השוואת draft↔final, curator, מדד-מרחק, פנקס-התאמה).
@@ -48,7 +48,7 @@
**שער-אישור אחד · טרנזקציית-כותב אחת (INV-IA3 → [X17](X17-information-architecture.md)):** ל-`decision_lesson` יש **סטטוס-יחיד** שקובע "זורם-לכותב" — `review_status='approved'` (INV-LRN1/G10). הדגל `applied_to_skill` **הוסר** (היה אינפורמטיבי-בלבד, נכתב-לשומקום → בלבל את היו"ר ב"שני שערים"; גל-2 #131). לקח שהיו"ר מחבר ידנית נוצר כבר כ-`approved`; לקח-פאנל נוצר כ-`proposed` וממתין לשער. promote של זוג draft↔final מטמיע את הלקחים/הביטויים שהיו"ר בחר **דרך appeal_type_rules בטרנזקציה אחת נעולה (FOR UPDATE)** — מסלול-כתיבה-יחיד, ללא read-modify-write מתפצל מול עורך-המתודולוגיה (MET-2/3, להלן G2 הפרות-ידועות).
### 0.5 Invariants חדשים
**INV-LRN4 (ניגוד-אמת → G10/G9):** למידת-קול מבוססת **pairing draft↔final ברמת-בלוק**, לא קריאת-final בלבד. כל החלטה אינה "סגורה" עד שהושוותה מול הסופי; כל סופי מנותח מול הטיוטה. נשמר פנקס-התאמה (`draft_final_pairs`) עם מצב-חיים `draft_done → final_received → analyzed → lessons_folded`.
**INV-LRN4 (ניגוד-אמת → G10/G9):** למידת-קול מבוססת **pairing draft↔final ברמת-בלוק**, לא קריאת-final בלבד. כל החלטה אינה "סגורה" עד שהושוותה מול הסופי; כל סופי מנותח מול הטיוטה. נשמר פנקס-התאמה (`draft_final_pairs`) עם מצב-חיים `draft_done → final_received → analyzed → lessons_folded`. ההכללה (האם הלמידה משפרת תיקים שלא-נלמדו) נמדדת פרוספקטיבית — §0.7.
*מקורות:* 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 מפריד במקור.
@@ -62,9 +62,20 @@
3. **בדיקת-ציטוטים**`extract_internal_citations` מקשר את הפסיקה שההחלטה מצטטת לספרייה; כל ציטוט שאינו בספרייה **מסומן אוטומטית** כ-`missing_precedent` (open) להעלאה ע"י היו"ר.
4. הציטוטים-המקושרים מזינים את **לולאת-ה-corroboration** (X11): ציטוט-נכנס מההחלטה שלנו מחזק את ההלכות של התקדים המצוטט (`corroboration_rebuild`).
ואז שני שלבים אוטומטיים נפרדים (`run-learning` / `run-halacha`) המעירים worker מקומי (claude/DeepSeek/Gemini מקומיים בלבד):
- **למידה:** `ingest_final_version` (Opus distillation) → **פאנל-סגנון דו-סוכני** (DeepSeek+Gemini, "למידה כפולה") שמצביע על כל לקח-style_method; הסכמה 2/2 → `decision_lesson` (`source=panel:deepseek+gemini`); פיצול → ליו"ר.
- **למידה:** הצינור (`final_learning_pipeline.py`) רץ בסדר `enroll_style_corpus` (יצירת רשומת-הקורפוס, מהיר — **ראשון** מאז #159 כדי שהקורפוס יהיה זמין לפני הדיסטילציה הארוכה) → `ingest_final_version` (Opus distillation) → **פאנל-סגנון דו-סוכני** (DeepSeek+Gemini, "למידה כפולה") שמצביע על כל לקח-style_method; הסכמה 2/2 → `decision_lesson` (`source=panel:deepseek+gemini`) **שזורם אוטומטית לכותב** כ-`approved` (שער-מדורג, INV-LRN1) — הפיך (veto-יו"ר ב-/training); פיצול → ליו"ר. **במקביל** (יקיצת `final_learning_*`) האוצֵר ממשיך ל-§A ורושם ממצאי-`source='curator'` (ערוץ ג׳, §1.1).
- **הלכות:** `extract_internal_citations``precedent_extract_halachot``corroboration_rebuild`**פאנל-הלכות תלת-סוכני** (`halacha_panel_approve.py --apply`).
שני הפאנלים **הפיכים** (גיבוי-CSV ל-`data/audit/`) ומסלימים מחלוקות. ההטמעה הסופית ל-`SKILL.md`/`legal-decision-lessons.md` נשארת **אישור-יו"ר ידני** (INV-LRN1/G10) — הפאנל יוצר *הצעות* בלבד.
שני הפאנלים **הפיכים** (גיבוי-CSV ל-`data/audit/`) ומסלימים מחלוקות. ההטמעה ל-`SKILL.md`/`legal-decision-lessons.md` ולכל **מהות** נשארת **אישור-יו"ר ידני קשיח** (INV-LRN1/G10); לקחי-**סגנון** בקונצנזוס זורמים אוטומטית-והפיך לכותב.
### 0.7 מדד-ההכללה הפרוספקטיבי (held-out trend — "מסלול A")
**השאלה שזה עונה:** האם הלמידה באמת *מכלילה* — כלומר משפרת טיוטות של תיקים **שלא נלמדו** — ולא רק "משננת" תיקים שכבר ראינו. מדד-מרחק-הסגנון (שלב [7]) על תיק שלקחיו כבר הוטמעו אינו held-out; מדידה רטרואקטיבית בלתי-אפשרית כי הלקחים נשמרים `appeal_type_rules` (`universal`) **ללא תיוג-מקור** → אין leave-one-out. לכן המדידה **פרוספקטיבית**: נלכדת ברגע היחיד שבו היא נקייה.
**המנגנון (אוטומטי, ב-`final/upload`, ללא LLM):** מיד אחרי פתיחת הזוג `draft_final_pair` אבל **לפני** הטמעת-לקחי-התיק (ה-fold הוא שלב-`promote` ידני נפרד ב-`/training`, §0.4), נלכד snapshot של `style_distance` (`anti_pattern_total`, סטיית-יחסי-זהב, `change_percent`) יחד עם **גודל-בריכת-הלקחים** באותו רגע (מספר `discussion_rules`+`transition_phrases` ב-`universal`). מכיוון שהטיוטה נכתבה עם הבריכה ה**קודמת** בלבד, כל שורה היא נקודת-נתון held-out נקייה: *"עם N לקחים מצטברים, הטיוטה שלנו על תיק שלא-נראה קיבלה ציון X"*.
**הקריאה:** טבלת `style_distance_history` (append-only) + `GET /api/learning/style-distance-history`. ירידה ב-`anti_pattern_total`/`change_percent` ככל ש-N גדל = **הוכחה מתגלגלת שהלמידה מכלילה** (INV-LRN4 — זהו משטח-המגמה של "ניגוד-האמת"). **אזהרת-פרשנות:** `change_percent` מערבב סגנון עם שלמות-תוכן (לפעמים היו"ר מכפילה אורך כי חסרה מהות, לפעמים חותכת) → `anti_pattern_total` הוא הסיגנל הנקי-יותר לסגנון. שימוש-חוזר בשירות `style_distance` ובבריכת `appeal_type_rules` — אין מסלול-מדד מקביל (G2).
> **המלצת-הכיול style-ranked (#213):** `recommend_effort` ב-`scripts/calibrate_effort.py` מדרג efforts לפי `anti_pattern_total` **ראשי** (סיגנל-סגנון נקי) → סטיית-יחסי-זהב → `distance` (tiebreak קלוש). `change_percent` **מדווח-לא-מדורג** מאותה אזהרת-פרשנות לעיל (מערבב סגנון עם שלמות-תוכן). ההמלצה נושאת דגל-`confidence` ("clear"/"weak") כך שבחירה שההובלה-ב-anti שלה בתוך-הרעש (epsilon=max(0.5, 0.20·spread)) לא מוצגת כוודאית. אין מסלול-מדד מקביל — שימוש-חוזר ב-`style_distance` (G2).
> **חלון נקי חד-פעמי:** תיק שכבר `lessons_folded` פספס את חלון ה-held-out שלו — אין backfill. הטבלה מתמלאת קדימה מהסופי הבא.
---
@@ -87,10 +98,24 @@
(`hermes-curator.md:60-70`).
- מזהה **35 דפוסים/פערים** חדשים, כל ממצא מתויג `[סגנון]` / `[מבנה]` /
`[לקסיקון משפטי]` / `[טבלאי]` (`hermes-curator.md:99-108`).
- **מציע** — comment ב-Paperclip + רישום כל ממצא כ-`decision_lesson` דרך
`POST /api/training/corpus/{corpus_id}/lessons` (`source:"curator"`) שמופיע ב-UI
תחת הטאב "מה למדנו" (`hermes-curator.md:73-96`).
- **אינו מעדכן** קבצים בעצמו (skills/, lessons.py, DB) — רק מציע (`hermes-curator.md:125-130`).
- **מציע** — comment + interaction בערוץ-הפלטפורמה, וגם **רושם כל ממצא מבנית** כ-`decision_lesson`
(`source='curator'`, `review_status='proposed'`) דרך כלי-ה-MCP `record_curator_findings`
(`hermes-curator.md` §A.5b → `tools/workflow.py::record_curator_findings``db.add_decision_lesson`).
הממצאים מופיעים ב-`/training` טאב "אוצֵר" (ערוץ ג׳) ועוברים את שער-היו"ר (INV-LRN1).
> **דריפט-מימוש שנסגר (2026-06-28):** עד גל זה הסוכן כתב comment בלבד והספ תיאר endpoint שלא נקרא —
> הממצאים האיכותיים אבדו, וכרטיס-האוצֵר (`get_curator_stats`) ספר `source='curator'` שאיש לא כתב → 0.
> `record_curator_findings` מממש את INV-LRN3 בפועל.
- **אינו מאשר ואינו מטמיע** — read-only על התוכן; רישום-הממצא הוא *הצעה* מגודרת-שער, לא שינוי-קול.
אינו עורך `skills/` / `lessons.py` / שכבת-הקול בעצמו (`hermes-curator.md` §"כללים כלליים").
- **שני ערוצי-למידה נפרדים על אותו סופי:** ערוץ-האוצֵר (כאן, qualitative, `source='curator'`) ו**פאנל-הסגנון**
הדו-סוכני שהצינור מפעיל (§0.6 — DeepSeek+Gemini, `source='panel:deepseek+gemini'`). שניהם
`decision_lessons` ממתיני-שער; ערוץ הדיסטילציה (`appeal_type_rules`, §0.4) הוא השלישי.
> **נסגר (TaskMaster #159, 2026-06-28):** ה-`PIPELINE-WAKE BRANCH` (`hermes-curator.md`) מבדיל כעת
> `final_learning_*` מ-`final_halacha_*`: על **learning** הסוכן מפעיל את הצינור ברקע ו**ממשיך ל-§A**
> (מצב AUTO — רושם ממצאי-`curator`, מדלג על interaction), על **halacha** יוצא מיד כקודם. המירוץ מול
> `enroll_style_corpus` נסגר בכך ש-enroll רץ **ראשון** בצינור (§0.6) — הקורפוס קיים תוך שניות, הרבה לפני
> שהסוכן/ה-§A מסיים את ניתוח-ה-LLM; `record_curator_findings` כולל גם retry כרשת-ביטחון.
### 1.2 לולאת-פידבק-היו"ר (capture → ניתוח שבועי → לקחים)
@@ -162,13 +187,12 @@
## 3. Invariants של התחום
### INV-LRN1: עדכון-ידע דורש אישור-יוידני — אין auto-commit (governance →G10)
**כלל:** מנגנוני-הלמידה (Hermes, ניתוח-פידבק שבועי) **מציעים בלבד**. כל שינוי ב-
[SKILL.md](../../skills/decision/SKILL.md) או ב-[legal-decision-lessons.md](../legal-decision-lessons.md)
מחייב **בחינה ואישור ידניים של היו"ר/חיים** ואז commit ידני — **לעולם לא auto-committed**.
Hermes כותב comment + `decision_lesson`, לא קבצים; ה-CEO השבועי כותב לקובץ אך הצעותיו
מאומתות ידנית לפני קיבוע. זהו פֶּאֶט של [INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
על שכבת-הידע: גם הלמידה כפופה לשיקול-הדעת האנושי.
### INV-LRN1: עדכון-ידע דורש שער-יו— **שער מדורג** לפי סיכון (governance →G10)
**כלל (מעודכן 2026-06-28, הכרעת-יו"ר):** השער **מדורג לפי סיכון-התוכן**, לא אחיד:
- **מהות** (הלכה / תקדים / עובדה / כל שינוי ב-[SKILL.md](../../skills/decision/SKILL.md) או ב-[legal-decision-lessons.md](../legal-decision-lessons.md)) → **שער-קשיח**: בחינה ואישור ידניים של היו"ר/חיים ואז commit ידני — **לעולם לא auto-committed**. פאנל-ההלכות התלת-סוכני מציע בלבד.
- **סגנון** (`decision_lessons` בקטגוריות style/structure/lexicon/tabular, וכן discussion_rules / transition_phrases / anti_patterns) שעבר **קונצנזוס-פאנל 2/2****זורם אוטומטית לכותב** (`review_status='approved'`), **הפיך**: היו"ר רואה ויכול **לבטל בדיעבד** ב-/training. זהו שינוי-קול נמוך-סיכון (טהור-מהות לפי INV-LRN5), בקרת-איכות מובנית (2 מודלים), והפיכוּת — ולכן עדיין "תחת בקרת-המשתמש" במובן NCSC/CEPEJ (veto, לא אישור-מראש על כל פריט).
זהו פֶּאֶט של [INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant) על שכבת-הידע: הלמידה כפופה לשיקול-הדעת האנושי — קשיח למהות, הפיך-veto לסגנון. **מימוש:** `scripts/style_lesson_panel.py:_review_status_for` קובע `approved` לקטגוריות-סגנון בקונצנזוס, `proposed` אחרת; הכותב צורך רק `approved` ([db.get_recent_decision_lessons]).
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* (human-in-the-loop;
never replace human judgment) · Council of Europe / CEPEJ (2018, under user control) ·
Federal Judicial Center — *Judicial Writing Manual* (2d ed.) | סטטוס: verified
@@ -249,6 +273,26 @@ LegalBench (gemini-2.5-flash) · Trust-or-Escalate (ICLR 2025) | סטטוס: ver
החלטת-יו"ר 2026-06-19; מקור-אמת: [`../legal-principles-redesign.md`](../legal-principles-redesign.md).
**הפרה ידועה:** — (חדש)
### INV-LRN8: סינתזת-לקחים מעוגנת + מגודרת-שער-מדורג (#158 → G2/G10/INV-AH)
**כלל:** לקחי-סגנון (`decision_lessons`) חופפים מאוחדים ל**לקח-על אחד** עשיר ומוכלל, כך
שהסט שזורם לכותב **קטֵן ומשתבח** (פותר את החיתוך-השקט limit=15, #157) — Authorial Style
Profiling (§0.1: פרופיל-מופשט מנצח ערימת-דוגמאות). המנגנון מחקה את סינתזת-הקנוני (INV-LRN6)
**על אותה טבלה** — אין מאגר-מקביל (G2): (א) אשכול greedy לפי cosine (`LESSON_SYNTH_CLUSTER_THRESHOLD`)
בתוך shard של `practice_area`+`category`; (ב) מיזוג ע"י `claude_session` **מעוגן-מקור** — נובע
מלקחי-המקור בלבד, סגנון-בלבד (לא מהות, INV-LRN5), abstain אם לא-מתמזג (INV-AH); (ג) **שער-drift**
הלקח-הממוזג מוטמע-מחדש ומושווה (cosine) ל-centroid האשכול; מתחת ל-`LESSON_SYNTH_DRIFT_FLOOR`
נדחה. הלקח-על נכתב `source='synthesis'` עם `synthesized_from`, והמקורות → `review_status='superseded'`
(provenance, לא נצרכים-כותב). **שער מדורג-הפיך (הכרעת-יו"ר 2026-06-28):** מאחר שכל המקורות `approved`,
הלקח-על זורם `approved` עם veto-יו"ר ב-/training — דחייתו משחזרת את המקורות ל-`approved`
(`db.revert_lesson_synthesis`). idempotency: lookup-cosine מול synthesis קיים לפני INSERT.
**מסלול-יחיד (G2):** הכלי `lesson_synthesize_pending` והסקריפט `backfill_lesson_synthesis.py` עוברים
שניהם דרך `services/lesson_synthesis.py`. audit CSV ב-`data/audit/lesson-synthesis-*.csv`.
**מקורות:** Authorial Style Profiling · grounding-vs-hallucination (Stanford RegLab) · CoVe (arXiv:2309.11495) | סטטוס: verified
**אכיפה:** `services/lesson_synthesis.py` (אשכול/מיזוג/drift), `db.{fetch_synthesis_candidates,
apply_lesson_synthesis,revert_lesson_synthesis,nearest_synthesis_lesson,synthesis_shards}`, SCHEMA_V46
(`embedding`+`synthesized_from`). config `LESSON_SYNTH_*`.
**הפרה ידועה:** — (חדש)
---
## 4. הג'ובים המתוזמנים (תמיכת-תשתית ללולאה)

View File

@@ -59,6 +59,16 @@ Paperclip בקונפליקט (project-specific מנצח default), אך אינו
ל-`SKILL.md`/`lessons.md` — מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant).
- **company_id פר-סוכן.** כל שורה בטבלה מיוצגת פעמיים (CMP + CMPA); ה-CEO לכל חברה שונה
([X2 §1](X2-multi-company.md)). הסוכן פועל רק בטווח-החברה שלו ([X2 §2](X2-multi-company.md)).
- **שטן-מליץ (Gemini red-team) — שער-קבע אחרי הניתוח, לא חלק מ-7-הדומייניים.** סוכן
`legal-analyst-gemini-critique` (`gemini_local`, CMP+CMPA) רץ **אחרי** שלב-הניתוח של ה-Case
Analyst (Opus) ו**לפני** הכותב, כ**שער-קבע (standing gate)** שה-CEO מפעיל **אוטומטית** — לא
on-demand. הוא read-only ומפיק `critique-gemini.md` = **מזכר-לידים לא-סמכותי**. ה-CEO **עוצר
את הזרימה לאישור-יו"ר של הלידים** (ה-issue הראשי ל-`in_review`); רק לידים שהיו"ר מאשר מומרים
ל-`chair_directions` (דרך מנגנון-ההנחיות הקיים — `record_chair_feedback``get_chair_directions`
`approve_direction`), והכותב צורך **רק** את פלט-המנתח המעוגן + ההנחיות-המאושרות — **לעולם
לא** את הלידים הגולמיים. מקיים [INV-AG4](#inv-ag4-שער-שטן-מליץ--red-team-לידים-לא-סמכותיים-תחת-אישור-יור) +
[04-analysis-writing §1.6](04-analysis-writing.md). מקור-אמת לזרימה: [legal-ceo.md](../../.claude/agents/legal-ceo.md)
"שלב A2"/"שלב A3" + [legal-analyst-gemini-critique.md](../../.claude/agents/legal-analyst-gemini-critique.md).
### 2א. מפת-הרשאות (tool grants) — frontmatter מול הוראות
@@ -140,6 +150,25 @@ another company`, [X2 §2](X2-multi-company.md)).
**אכיפה:** בדיקת-עקביות 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א).
### INV-AG4: שער שטן-מליץ — red-team לידים לא-סמכותיים תחת אישור-יו
**כלל:** אחרי שלב-הניתוח (`analysis-and-research.md` תקין) וב**לפני** הפעלת הכותב, ה-CEO מפעיל
**אוטומטית** את סוכן שטן-מליץ (Gemini red-team, read-only) כ**שער-קבע** — לא on-demand. הפלט
(`critique-gemini.md`) הוא **מזכר-לידים לא-סמכותי**; ה-CEO **עוצר את הזרימה לעצירת-אישור קשיחה
של היו"ר** (issue ראשי ל-`in_review` + מייל) ואינו מתקדם לכותב בלי הכרעת-יו"ר מפורשת. **רק לידים
שהיו"ר אישר** מומרים ל-`chair_directions` דרך מנגנון-ההנחיות הקיים (`record_chair_feedback`
`get_chair_directions``approve_direction`); לידים שנדחו נמחקים. הכותב צורך **אך-ורק** את
פלט-המנתח המעוגן + ההנחיות-המאושרות — **לעולם לא** את הלידים הגולמיים. מופע של
[G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant) (שער אנושי
לא-עקיף) ושל [INV-AH](../anti-hallucination-gate.md) / [INV-LRN5](07-learning.md) (לידים
לא-סמכותיים, אינם מוזנים אוטומטית לקול/למהות).
**מקור-סמכות:** [legal-ceo.md](../../.claude/agents/legal-ceo.md) ("שלב A2"/"שלב A3" + "מפת
סטטוסים") + [legal-analyst-gemini-critique.md](../../.claude/agents/legal-analyst-gemini-critique.md)
+ [HEARTBEAT.md §7](../../.claude/agents/HEARTBEAT.md). (invariant פרויקטלי-תפעולי — ללא
פרוטוקול ≥3-המקורות; משרת את G10 + INV-AH/INV-LRN5.)
**אכיפה:** פרוצדורלית (נוהל ה-CEO — "אל תמשיך לכותב בלי `critique-gemini.md` + אישור-יו"ר ללידים")
+ עצירת-`in_review` של ה-issue הראשי; אין שער-קוד אוטומטי (כמו יתר ה-INV-AG*).
**הפרה ידועה:** — (שינוי-מדיניות יו"ר 2026-06-30: שטן-מליץ עבר מ-on-demand ל-שער-קבע; TaskMaster `legal-ai` #211).
---
## 5. חיווט הספ לסוכנים — בוצע (FU-8b)

View File

@@ -105,13 +105,36 @@ TanStack Query — *Important Defaults* (staleTime/refetch) (https://tanstack.co
**אכיפה:** `halacha_backlog` מרונדר ב-/operations (לא נזרק); `findings_approved` (review_status, צרכן אמיתי)
החליף את `findings_applied` (דגל מת); מוני-סוכנים מסמנים "חלקי" כשחברה לא-נטענה (גל-1 #130).
### INV-UI9: זמן-מאוחסן ב-UTC · תצוגה אנושית דטרמיניסטית ב-Asia/Jerusalem
**כלל:** **האחסון נשאר UTC** מקצה-לקצה — Postgres `TIMESTAMPTZ`, וה-API מחזיר ISO-UTC; אין נרמול-tz
בשכבת-האחסון/ה-API. **שכבת-התצוגה בלבד** ממירה, ובאופן **דטרמיניסטי**: כל חותמת-זמן אנושית מרונדרת
ב-`timeZone: "Asia/Jerusalem"` (locale `he-IL`), בלי תלות ב-tz של ה-host/קונטיינר/דפדפן. כיוון
שהקונטיינר רץ ב-UTC, פירמוט ad-hoc (`new Date(iso).toLocaleDateString("he-IL")`, `.getHours()`,
`toISOString().slice(0,10)`) מציג UTC תחת SSR (ושוב UTC בכל runtime לא-ישראלי) → drift. נעיצת
`Asia/Jerusalem` מבטיחה ש-SSR והדפדפן מסכימים. **פורמט-התצוגה (צורת התאריך/שעה) נשמר זהה — רק ה-tz
מנורמל.** מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
(מפרמט-תאריך **יחיד**, אין פירמוט-זמן מקביל-מפוזר) בשכבת-התצוגה.
**מקורות:** ECMAScript Internationalization API — `Intl.DateTimeFormat` `timeZone` option
(https://tc39.es/ecma402/#datetimeformat-objects) · MDN — *Intl.DateTimeFormat* (IANA time zones)
(https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) ·
Next.js — *Rendering* (SSR משתמש בשעון-השרת אם לא ננעץ)
(https://nextjs.org/docs/app/building-your-application/rendering) | סטטוס: verified
**אכיפה:** מפרמט-תאריך יחיד ב-[web-ui/src/lib/format-date.ts](../../web-ui/src/lib/format-date.ts)
(`formatDate`/`formatDateShort`/`formatDateLong`/`formatDateTime`/`formatDateTimeFull`/`formatTime`/
`formatIsoDate`/`formatRelative` + עוזרי-Israel `getIsraelYear`/`israelDayKey`/`israelMidnightMs`/
`israelParts`), כולם נעוצים ל-`Asia/Jerusalem`; כל משטחי-ה-UI מייבאים ממנו ולא מפרמטים tz ad-hoc.
פירמוט-**מספרים** (`n.toLocaleString`) מחוץ-לתחום (אינו חותמת-זמן). **חריג מוצהר:** קלט
`<input type="datetime-local">` (בקרת-burst ב-`/operations`) הוא ערך הניתן-לכתיבה ע"י המפעיל, לא תצוגת-
מאוחסן — נשאר בנייה מקומית. **אודיט-נלווה (לא מיושם):** הגדרת `TZ=Asia/Jerusalem` בקונטיינר בטוחה רק
אם אף לוגיקה עסקית אינה תלויה בשעון-ה-process כ-UTC.
---
## 3. כללי-עיצוב (Design Rules) — נגזרים מה-invariants
- **SSoT ל-enums/תוויות/tones:** כל enum (CaseStatus, PracticeArea, AppealSubtype, DocType, outcome) +
תוויותיו + צבעיו מוגדרים **פעם אחת** ונצרכים מיבוא — לא משוכפלים בין דפים/רכיבים (מופע UI1/G2).
- **helpers משותפים:** פירמוט-תאריך, builder ל-FormData (העלאות), רכיב-שגיאה, query-config (intervals) —
משותפים, לא מועתקים.
- **helpers משותפים:** פירמוט-תאריך (מפרמט-יחיד נעוץ-Asia/Jerusalem ב-[format-date.ts](../../web-ui/src/lib/format-date.ts),
INV-UI9), builder ל-FormData (העלאות), רכיב-שגיאה, query-config (intervals) — משותפים, לא מועתקים.
- **חוזי-טופס:** ראה INV-UI6 ([X8](X8-field-provenance.md)).
הממצאים הקונקרטיים (כפילויות, הגדרות-שגויות, redundancy) ב-[ui-audit.md](ui-audit.md); התיקון — **FU-10**.

View File

@@ -35,14 +35,14 @@
| `case_number` (citation) | CHAIR (חובה) | מפתח idempotency |
| `full_text`, `extraction_status`, `source_kind` | DETERMINISTIC | — |
| `case_name`, `court`, `date`, `headnote`, `summary`, `key_quote`, `subject_tags`, `appeal_subtype`, `precedent_level`, `source_type`, `citation_formatted` | CHAIR או OPUS | Opus ממלא רק אם ריק |
| `is_binding` | CHAIR (default true) | קובע prompt-הלכה |
| `is_binding` | CHAIR (default true) **אך** DETERMINISTIC-false ל-`source_type=appeals_committee` | קובע prompt-הלכה. סמכות מבנית ([INV-DM7](02-data-model.md#inv-dm7-סיווג-הלכה--סמכות-נגזרת--תפקיד-כלל-מסווג-שני-צירים-לא-enum-אחד)): מקור-ועדה הוא תמיד persuasive — נכפה ל-false ב-`create_external_case_law` ונאכף ע"י `case_law_committee_not_binding_check` |
| chunks (`content`/`section_type`/`page_number`) | DETERMINISTIC | — |
| `embedding` (chunks) | Voyage (לא-LLM-reasoning) | ⚠ לא-GENERATED ([gap-audit GAP-09](gap-audit.md)) |
| כל `halachot` | OPUS | נכנס pending_review |
### 2ב. החלטה פנימית (`case_law`, source_kind=`internal_committee`)
כמו 2א, ובנוסף: `case_number` **חובה**; `chair_name`/`district`/`proceeding_type` — CHAIR או OPUS או DERIVED;
`source_type` = `appeals_committee` (DETERMINISTIC קבוע). placeholder `"(טרם חולץ)"` מסומן ל-chair_name/district
`source_type` = `appeals_committee` (DETERMINISTIC קבוע); `is_binding` = `false` (DETERMINISTIC קבוע — נכפה ב-`create_internal_committee_decision`, [INV-DM7](02-data-model.md#inv-dm7-סיווג-הלכה--סמכות-נגזרת--תפקיד-כלל-מסווג-שני-צירים-לא-enum-אחד): החלטת-ועדה אינה מחייבת ועדה אחרת ולא את עצמה — תמיד persuasive). placeholder `"(טרם חולץ)"` מסומן ל-chair_name/district
ריקים ומטופל כריק ע"י ה-extractor.
### 2ג. מסמך-תיק (`documents`)

View File

@@ -232,3 +232,25 @@ FU-7 אחרי FU-1; FU-5 אחרי FU-2; FU-8 אחרי ייצוב-הספ. **(מח
אחרי 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 בפועל — של היו"ר.
---
## ✅ ווידוא-סגירה — 7 דליפות עיצוב-מחדש זרימת-העבודה (WS7, #207, 2026-06-30)
תת-פרויקט עיצוב-מחדש זרימת-העבודה (תוכנית `groovy-doodling-token`, WS7) דרש סגירת **7 דליפות
ידועות** תוך-כדי. ביקורת חוצת-קוד (#207) אימתה שכל ה-7 כבר **נסגרו במחזורים קודמים** (בעיקר
FU-1/4/5/6/7 + גלי-ה-IA), ושהספ נשא תיאורי-הפרה מיושנים. ה-PR מיישר את הספ למצב-הקוד בפועל
ומאמת כל סגירה בבדיקה. **אין כאן GAP/FU חדש — זו ביקורת-יישור, לא פתרון-מחדש (G2).**
| # | דליפה | invariant | סטטוס | FU | סגירה (קוד) | בדיקה |
|---|-------|-----------|-------|-----|-------------|-------|
| 1 | הלכות חוצות-קורפוס (#56) | INV-RET1/G5 (GAP-10) | ✅ סגור | FU-4 | `cl.source_kind` ב-`halacha_filters` בשתי הפונקציות (`db.py:7516,7519`) | `test_precedent_corpus_isolation.py` |
| 2 | backlog-הלכות בלתי-נראה | INV-QA1/G10 (GAP-14) | ✅ סגור | FU-5 | health `halacha_backlog` (`app.py:2352-2364`) + כלי `extraction_status`/`halachot_pending` | — (health-endpoint) |
| 3 | אין eval-harness | INV-RET4/G8 (GAP-11) | ✅ סגור | FU-5 | `scripts/eval_retrieval.py` (P/R/MRR/nDCG מול gold-set+baseline) | `eval_retrieval.py --self-test` ("ALL PASS") |
| 4 | שער-ייצוא לא hard-block | INV-EX3/QA3/G10 (GAP-15) | ✅ סגור | FU-6 | `export_docx` בודק `qa_run_exists`+`get_critical_qa_failures` לפני exporter (`drafting.py:462-494`) | `test_export_qa_gate.py` |
| 5 | מטא במסלול-פנימי | INV-ING3/G2 (GAP-02) | ✅ סגור | FU-1 | מסלול קנוני `ingest.ingest_document` מתזמן metadata+halacha יחד (`ingest.py:233-234`) | `test_unified_ingest.py` |
| 6 | DOCX זוחל למקור-אמת | INV-EX1/G2 (GAP-17) | ✅ מגודר | FU-7 | חוזה: `active_draft_path`=אנקור בלבד; drift בדגל `cases.blocks_stale` (V22) | (חוזה+דגל; שארית health-check low-pri) |
| 7 | cache-invalidation ב-UI | INV-UI5/IA (GAP-33) | ✅ סגור | FU-10/IA | `qc.invalidateQueries` על מוטציות (`web-ui/src/lib/api/*.ts`) + גלי-IA #130-132 | (TanStack Query) |
**שאריות (low-pri, מתועדות לא-חוסמות):** דליפה #6 — health-check שמתריע על `blocks_stale=true`
עתיק (הדגל קיים ונאכף; חסר רק התרעת-נראות). אינה חוסמת את WS7; ניתן ל-FU-7-המשך.

View File

@@ -0,0 +1,348 @@
# Hooks: Case Status Webhooks 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:** כאשר status של תיק משתנה ב-legal-ai (e.g. `qa_failed`, `exported`), ה-plugin מקבל webhook, מעדכן את ה-issue ב-Paperclip, ומעיר את ה-CEO במקרה הצורך.
**Architecture:** Legal-ai REST API קורא `pc_request("POST", "/api/plugins/marcusgroup.legal-ai/webhooks/case-status", ...)` אחרי כל שינוי status. ה-plugin מטפל ב-`onWebhook()` ומגיב: מוסיף תגובה לissue, מעיר CEO אם QA נכשל.
**Tech Stack:** TypeScript (plugin-legal-ai), Python/FastAPI (legal-ai web), `@paperclipai/plugin-sdk@2026.325.0`, `httpx` (Python), `pc_request` helper.
---
## File Map
| Action | File |
|--------|------|
| Modify | `plugin-legal-ai/src/worker.ts` — add `onWebhook()` to `definePlugin({})` |
| Modify | `plugin-legal-ai/plugin.json` — add `"webhooks.receive"` capability |
| Modify | `plugin-legal-ai/src/manifest.ts` — add webhook capability |
| Modify | `legal-ai/web/app.py` — emit webhook after `PUT /api/cases/{case_number}` |
| Modify | `legal-ai/web/paperclip_api.py` — add `emit_webhook()` helper |
---
## Task 1: Add `emit_webhook` helper ב-Python
**Files:**
- Modify: `legal-ai/web/paperclip_api.py`
- [ ] **Step 1: קרא את הקובץ הקיים**
```bash
head -90 /home/chaim/legal-ai/web/paperclip_api.py
```
- [ ] **Step 2: הוסף את ה-helper בסוף הקובץ**
פתח `/home/chaim/legal-ai/web/paperclip_api.py` והוסף אחרי הפונקציה `pc_request`:
```python
async def emit_case_status_webhook(
case_number: str,
old_status: str,
new_status: str,
company_id: str | None = None,
run_id: str | None = None,
) -> None:
"""Notify the Paperclip plugin that a case status changed.
Fire-and-forget: logs errors but never raises, so callers aren't blocked.
"""
try:
await pc_request(
"POST",
"/api/plugins/marcusgroup.legal-ai/webhooks/case-status",
json={
"caseNumber": case_number,
"oldStatus": old_status,
"newStatus": new_status,
"companyId": company_id,
"timestamp": datetime.utcnow().isoformat() + "Z",
},
run_id=run_id,
timeout=5.0,
)
except Exception as exc:
logger.warning("emit_case_status_webhook failed: %s", exc)
```
> **הערה:** `datetime` ו-`logger` כבר מיובאים ב-`app.py`. בדוק שהם מיובאים גם ב-`paperclip_api.py` — אם לא, הוסף `from datetime import datetime` ו-`import logging; logger = logging.getLogger(__name__)` בראש הקובץ.
- [ ] **Step 3: Commit**
```bash
cd /home/chaim/legal-ai
git add web/paperclip_api.py
git commit -m "feat: add emit_case_status_webhook helper"
```
---
## Task 2: צרף webhook לendpoint `PUT /api/cases/{case_number}`
**Files:**
- Modify: `legal-ai/web/app.py`
- [ ] **Step 1: מצא את ה-endpoint**
```bash
grep -n "PUT\|case_number\|update_case" /home/chaim/legal-ai/web/app.py | head -30
```
- [ ] **Step 2: קרא את הenable endpoint המלא**
זהה את הסקציה המלאה של ה-endpoint ואת הייבוא הקיים.
- [ ] **Step 3: הוסף import ל-emit_webhook**
בראש `app.py`, בסקציית ה-imports מ-`paperclip_api`:
```python
from .paperclip_api import pc_request, emit_case_status_webhook
```
- [ ] **Step 4: הוסף webhook emit בתוך הendpoint**
אחרי שהקוד מעדכן את ה-case (לפני ה-`return`), הוסף:
```python
# Notify plugin about status change (fire-and-forget)
if updates.get("status") and old_status != updates["status"]:
background_tasks.add_task(
emit_case_status_webhook,
case_number=case_number,
old_status=old_status,
new_status=updates["status"],
company_id=str(case.get("company_id")),
)
```
> אם ה-endpoint כבר מקבל `background_tasks: BackgroundTasks` — השתמש בו. אם לא, הוסף `background_tasks: BackgroundTasks` לחתימת הפונקציה. הוסף `from fastapi import BackgroundTasks` ל-imports.
- [ ] **Step 5: שמור את ה-`old_status` לפני ה-update**
בתחילת ה-endpoint handler, לפני קריאת ה-DB update:
```python
old_status = (await db.get_case(case_number) or {}).get("status", "")
```
- [ ] **Step 6: בדיקה בסיסית — שלח PUT ידני**
```bash
curl -s -X PUT https://legal-ai.nautilus.marcusgroup.org/api/cases/1130-25 \
-H "Content-Type: application/json" \
-d '{"status": "in_progress"}' | jq .status
```
בדוק ב-Paperclip logs שהwebhook נשלח (עדיין לא מטופל בצד ה-plugin):
```bash
pm2 logs paperclip --lines 20
```
- [ ] **Step 7: Commit**
```bash
cd /home/chaim/legal-ai
git add web/app.py
git commit -m "feat: emit case-status webhook on PUT /api/cases/:case"
```
---
## Task 3: הוסף `onWebhook()` ל-plugin
**Files:**
- Modify: `plugin-legal-ai/src/worker.ts`
- [ ] **Step 1: קרא את `definePlugin({})` הקיים**
```bash
grep -n "definePlugin\|onWebhook\|onHealth\|onShutdown" /home/chaim/plugin-legal-ai/src/worker.ts
```
- [ ] **Step 2: הוסף את `onWebhook` handler**
בתוך הobject שמועבר ל-`definePlugin({})`, אחרי `setup(ctx)`:
```typescript
onWebhook: async (input) => {
const { endpointKey, payload, companyId } = input as {
endpointKey: string;
payload: {
caseNumber: string;
oldStatus: string;
newStatus: string;
companyId: string;
timestamp: string;
};
companyId: string;
};
if (endpointKey !== "case-status") return;
const { caseNumber, oldStatus, newStatus } = payload;
ctx.logger.info(`Webhook: ${caseNumber} ${oldStatus}${newStatus}`);
// Find the Paperclip issue linked to this case
const stateKey = `case:${caseNumber}`;
const issueId = await ctx.state.get({ companyId }, stateKey);
if (!issueId) {
ctx.logger.warn(`No issue found for case ${caseNumber}`);
return;
}
const statusLabels: Record<string, string> = {
in_progress: "🔄 בעבודה",
drafted: "✍️ טיוטה מוכנה",
qa_failed: "❌ QA נכשל",
exported: "📄 יוצא ל-DOCX",
reviewed: "✅ נבדק",
final: "🎯 סופי",
};
const label = statusLabels[newStatus] ?? newStatus;
// Post a status comment on the issue
await ctx.issues.createComment({
issueId: issueId as string,
body: `**עדכון סטטוס:** ${label} (היה: ${oldStatus})`,
});
// Wake CEO if QA failed
if (newStatus === "qa_failed") {
const companies = await ctx.companies.list();
const company = companies.find((c) => c.id === companyId);
if (!company) return;
const CEO_IDS: Record<string, string> = {
"42a7acd0-30c5-4cbd-ac97-7424f65df294": "752cebdd-6748-4a04-aacd-c7ab0294ef33",
"8639e837-4c9d-47fa-a76b-95788d651896": "cdbfa8bc-3d61-41a4-a2e7-677ec7d34562",
};
const ceoId = CEO_IDS[companyId];
if (ceoId) {
await ctx.agents.invoke(ceoId, companyId, {
prompt: `תיק ${caseNumber} נכשל בבדיקת QA. עיין בתוצאות QA ותקן את הבעיות.`,
reason: "qa_failed webhook",
});
}
}
},
```
> **הערה:** `ctx` חייב להיות נגיש ב-`onWebhook`. אם `ctx` מוגדר בתוך `setup()` בלבד — הוצא אותו ל-closure חיצוני של ה-plugin object (ראה את הדפוס הקיים ב-`worker.ts`).
- [ ] **Step 3: בדק TypeScript**
```bash
cd /home/chaim/plugin-legal-ai && npx tsc --noEmit
```
Expected: 0 errors.
- [ ] **Step 4: Build**
```bash
cd /home/chaim/plugin-legal-ai && npm run build
```
Expected: `dist/worker.js` נוצר ללא שגיאות.
- [ ] **Step 5: Commit**
```bash
cd /home/chaim/plugin-legal-ai
git add src/worker.ts
git commit -m "feat: add onWebhook handler for case-status events"
```
---
## Task 4: הוסף capability ל-`plugin.json` ול-`manifest.ts`
**Files:**
- Modify: `plugin-legal-ai/plugin.json`
- Modify: `plugin-legal-ai/src/manifest.ts`
- [ ] **Step 1: הוסף `"webhooks.receive"` ל-capabilities**
ב-`plugin-legal-ai/plugin.json`, בarray `"capabilities"`, הוסף:
```json
"webhooks.receive"
```
- [ ] **Step 2: הוסף גם ב-`manifest.ts`**
```bash
grep -n "capabilities\|webhooks" /home/chaim/plugin-legal-ai/src/manifest.ts
```
הוסף `"webhooks.receive"` לarray שם.
- [ ] **Step 3: Re-install plugin**
```bash
cd /home/chaim/plugin-legal-ai && npm run build
npx paperclipai plugin uninstall marcusgroup.legal-ai \
--api-base http://localhost:3100 --api-key pcapi_legal_install_key_2026
npx paperclipai plugin install /home/chaim/plugin-legal-ai \
--api-base http://localhost:3100 --api-key pcapi_legal_install_key_2026
pm2 restart paperclip
```
- [ ] **Step 4: Commit**
```bash
cd /home/chaim/plugin-legal-ai
git add plugin.json src/manifest.ts
git commit -m "feat: add webhooks.receive capability to plugin manifest"
```
---
## Task 5: בדיקה end-to-end
- [ ] **Step 1: Deploy legal-ai**
```bash
cd /home/chaim/legal-ai
git push origin main
# המתן לבנייה (~2-4 דקות)
```
- [ ] **Step 2: שנה סטטוס תיק**
```bash
curl -s -X PUT https://legal-ai.nautilus.marcusgroup.org/api/cases/1130-25 \
-H "Content-Type: application/json" \
-d '{"status": "qa_failed"}' | jq .status
```
- [ ] **Step 3: בדוק שהתגובה הוספה ל-Paperclip issue**
```bash
# מצא את ה-issue הקשור לתיק 1130-25
pm2 logs paperclip --lines 30 | grep "1130-25\|webhook\|qa_failed"
```
Expected: תגובה "❌ QA נכשל" הוספה לissue. CEO הועיר.
- [ ] **Step 4: בדוק שינוי סטטוס שגרתי (לא QA)**
```bash
curl -s -X PUT https://legal-ai.nautilus.marcusgroup.org/api/cases/1130-25 \
-H "Content-Type: application/json" \
-d '{"status": "drafted"}' | jq .status
```
Expected: תגובה "✍️ טיוטה מוכנה" בissue. CEO **לא** הועיר.
---
## אימות סופי
| בדיקה | פקודה | תוצאה מצופה |
|-------|-------|-------------|
| QA נכשל → CEO מועיר | `PUT status=qa_failed` | תגובה + agent invocation |
| exported → תגובה בלבד | `PUT status=exported` | תגובה בלבד |
| שינוי ללא status | `PUT title=...` | שום webhook |
| תיק ללא issue | webhook לתיק חדש | לוג warning, ללא crash |

View File

@@ -0,0 +1,306 @@
# Per-Agent CLAUDE.md Versioning & Validation 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:** הוסף validation ו-version tracking לקבצי הוראות הסוכנים. כעת, לפני שה-sync script מחיל שינויים, הוא מאמת שכל `instructionsFilePath` קיים. בנוסף, metadata של הסוכן ב-DB יכיל `claude_md_mtime` — השינוי האחרון בקובץ — כדי לזהות drift.
**Architecture:** `sync_agents_across_companies.py` מקבל `--check-instructions` flag שסורק את כל הסוכנים ומדווח על קבצים חסרים/ישנים. ב-`--apply`, מתווספת בדיקת pre-flight שמבטלת את הsync אם קובץ חסר. `agents.metadata` מקבל `claude_md_mtime` עם ה-mtime בפועל של הקובץ.
**Tech Stack:** Python 3.10+, asyncpg, httpx, `os.path.getmtime()`, Paperclip REST API (`PATCH /api/agents/{id}`).
---
## File Map
| Action | File |
|--------|------|
| Modify | `legal-ai/scripts/sync_agents_across_companies.py``--check-instructions` flag, pre-flight, metadata update |
זה הקובץ היחיד שצריך לגעת בו. כל שאר הלוגיקה קיימת כבר.
---
## Task 1: הוסף `--check-instructions` flag
**Files:**
- Modify: `legal-ai/scripts/sync_agents_across_companies.py`
- [ ] **Step 1: קרא את החלק של `argparse` בסקריפט**
```bash
grep -n "argparse\|add_argument\|--verify\|--dry-run\|--apply" \
/home/chaim/legal-ai/scripts/sync_agents_across_companies.py | head -20
```
- [ ] **Step 2: הוסף את הargument**
מצא את הסקציה שמגדירה `args` והוסף:
```python
parser.add_argument(
"--check-instructions",
action="store_true",
help="Scan all agents' instructionsFilePath and report missing/outdated files",
)
```
- [ ] **Step 3: הוסף את הפונקציה `check_instructions()`**
הוסף לפני `async def main()`:
```python
async def check_instructions(agents: list[dict]) -> bool:
"""Print a report of all agents' instruction files. Returns True if all OK."""
all_ok = True
print(f"\n{'Agent':<30} {'File':<60} {'Status':<15} {'Size':>8} {'Modified'}")
print("-" * 120)
for agent in agents:
adapter_cfg = agent.get("adapter_config") or {}
if isinstance(adapter_cfg, str):
import json as _json
adapter_cfg = _json.loads(adapter_cfg)
file_path = adapter_cfg.get("instructionsFilePath", "")
name = agent.get("name", agent.get("id", "?"))[:29]
if not file_path:
print(f"{name:<30} {'(none)':<60} {'⚠️ NOT SET':<15}")
continue
if not os.path.exists(file_path):
print(f"{name:<30} {file_path[-59:]:<60} {'❌ MISSING':<15}")
all_ok = False
continue
stat = os.stat(file_path)
size_kb = stat.st_size // 1024
mtime = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
# Compare with DB metadata
metadata = agent.get("metadata") or {}
if isinstance(metadata, str):
import json as _json
metadata = _json.loads(metadata)
db_mtime = metadata.get("claude_md_mtime", "")
actual_mtime = str(int(stat.st_mtime))
drift = " ⚠️ DRIFT" if db_mtime and db_mtime != actual_mtime else ""
print(f"{name:<30} {file_path[-59:]:<60} {'✅ OK':<15} {size_kb:>6}KB {mtime}{drift}")
print()
return all_ok
```
> `from datetime import datetime` ו-`import os` — בדוק שמיובאים בראש הסקריפט. אם לא, הוסף.
- [ ] **Step 4: הוסף קריאה ל-`check_instructions()` ב-`main()`**
בתוך `async def main()`, אחרי שloading הagents מה-DB:
```python
if args.check_instructions:
all_ok = await check_instructions(master_agents + mirror_agents)
sys.exit(0 if all_ok else 1)
```
- [ ] **Step 5: בדיקה**
```bash
cd /home/chaim/legal-ai
python scripts/sync_agents_across_companies.py --check-instructions
```
Expected: טבלה עם כל הסוכנים, paths, סטטוס ✅/❌.
- [ ] **Step 6: Commit**
```bash
cd /home/chaim/legal-ai
git add scripts/sync_agents_across_companies.py
git commit -m "feat: add --check-instructions flag to sync script"
```
---
## Task 2: הוסף pre-flight validation לפני `--apply`
**Files:**
- Modify: `legal-ai/scripts/sync_agents_across_companies.py`
- [ ] **Step 1: מצא את נקודת הכניסה של `--apply`**
```bash
grep -n "args.apply\|if.*apply\|apply.*mode" \
/home/chaim/legal-ai/scripts/sync_agents_across_companies.py | head -10
```
- [ ] **Step 2: הוסף pre-flight לפני apply**
בתחילת בלוק `--apply`, לפני כל שינוי:
```python
if args.apply:
# Pre-flight: abort if any agent is missing its instructions file
print("🔍 Pre-flight: checking instruction files...")
all_ok = await check_instructions(master_agents + mirror_agents)
if not all_ok:
print("❌ Abort: one or more instruction files are missing. Fix paths before --apply.")
sys.exit(1)
print("✅ Pre-flight passed.\n")
# ... rest of apply logic ...
```
- [ ] **Step 3: בדיקה — הפעל עם קובץ חסר (סימולציה)**
```bash
# שנה זמנית path לקובץ שלא קיים
cd /home/chaim/legal-ai
python scripts/sync_agents_across_companies.py --dry-run 2>&1 | head -5
```
Expected: dry-run עובר. אם תנסה `--apply` עם agent שhis file חסר — הsync יבוטל.
- [ ] **Step 4: Commit**
```bash
cd /home/chaim/legal-ai
git add scripts/sync_agents_across_companies.py
git commit -m "feat: add pre-flight instruction file validation before --apply"
```
---
## Task 3: עדכן `agents.metadata` עם `claude_md_mtime`
**Files:**
- Modify: `legal-ai/scripts/sync_agents_across_companies.py`
- [ ] **Step 1: מצא את `compute_diff()` או `apply_diff()`**
```bash
grep -n "def compute_diff\|def apply_diff\|def build_patch\|metadata" \
/home/chaim/legal-ai/scripts/sync_agents_across_companies.py | head -20
```
- [ ] **Step 2: הוסף פונקציה `get_claude_md_mtime()`**
```python
def get_claude_md_mtime(adapter_config: dict) -> str | None:
"""Return the Unix mtime of the agent's instructionsFilePath, or None if missing."""
path = adapter_config.get("instructionsFilePath", "")
if not path or not os.path.exists(path):
return None
return str(int(os.path.getmtime(path)))
```
- [ ] **Step 3: שלב mtime בbuild של metadata patch**
מצא את המקום שמכין את ה-`metadata` לsync. הוסף:
```python
# Build metadata patch with claude_md_mtime
current_metadata = master_agent.get("metadata") or {}
if isinstance(current_metadata, str):
import json as _json
current_metadata = _json.loads(current_metadata)
adapter_cfg = master_agent.get("adapter_config") or {}
if isinstance(adapter_cfg, str):
import json as _json
adapter_cfg = _json.loads(adapter_cfg)
mtime = get_claude_md_mtime(adapter_cfg)
if mtime:
current_metadata["claude_md_mtime"] = mtime
current_metadata["claude_md_last_synced"] = datetime.utcnow().isoformat() + "Z"
```
כלול את ה-`current_metadata` המעודכן ב-PATCH לAPI.
- [ ] **Step 4: בדוק שה-metadata מתעדכן**
הפעל `--dry-run` וחפש `claude_md_mtime` בoutput:
```bash
python scripts/sync_agents_across_companies.py --dry-run 2>&1 | grep -i "mtime\|metadata" | head -10
```
לאחר `--apply`, בדוק ב-DB:
```bash
psql -h localhost -p 54329 -U paperclip -c \
"SELECT name, metadata->>'claude_md_mtime' AS mtime FROM agents WHERE metadata->>'claude_md_mtime' IS NOT NULL LIMIT 5" \
paperclip
```
- [ ] **Step 5: Commit**
```bash
cd /home/chaim/legal-ai
git add scripts/sync_agents_across_companies.py
git commit -m "feat: track claude_md_mtime in agents.metadata during sync"
```
---
## Task 4: הוסף `make check-agents` shortcut
**Files:**
- Modify: `legal-ai/Makefile` (אם קיים) אחרת הוסף alias
- [ ] **Step 1: בדוק אם Makefile קיים**
```bash
ls /home/chaim/legal-ai/Makefile 2>/dev/null && echo "EXISTS" || echo "MISSING"
```
- [ ] **Step 2א: אם Makefile קיים — הוסף target**
```makefile
check-agents:
python scripts/sync_agents_across_companies.py --check-instructions
sync-agents-dry:
python scripts/sync_agents_across_companies.py --dry-run
sync-agents:
python scripts/sync_agents_across_companies.py --apply
```
- [ ] **Step 2ב: אם Makefile לא קיים — הוסף alias ל-`~/.bashrc`**
```bash
echo "alias check-agents='cd /home/chaim/legal-ai && python scripts/sync_agents_across_companies.py --check-instructions'" >> ~/.bashrc
source ~/.bashrc
```
- [ ] **Step 3: בדוק**
```bash
# אם Makefile:
make -C /home/chaim/legal-ai check-agents
# אם alias:
check-agents
```
Expected: טבלת סוכנים מוצגת.
- [ ] **Step 4: Commit (אם Makefile)**
```bash
cd /home/chaim/legal-ai
git add Makefile
git commit -m "feat: add check-agents and sync-agents make targets"
```
---
## אימות סופי
| בדיקה | פקודה | תוצאה מצופה |
|-------|-------|-------------|
| `--check-instructions` | `python sync_agents... --check-instructions` | טבלה עם ✅ לכל agent |
| Pre-flight בולם apply | מחק קובץ זמנית + `--apply` | Abort עם הודעה ברורה |
| mtime ב-DB | `SELECT metadata->>'claude_md_mtime' FROM agents` | timestamp לכל agent |
| DRIFT זוהה | שנה קובץ + `--check-instructions` | ⚠️ DRIFT מוצג |
| shortcut | `check-agents` או `make check-agents` | עובד |

View File

@@ -0,0 +1,412 @@
# Scheduled Background Agents 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:** הוסף 2 cron jobs לplugin: (1) תזכורת על תיקים תקועים 3+ ימים, (2) ניתוח פידבק יו"ר שבועי עם עדכון `decision-lessons.md` אוטומטי.
**Architecture:** שני jobs חדשים נרשמים ב-`ctx.jobs.register()`. Job 1 קורא `/api/cases/stale?days=3` (endpoint חדש ב-legal-ai) ומוסיף תגובה לissues תקועים. Job 2 קורא `/api/chair-feedback/weekly-summary` (endpoint חדש), שולח לCEO agent שמעדכן את `decision-lessons.md`.
**Tech Stack:** TypeScript (plugin-legal-ai jobs), Python/FastAPI (legal-ai web), asyncpg, Paperclip SDK `ctx.jobs.register()`, `ctx.agents.invoke()`.
---
## File Map
| Action | File |
|--------|------|
| Modify | `plugin-legal-ai/src/worker.ts` — register 2 new jobs |
| Modify | `plugin-legal-ai/plugin.json` — declare 2 new job entries |
| Modify | `plugin-legal-ai/src/manifest.ts` — add to jobs array |
| Modify | `legal-ai/web/app.py` — add `GET /api/cases/stale` + `GET /api/chair-feedback/weekly-summary` |
| Modify | `legal-ai/web/database.py` (or equivalent DB module) — add `get_stale_cases()` + `get_weekly_chair_feedback()` |
---
## Task 1: הוסף endpoint `GET /api/cases/stale`
**Files:**
- Modify: `legal-ai/web/app.py`
- Modify: `legal-ai/web/database.py` (שם DB queries מנוהלים — בדוק עם `grep -n "async def get_cases\|from.*database\|import.*db" /home/chaim/legal-ai/web/app.py | head -10`)
- [ ] **Step 1: מצא את module ה-DB**
```bash
grep -n "^from\|^import\|db\." /home/chaim/legal-ai/web/app.py | head -20
```
זהה את שם הmodule שמכיל את DB queries (בד"כ `database.py` או `db.py`).
- [ ] **Step 2: הוסף `get_stale_cases()` לmodule ה-DB**
```python
async def get_stale_cases(days: int = 3) -> list[dict]:
"""Return cases whose status is not 'final' and haven't been updated in `days` days."""
async with get_db_connection() as conn:
rows = await conn.fetch(
"""
SELECT case_number, title, status, company_id,
updated_at,
now() - updated_at AS age
FROM cases
WHERE status NOT IN ('final', 'new')
AND updated_at < now() - ($1 || ' days')::interval
ORDER BY updated_at ASC
""",
str(days),
)
return [dict(r) for r in rows]
```
> `get_db_connection()` — השתמש בדפוס הקיים בקובץ. אם זה `asyncpg.connect()` ישיר, `asyncpg.create_pool()`, או context manager — העתק את הדפוס.
- [ ] **Step 3: הוסף endpoint ב-`app.py`**
```python
@app.get("/api/cases/stale")
async def list_stale_cases(days: int = 3):
"""Cases stuck in non-final status for more than `days` days."""
cases = await db.get_stale_cases(days=days)
return {
"cases": [
{
"case_number": c["case_number"],
"title": c["title"],
"status": c["status"],
"company_id": str(c["company_id"]),
"days_stale": c["age"].days,
}
for c in cases
],
"total": len(cases),
}
```
- [ ] **Step 4: בדיקה**
```bash
curl -s "https://legal-ai.nautilus.marcusgroup.org/api/cases/stale?days=1" | jq .total
```
Expected: JSON עם רשימת תיקים.
- [ ] **Step 5: Commit**
```bash
cd /home/chaim/legal-ai
git add web/app.py web/database.py # או השם הנכון
git commit -m "feat: add GET /api/cases/stale endpoint"
```
---
## Task 2: הוסף endpoint `GET /api/chair-feedback/weekly-summary`
**Files:**
- Modify: `legal-ai/web/app.py`
- Modify: DB module
- [ ] **Step 1: בדוק את מבנה טבלת `chair_feedback`**
```bash
sqlite3 /home/chaim/.paperclip/instances/default/data/app.db \
".schema chair_feedback" 2>/dev/null || \
psql -h localhost -p 5433 -U legal_ai -c "\d chair_feedback" legal_ai 2>/dev/null || \
grep -rn "chair_feedback" /home/chaim/legal-ai/mcp-server/src/ | head -10
```
- [ ] **Step 2: הוסף `get_weekly_chair_feedback()` לDB module**
```python
async def get_weekly_chair_feedback(days: int = 7) -> list[dict]:
"""Return chair feedback entries from the last `days` days."""
async with get_db_connection() as conn:
rows = await conn.fetch(
"""
SELECT cf.case_number, cf.feedback_text, cf.created_at,
cf.feedback_type, c.title
FROM chair_feedback cf
JOIN cases c ON c.case_number = cf.case_number
WHERE cf.created_at > now() - ($1 || ' days')::interval
ORDER BY cf.created_at DESC
""",
str(days),
)
return [dict(r) for r in rows]
```
> אם שמות השדות שונים (בדוק ב-Step 1) — התאם.
- [ ] **Step 3: הוסף endpoint**
```python
@app.get("/api/chair-feedback/weekly-summary")
async def get_chair_feedback_weekly(days: int = 7):
"""Feedback entries from the past week, formatted for the learning agent."""
entries = await db.get_weekly_chair_feedback(days=days)
if not entries:
return {"summary": "", "entry_count": 0}
lines = [
f"- תיק {e['case_number']} ({e['title']}): {e['feedback_text']}"
for e in entries
]
summary = "\n".join(lines)
return {"summary": summary, "entry_count": len(entries), "entries": entries}
```
- [ ] **Step 4: בדיקה**
```bash
curl -s "https://legal-ai.nautilus.marcusgroup.org/api/chair-feedback/weekly-summary" | jq .entry_count
```
- [ ] **Step 5: Commit**
```bash
cd /home/chaim/legal-ai
git add web/app.py web/database.py
git commit -m "feat: add GET /api/chair-feedback/weekly-summary endpoint"
```
---
## Task 3: הוסף jobs לplugin
**Files:**
- Modify: `plugin-legal-ai/plugin.json`
- Modify: `plugin-legal-ai/src/manifest.ts`
- [ ] **Step 1: קרא את ה-`jobs` הקיים ב-`plugin.json`**
```bash
cat /home/chaim/plugin-legal-ai/plugin.json | python3 -c "import json,sys; d=json.load(sys.stdin); print(json.dumps(d['jobs'], indent=2))"
```
- [ ] **Step 2: הוסף 2 jobs חדשים לarray `"jobs"` ב-`plugin.json`**
```json
{
"jobKey": "stale-case-reminder",
"displayName": "תזכורת תיקים תקועים",
"description": "מזהה תיקים שלא עודכנו 3+ ימים ומוסיף תגובה לissue",
"schedule": "0 8 * * *"
},
{
"jobKey": "weekly-feedback-analysis",
"displayName": "ניתוח פידבק שבועי",
"description": "מסכם פידבק יו\"ר מהשבוע האחרון ומעדכן את decision-lessons.md",
"schedule": "0 19 * * 0"
}
```
> `"0 8 * * *"` = כל יום בשעה 08:00. `"0 19 * * 0"` = כל ראשון ב-19:00.
- [ ] **Step 3: עדכן `manifest.ts`**
```bash
grep -n "jobs\|jobKey\|schedule" /home/chaim/plugin-legal-ai/src/manifest.ts
```
הוסף את אותם 2 objects לarray `jobs` ב-`manifest.ts`.
- [ ] **Step 4: Commit**
```bash
cd /home/chaim/plugin-legal-ai
git add plugin.json src/manifest.ts
git commit -m "feat: declare stale-case-reminder and weekly-feedback-analysis jobs"
```
---
## Task 4: Implement job handlers ב-`worker.ts`
**Files:**
- Modify: `plugin-legal-ai/src/worker.ts`
- [ ] **Step 1: קרא את handler של `sync-case-status` הקיים**
```bash
grep -n "sync-case-status\|jobs.register\|jobKey" /home/chaim/plugin-legal-ai/src/worker.ts
```
העתק את הדפוס.
- [ ] **Step 2: הוסף את `stale-case-reminder` handler**
בתוך `setup(ctx)`, אחרי רישום ה-job הקיים:
```typescript
ctx.jobs.register("stale-case-reminder", async (job) => {
ctx.logger.info("stale-case-reminder: starting");
const config = await ctx.config.get();
const apiBase = (config.legalApiBaseUrl as string) ?? "http://localhost:8085";
const resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=3`);
if (!resp.ok) {
ctx.logger.error(`stale-case-reminder: API error ${resp.status}`);
return;
}
const data = (await resp.json()) as {
cases: Array<{
case_number: string;
title: string;
status: string;
company_id: string;
days_stale: number;
}>;
};
for (const staleCase of data.cases) {
const issueId = await ctx.state.get(
{ companyId: staleCase.company_id },
`case:${staleCase.case_number}`
);
if (!issueId) continue;
await ctx.issues.createComment({
issueId: issueId as string,
body: `⚠️ **תיק תקוע** — ${staleCase.days_stale} ימים ללא עדכון (סטטוס: ${staleCase.status}). האם יש צורך בפעולה?`,
});
ctx.logger.info(
`stale-case-reminder: posted reminder for ${staleCase.case_number} (${staleCase.days_stale}d stale)`
);
}
ctx.logger.info(`stale-case-reminder: done. ${data.cases.length} cases reminded`);
});
```
- [ ] **Step 3: הוסף את `weekly-feedback-analysis` handler**
```typescript
ctx.jobs.register("weekly-feedback-analysis", async (job) => {
ctx.logger.info("weekly-feedback-analysis: starting");
const config = await ctx.config.get();
const apiBase = (config.legalApiBaseUrl as string) ?? "http://localhost:8085";
const resp = await ctx.http.fetch(`${apiBase}/api/chair-feedback/weekly-summary`);
if (!resp.ok) {
ctx.logger.error(`weekly-feedback-analysis: API error ${resp.status}`);
return;
}
const data = (await resp.json()) as {
summary: string;
entry_count: number;
};
if (data.entry_count === 0) {
ctx.logger.info("weekly-feedback-analysis: no feedback this week, skipping");
return;
}
// Invoke the CEO agent to process feedback and update decision-lessons.md
const companies = await ctx.companies.list();
for (const company of companies) {
// CEO IDs per company
const CEO_IDS: Record<string, string> = {
"42a7acd0-30c5-4cbd-ac97-7424f65df294": "752cebdd-6748-4a04-aacd-c7ab0294ef33",
"8639e837-4c9d-47fa-a76b-95788d651896": "cdbfa8bc-3d61-41a4-a2e7-677ec7d34562",
};
const ceoId = CEO_IDS[company.id];
if (!ceoId) continue;
await ctx.agents.invoke(ceoId, company.id, {
prompt: `ניתוח פידבק שבועי יו"ר (${data.entry_count} פריטים):
${data.summary}
המשימה: עדכן את /home/chaim/legal-ai/docs/legal-decision-lessons.md עם הלקחים החדשים שעולים מהפידבק. הוסף רק לקחים חדשים שלא קיימים כבר. קבץ לפי נושא.`,
reason: "weekly-feedback-analysis scheduled job",
});
ctx.logger.info(
`weekly-feedback-analysis: invoked CEO for company ${company.id} (${data.entry_count} feedback entries)`
);
break; // One CEO is enough — lessons file is shared
}
});
```
- [ ] **Step 4: TypeScript check**
```bash
cd /home/chaim/plugin-legal-ai && npx tsc --noEmit
```
Expected: 0 errors.
- [ ] **Step 5: Build**
```bash
cd /home/chaim/plugin-legal-ai && npm run build
```
- [ ] **Step 6: Commit**
```bash
cd /home/chaim/plugin-legal-ai
git add src/worker.ts
git commit -m "feat: implement stale-case-reminder and weekly-feedback-analysis jobs"
```
---
## Task 5: Re-install plugin + בדיקה
- [ ] **Step 1: Deploy legal-ai**
```bash
cd /home/chaim/legal-ai && git push origin main
# המתן ~3 דקות
curl -s https://legal-ai.nautilus.marcusgroup.org/api/health | jq .status
```
- [ ] **Step 2: Re-install plugin**
```bash
cd /home/chaim/plugin-legal-ai && npm run build
npx paperclipai plugin uninstall marcusgroup.legal-ai \
--api-base http://localhost:3100 --api-key pcapi_legal_install_key_2026
npx paperclipai plugin install /home/chaim/plugin-legal-ai \
--api-base http://localhost:3100 --api-key pcapi_legal_install_key_2026
pm2 restart paperclip
```
- [ ] **Step 3: בדוק שה-jobs רשומים**
```bash
curl -s -H "Authorization: Bearer pcapi_legal_install_key_2026" \
http://localhost:3100/api/plugins/marcusgroup.legal-ai/jobs | jq .[].jobKey
```
Expected: `"sync-case-status"`, `"stale-case-reminder"`, `"weekly-feedback-analysis"`.
- [ ] **Step 4: הפעל job ידנית לבדיקה**
```bash
curl -s -X POST -H "Authorization: Bearer pcapi_legal_install_key_2026" \
http://localhost:3100/api/plugins/marcusgroup.legal-ai/jobs/stale-case-reminder/run | jq .
```
Expected: Job הופעל. בדוק logs:
```bash
pm2 logs paperclip --lines 30 | grep "stale-case-reminder"
```
---
## אימות סופי
| בדיקה | פקודה | תוצאה מצופה |
|-------|-------|-------------|
| API stale endpoint | `curl .../api/cases/stale?days=1` | JSON עם cases |
| API feedback endpoint | `curl .../api/chair-feedback/weekly-summary` | JSON עם summary |
| Jobs רשומים | `GET .../api/plugins/.../jobs` | 3 jobs רשומים |
| Stale reminder ידני | `POST .../jobs/stale-case-reminder/run` | תגובות בissues |
| Feedback analysis ידני | `POST .../jobs/weekly-feedback-analysis/run` | CEO מועיר |

View File

@@ -0,0 +1,118 @@
"""Single source of truth for the case-status lifecycle (the *status model*).
Every consumer derives its order / labels / phase from this one registry:
• ``CaseStatus`` enum (models.py) — the type-level key set (kept consistent
by the assertion at the bottom of this module);
• the forward-only ``STATUS_ORDER`` guard in ``tools/cases.py``;
• the ``GET /api/status-model`` endpoint (web/app.py) that the frontend
mirror (``web-ui/src/lib/api/case-status.ts``) is generated against.
To make a status *do something* on entry (notify, kick a job, …), set its
``on_enter`` to an action key — the single place where a status's behaviour is
declared. ``tools/cases.py`` dispatches it when a case transitions into that
status (forward-only), so the behaviour lives next to the definition.
The five **phases** are the coarse pipeline the 12 statuses collapse into for the
header stepper. Intermediate analyst/research states (``analyst_verified``,
``research_complete``) are first-class canonical statuses here — the agents set
them, so they must be in the model rather than fall between the canonical set
and the legacy bucket (the bug that made the chip, stepper and manual-changer
disagree).
"""
from __future__ import annotations
from dataclasses import dataclass
# Ordered 5-phase pipeline (key → Hebrew label) — coarse view of the lifecycle.
PHASES: list[tuple[str, str]] = [
("intake", "קליטה ועיבוד"),
("prep", "הכנת תיק"),
("thinking", "ניתוח וכיוון"),
("writing", "כתיבת טיוטה"),
("done", "סגירה"),
]
PHASE_LABELS: dict[str, str] = dict(PHASES)
@dataclass(frozen=True)
class StatusDef:
key: str
label: str # Hebrew label (chip / dropdown)
description: str # Hebrew one-liner (status guide)
phase: str # one of PHASES keys
selectable: bool = True # offered in the manual status-changer dropdown
terminal: bool = False
on_enter: str | None = None # future: action key dispatched on entry
# THE lifecycle — ordered. STATUS_ORDER, the enum and the frontend all derive
# from this list. Insert intermediates in workflow order.
STATUS_DEFS: list[StatusDef] = [
StatusDef("new", "חדש", "התיק נוצר וממתין להעלאת מסמכים", "intake"),
StatusDef("processing", "בעיבוד", "המערכת מעבדת ומנתחת את המסמכים", "intake"),
StatusDef("documents_ready", "מסמכים מוכנים", "כל המסמכים עובדו ומוכנים לעבודה", "prep"),
StatusDef("analyst_verified", "ניתוח אומת", "המנתח סיים ואימת את הניתוח — ממתין להכרעת תוצאה של היו״ר", "thinking"),
StatusDef("research_complete", "מחקר הושלם", "חקר התקדימים הושלם (מסלול נפרד מהמנתח)", "thinking"),
StatusDef("outcome_set", "תוצאה נקבעה", "נקבעה תוצאה צפויה לערר", "thinking"),
StatusDef("direction_approved", "כיוון אושר", "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה", "thinking"),
StatusDef("qa_review", "בדיקת איכות", "הטיוטה בבדיקת איכות אוטומטית", "writing"),
StatusDef("drafted", "טיוטה", "טיוטה מוכנה לעיון", "writing"),
StatusDef("exported", "יוצא", "ההחלטה יוצאה לקובץ DOCX", "done"),
StatusDef("reviewed", "נבדק", 'ההחלטה נבדקה ע"י היו"ר', "done"),
StatusDef("final", "סופי", "החלטה סופית — מוכנה להגשה", "done", terminal=True),
]
BY_KEY: dict[str, StatusDef] = {d.key: d for d in STATUS_DEFS}
STATUS_ORDER: list[str] = [d.key for d in STATUS_DEFS]
def phase_of(status: str | None) -> str | None:
"""The pipeline phase a status belongs to (None for unknown values)."""
d = BY_KEY.get(status or "")
return d.phase if d else None
def label_of(status: str | None) -> str:
d = BY_KEY.get(status or "")
return d.label if d else (status or "")
def to_dict() -> dict:
"""Serialisable status model for ``GET /api/status-model`` (frontend SSoT)."""
return {
"statuses": [
{
"key": d.key,
"label": d.label,
"description": d.description,
"phase": d.phase,
"selectable": d.selectable,
"terminal": d.terminal,
"on_enter": d.on_enter,
}
for d in STATUS_DEFS
],
"phases": [{"key": k, "label": v} for k, v in PHASES],
}
# Drift guard — the registry IS the canonical key set; the CaseStatus enum must
# match it exactly. Importing here (models.py never imports this module) is safe.
def _assert_consistent() -> None:
from legal_mcp.models import CaseStatus
registry = {d.key for d in STATUS_DEFS}
enum_keys = {s.value for s in CaseStatus}
if registry != enum_keys:
raise RuntimeError(
"case_status_model drift: registry vs CaseStatus enum differ — "
f"only-in-registry={registry - enum_keys}, only-in-enum={enum_keys - registry}"
)
phase_keys = {k for k, _ in PHASES}
bad = {d.key: d.phase for d in STATUS_DEFS if d.phase not in phase_keys}
if bad:
raise RuntimeError(f"case_status_model: statuses with unknown phase: {bad}")
_assert_consistent()

View File

@@ -275,8 +275,33 @@ HALACHA_CANONICAL_SYNTH_MODEL = os.environ.get("HALACHA_CANONICAL_SYNTH_MODEL",
HALACHA_CANONICAL_SYNTH_EFFORT = os.environ.get("HALACHA_CANONICAL_SYNTH_EFFORT", "high")
HALACHA_CANONICAL_SYNTH_DRIFT_FLOOR = float(os.environ.get("HALACHA_CANONICAL_SYNTH_DRIFT_FLOOR", "0.80"))
# Google Cloud Vision (OCR for scanned PDFs)
GOOGLE_CLOUD_VISION_API_KEY = os.environ.get("GOOGLE_CLOUD_VISION_API_KEY", "")
# Lesson synthesis (#158 / INV-LRN8) — mirrors the canonical-halacha synthesis above
# for decision_lessons: cluster overlapping style lessons (cosine ≥ CLUSTER_THRESHOLD,
# within a practice_area+category shard) and merge each cluster into one richer
# "super-lesson" via a local claude_session pass, grounded in the source lessons
# (INV-AH) with a re-embedding DRIFT_FLOOR guard. Opus by default — chair-facing
# quality. The synthesised set is smaller, so the writer's limit=15 stops truncating
# (the real fix for the silent cap, #157).
LESSON_SYNTH_MODEL = os.environ.get("LESSON_SYNTH_MODEL", HALACHA_EXTRACT_MODEL)
LESSON_SYNTH_EFFORT = os.environ.get("LESSON_SYNTH_EFFORT", "high")
LESSON_SYNTH_DRIFT_FLOOR = float(os.environ.get("LESSON_SYNTH_DRIFT_FLOOR", "0.80"))
# Cosine floor for two lessons to land in the same cluster (greedy, within shard).
# Tuned empirically (#158 dry-run, 81 lessons): the lessons are diverse (median
# pairwise cosine ~0.48, max ~0.72), so 0.82 merged nothing. 0.68 captures the real
# near-duplicates (5 faithful clusters across rishuy+betterment, all clearing the
# 0.80 drift gate) without merging weakly-related lessons; the drift gate is the
# quality backstop below this.
LESSON_SYNTH_CLUSTER_THRESHOLD = float(os.environ.get("LESSON_SYNTH_CLUSTER_THRESHOLD", "0.68"))
# How many CHAIR-APPROVED decision_lessons flow to the writer per practice_area
# (get_recent_decision_lessons). Raised 15 → 60 (#158/#157): the lessons are now both
# deduplicated (synthesis merges true near-duplicates) AND chair-approved, so the old
# cap silently truncated distinct, vetted style guidance. 60 covers every current shard
# with headroom; synthesis keeps the set non-redundant as it grows. env-tunable.
WRITER_LESSONS_LIMIT = int(os.environ.get("WRITER_LESSONS_LIMIT", "60"))
# Mistral OCR (fallback for scanned PDFs — replaces Google Cloud Vision)
MISTRAL_API_KEY = os.environ.get("MISTRAL_API_KEY", "")
# Data directory
DATA_DIR = Path(os.environ.get("DATA_DIR", str(Path.home() / "legal-ai" / "data")))
@@ -361,7 +386,7 @@ PARENT_DOC_CHILD_OVERLAP_TOKENS = int(
# External service allowlist — case materials may ONLY be sent to these domains
ALLOWED_EXTERNAL_SERVICES = {
"api.voyageai.com", # Voyage AI (embeddings)
"vision.googleapis.com", # Google Cloud Vision (OCR)
"api.mistral.ai", # Mistral OCR (scanned PDFs)
}
# Audit

View File

@@ -9,13 +9,18 @@ from uuid import UUID
from pydantic import BaseModel, Field
# Core case lifecycle — kept in sync with STATUS_ORDER in tools/cases.py and the
# frontend SSoT web-ui/src/lib/api/case-status.ts. Trimmed from 17 → 10 (the
# decorative mid-stage markers that no pipeline code ever set were removed).
# Core case lifecycle. The canonical key set, order, labels, phases and (future)
# per-status actions all live in ONE place — legal_mcp/case_status_model.py —
# which asserts this enum matches it. The frontend mirror is
# web-ui/src/lib/api/case-status.ts (generated against GET /api/status-model).
# The analyst/research intermediate states are first-class canonical statuses
# (the agents set them) — not legacy.
class CaseStatus(str, enum.Enum):
NEW = "new"
PROCESSING = "processing"
DOCUMENTS_READY = "documents_ready"
ANALYST_VERIFIED = "analyst_verified"
RESEARCH_COMPLETE = "research_complete"
OUTCOME_SET = "outcome_set"
DIRECTION_APPROVED = "direction_approved"
QA_REVIEW = "qa_review"

View File

@@ -418,6 +418,12 @@ async def digest_process_pending(limit: int = 20) -> str:
return await digest_tools.digest_process_pending(_clamp_limit(limit))
@mcp.tool()
async def digest_radar(case_number: str, limit: int = 5, min_score: float = 0.45) -> str:
"""רדאר-יומונים הקשרי-לתיק (X12) — מחזיר יומונים לא-מקושרים (פס"ד שעוד אין לנו) שהנושא שלהם קרוב סמנטית לתיק, כדי שלא ייפול פס"ד רלוונטי בזמן ההכרעה. כל ליד מצביע על מראה-המקום של הפס"ד המקורי + סטטוס-הפער + פעולה מוצעת. ⚠️ radar בלבד — היומון אינו מצוטט (INV-DIG1)."""
return await digest_tools.digest_radar(case_number, _clamp_limit(limit), min_score)
@mcp.tool()
async def halacha_review(
halacha_id: str,
@@ -566,6 +572,18 @@ async def get_legal_arguments(
return await la_tools.get_legal_arguments(case_number, party)
@mcp.tool()
async def reanalyze_claims(
case_number: str,
reanalyze_all_primary: bool = False,
) -> str:
"""ניתוח-מחדש מאחד אחרי הוספת מסמך-עיקרי: מחלץ ממסמכים-עיקריים חדשים/לא-נותחו בלבד,
מאחד עם הטענות הקיימות (לא מוחק הכול), מריץ צבירה-מחדש, ומחזיר בדיקת-השפעה
(diff טיעונים/המלצה לפני↔אחרי) ליו"ר. reanalyze_all_primary=True = כל המסמכים-העיקריים.
"""
return await la_tools.reanalyze_claims(case_number, reanalyze_all_primary)
# References
@mcp.tool()
async def extract_references(
@@ -738,6 +756,29 @@ async def get_appraiser_facts(case_number: str) -> str:
return await drafting.get_appraiser_facts(case_number)
# ── Protocol comparative analysis (WS4 / #203) — ניתוח פרוטוקול ────
@mcp.tool()
async def analyze_protocol(case_number: str, document_id: str = "") -> str:
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות: אילו טענות ירדו/חוזקו/עלו-חדשות + חידוד-שאלות + חילוץ א–ד.
בוחר אוטומטית פרוטוקול ועדת-ערר (scope שאינו 'lower'); document_id מכוון לדיון
מדויק כשיש כמה פרוטוקולים. מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול
doc_type='protocol' + טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high;
re-run מחליף (idempotent).
"""
return await drafting.analyze_protocol(case_number, document_id=document_id)
@mcp.tool()
async def get_protocol_analysis(case_number: str, change_type: str = "") -> str:
"""קריאת ניתוח-הפרוטוקול שכבר חולץ — ללא ניתוח-מחדש. ה-get המקביל ל-analyze_protocol.
change_type: dropped/strengthened/newly_raised (ריק=הכל).
"""
return await drafting.get_protocol_analysis(case_number, change_type)
# ── Planning-schemes registry (V38) — מרשם-התכניות ─────────────────
# SSOT לזהות+תוקף של תכנית, נעשה שימוש חוזר בין תיקים (G2). פלט-LLM נכנס
# pending_review וממתין לאישור-יו"ר (plan_review, G10) לפני שמשמש בבלוק ט.
@@ -808,6 +849,12 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
return await drafting.export_interim_draft(case_number, output_path)
@mcp.tool()
async def summarize_party_claims(case_number: str, instructions: str = "") -> str:
"""סיכום-מנהלים מזוקק של טענות הצדדים — מסמך פרוזה נפרד מטיוטת-ההחלטה, להכנת היו"ר לדיון. נגזר מ-legal_arguments/claims (אותו מקור), נעוץ Opus 4.8, נשמר וניתן-לייצוא."""
return await drafting.summarize_party_claims(case_number, instructions)
@mcp.tool()
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
"""רישום עריכה שהעלה המשתמש (עריכה-v*.docx) כמקור האמת החדש — מזריק bookmarks אם חסר."""
@@ -1170,6 +1217,25 @@ async def list_chair_feedback(
return await workflow.list_chair_feedback(case_number, category, unresolved_only, _clamp_limit(limit))
@mcp.tool()
async def record_curator_findings(case_number: str, findings: list[dict]) -> str:
"""לכידת ממצאי-האוצֵר על החלטה סופית כ-decision_lessons מובְנים (source='curator',
ממתינים לשער-יו"ר) — INV-LRN3. כל ממצא: {"text": "...", "category": style/structure/
lexicon/tabular} (או "tag" עברי). מחזיר כמה נרשמו וכמה כפילויות דולגו. read-only על
התוכן — הרישום הצעה הממתינה לאישור דפנה ב-/training, לא שינוי-קול ישיר (G10)."""
return await workflow.record_curator_findings(case_number, findings)
@mcp.tool()
async def lesson_synthesize_pending(
practice_area: str = "", category: str = "", apply: bool = True,
) -> str:
"""סינתזת-לקחים (#158 / INV-LRN8): ממזגת לקחי-סגנון חופפים ל"לקח-על" אחד עשיר (source='synthesis'),
המקורות→superseded; הסט שזורם לכותב קטֵן ומשתבח (limit=15 מפסיק לחתוך). מעוגן-מקור (INV-AH) +
שער-drift; שער מדורג-הפיך (G10). apply=False = dry-run. practice_area/category ריקים = כל ה-shards."""
return await workflow.lesson_synthesize_pending(practice_area, category, apply)
@mcp.tool()
async def halacha_corroboration(halacha_id: str) -> dict:
"""החזר את ה-corroboration של הלכה: הציטוטים שמתקפים אותה, הטיפול, וסיכום (X11, read-only)."""

View File

@@ -182,16 +182,21 @@ def _add_runs_with_inline_bold(paragraph: Paragraph, text: str) -> None:
def _clear_body(doc: DocumentT) -> None:
"""Remove every paragraph currently in the document body.
"""Remove ALL body content (paragraphs, tables, stray bookmarks),
keeping only sectPr.
The template ships with example paragraphs ("רקע", "דיון והכרעה"…)
that we don't want in the output. Section properties (sectPr) are
kept so page size / margins / RTL / footer remain intact.
The template ships with example paragraphs ("רקע", "דיון והכרעה"…) *and
three sample tables* (header / panel / signatures). Removing only ``w:p``
left the tables behind — including the signatures table, which then floated
to the top against the header table. Mirror the decision exporter's
``_clear_body`` (G2 symmetry): drop everything except sectPr, which carries
page setup including bidi.
"""
body = doc.element.body
for p in list(body.findall(qn("w:p"))):
body.remove(p)
# Leave sectPr alone — it carries page setup including bidi.
sectPr_tag = qn("w:sectPr")
for child in list(body):
if child.tag != sectPr_tag:
body.remove(child)
def _add_paragraph(doc: DocumentT, text: str, style: str) -> Paragraph:
@@ -386,9 +391,9 @@ def _group_precedents(
return case_level, by_section
def _next_version(export_dir: Path) -> int:
"""Return the next version number for ניתוח-משפטי-v{N}.docx."""
existing = sorted(export_dir.glob("ניתוח-משפטי-v*.docx"))
def _next_version(export_dir: Path, prefix: str = "ניתוח-משפטי") -> int:
"""Return the next version number for {prefix}-v{N}.docx in export_dir."""
existing = sorted(export_dir.glob(f"{prefix}-v*.docx"))
next_ver = 1
for p in existing:
try:
@@ -511,3 +516,77 @@ async def build_analysis_docx(case_number: str) -> Path:
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
return out_path
# ── Generic markdown → DOCX (party-claims summary, #202) ───────────
# ATX-heading markers → template heading style. The party-claims summary is a
# plain markdown document (not the structured analysis-and-research.md), so it
# is rendered line-by-line through the same template machinery.
_ATX_HEADING_RE = re.compile(r"^(#{1,4})\s+(.+)$")
_HR_RE = re.compile(r"^\s*[-*_]{3,}\s*$")
def _render_markdown_body(doc: DocumentT, markdown: str) -> None:
"""Render a markdown string into the doc using the template styles.
Handles ATX headings (# .. ####), horizontal rules (skipped), and delegates
every other line to _emit_content_line (bullets, numbered, bold-labels, plain).
"""
for raw in markdown.splitlines():
line = raw.rstrip()
if not line.strip():
continue
if _HR_RE.match(line):
continue
m = _ATX_HEADING_RE.match(line.strip())
if m:
level = len(m.group(1))
# # → Heading 1 (doc title), ## → Heading 1, ### / #### → Heading 2.
style = "Heading 1" if level <= 2 else "Heading 2"
_add_paragraph(doc, m.group(2).strip(), style)
continue
_emit_content_line(doc, line)
async def build_party_claims_summary_docx(case_number: str) -> Path:
"""Build a DOCX of the party-claims executive summary using the template
styles, saved versioned under the case's exports folder.
Reads the saved party-claims-summary.md (produced by the summarize_party_claims
MCP tool — generation is local-only). Raises FileNotFoundError if the summary
or the template is missing.
"""
from legal_mcp.services import party_claims_summary
if not TEMPLATE_PATH.exists():
raise FileNotFoundError(
f"Template not found at {TEMPLATE_PATH}. "
"Run: python scripts/convert_decision_template.py"
)
summary_path = party_claims_summary.summary_file_path(case_number)
if not summary_path.exists():
raise FileNotFoundError(
f"Party-claims summary not found for case {case_number}. "
"Run summarize_party_claims first."
)
markdown = summary_path.read_text(encoding="utf-8")
doc = Document(str(TEMPLATE_PATH))
_clear_body(doc)
_render_markdown_body(doc, markdown)
export_dir = config.find_case_dir(case_number) / "exports"
export_dir.mkdir(parents=True, exist_ok=True)
_PREFIX = "סיכום-מנהלים-טענות"
out_path = export_dir / f"{_PREFIX}-v{_next_version(export_dir, _PREFIX)}.docx"
buf = io.BytesIO()
doc.save(buf)
await storage.put_bytes(
out_path.relative_to(config.DATA_DIR).as_posix(), buf.getvalue(),
bucket=storage.Bucket.DOCUMENTS,
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
return out_path

View File

@@ -31,6 +31,10 @@ logger = logging.getLogger(__name__)
# Allowed enum values mirror the DB CHECK constraints.
ALLOWED_PARTIES = {"appellant", "respondent", "committee", "permit_applicant", "unknown"}
# Sides that may comprise multiple distinct litigants with potentially opposing
# positions — aggregated per source pleading (party_name) rather than collapsed
# into one bucket (#224). Appellant/committee speak with a single voice.
SPLIT_PARTIES = {"respondent", "permit_applicant"}
ALLOWED_PRIORITIES = {"threshold", "substantive", "procedural", "relief"}
# Hebrew labels for the prompt (Claude needs context in the same
@@ -81,7 +85,7 @@ AGGREGATE_PROMPT_TEMPLATE = """אתה מנתח כתבי טענות בתחום ת
"""
def _build_prompt(party: str, propositions: list[dict]) -> str:
def _build_prompt(party: str, propositions: list[dict], party_name: str = "") -> str:
"""Compose the per-party aggregation prompt."""
n = len(propositions)
# Conservative target: ~1 argument per 2-3 propositions, clamped 4-12.
@@ -89,6 +93,11 @@ def _build_prompt(party: str, propositions: list[dict]) -> str:
target_max = max(target_min + 1, min(12, n // 2 + 1))
party_he = PARTY_LABELS_HE.get(party, party)
# For a split side (e.g. a specific respondent brief), name the brief so
# Claude aggregates only that litigant's position and does not conflate it
# with a co-respondent's separate — possibly opposing — pleading (#224).
if party_name:
party_he = f"{party_he}{party_name}"
# Strip noise from propositions for the prompt — Claude only needs
# the id and the text to do the grouping.
compact = [
@@ -139,12 +148,17 @@ def _normalize_argument(raw: dict, fallback_topic: str = "") -> dict | None:
async def _aggregate_party(
party: str, propositions: list[dict],
party: str, propositions: list[dict], party_name: str = "",
) -> list[dict]:
"""Ask Claude to group one party's propositions; return normalized rows."""
"""Ask Claude to group one party's propositions; return normalized rows.
``party_name`` names the specific pleading when this is a split side
(respondent / permit_applicant brief), so the prompt scopes to that
litigant's position only (#224).
"""
if not propositions:
return []
prompt = _build_prompt(party, propositions)
prompt = _build_prompt(party, propositions, party_name=party_name)
try:
raw_result = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
@@ -207,7 +221,7 @@ async def aggregate_claims_to_arguments(
# Pull all claims for this case, grouped by party.
rows = await conn.fetch(
"""SELECT id, party_role, claim_text, claim_index, source_document
"""SELECT id, party_role, claim_text, claim_index, source_document, party_name
FROM claims
WHERE case_id = $1
ORDER BY party_role, claim_index""",
@@ -221,22 +235,47 @@ async def aggregate_claims_to_arguments(
"total": 0,
}
# Group propositions by party.
by_party: dict[str, list[dict]] = {}
# Group propositions by party — and, for the multi-party sides (respondent /
# permit_applicant), further by their source pleading so opposing joint
# briefs (e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6") aggregate into
# SEPARATE argument sets instead of collapsing into one "respondent" bucket
# (#224). Appellant/committee stay single-group (party_name=""). Splitting
# the large respondent set per-brief also keeps each Claude call small enough
# to succeed — a single 100+ proposition call previously returned non-JSON
# and silently dropped the whole side.
by_group: dict[tuple[str, str], list[dict]] = {}
for r in rows:
party = r["party_role"]
# Map deprecated 'appraiser' or unknown labels to 'unknown'.
if party not in ALLOWED_PARTIES:
party = "unknown"
by_party.setdefault(party, []).append(dict(r))
# Prefer the stored party_name (stamped by the extractor, #224); fall
# back to source_document for legacy claims predating the stamping.
# Single-voice sides (appellant/committee) stay ''.
if party in SPLIT_PARTIES:
party_name = (r["party_name"] or "").strip() or (r["source_document"] or "").strip()
else:
party_name = ""
by_group.setdefault((party, party_name), []).append(dict(r))
# Valid claim_ids for this case == the ids of the claims we just fetched.
# The LLM is asked to echo back supporting claim_ids, but it may hallucinate
# a syntactically-valid-but-nonexistent UUID (malformed ones are already
# dropped in ``_normalize_argument``). Validating against this known set at
# source keeps a doomed INSERT — which would poison the surrounding asyncpg
# transaction (FK violation -> "current transaction is aborted") — out of
# the transaction entirely (G1: fix at source, not symptom).
valid_claim_ids: set[UUID] = {r["id"] for r in rows}
party_counts: dict[str, int] = {}
inserted = 0
errors: list[str] = []
for party, props in by_party.items():
for (party, party_name), props in by_group.items():
# Display key for the per-side summary: keep opposing briefs distinct.
group_key = f"{party}·{party_name}" if party_name else party
try:
arguments = await _aggregate_party(party, props)
arguments = await _aggregate_party(party, props, party_name=party_name)
except RuntimeError as e:
# Most likely cause: Claude CLI not installed (running from
# the container). Don't crash — record the gap and continue.
@@ -250,11 +289,11 @@ async def aggregate_claims_to_arguments(
),
"total": 0,
}
errors.append(f"{party}: {msg}")
errors.append(f"{group_key}: {msg}")
continue
if not arguments:
party_counts[party] = 0
party_counts[group_key] = 0
continue
async with pool.acquire() as conn:
@@ -262,12 +301,13 @@ async def aggregate_claims_to_arguments(
for idx, arg in enumerate(arguments):
arg_id = await conn.fetchval(
"""INSERT INTO legal_arguments
(case_id, party, argument_index, argument_title,
argument_body, legal_topic, priority)
VALUES ($1, $2, $3, $4, $5, $6, $7)
(case_id, party, party_name, argument_index,
argument_title, argument_body, legal_topic, priority)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id""",
case_id,
party,
party_name,
idx + 1,
arg["title"],
arg["body"],
@@ -275,23 +315,36 @@ async def aggregate_claims_to_arguments(
arg["priority"],
)
for cid in arg["claim_ids"]:
try:
await conn.execute(
"""INSERT INTO legal_argument_propositions
(argument_id, claim_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING""",
arg_id, cid,
if cid not in valid_claim_ids:
# Hallucinated claim_id that doesn't belong to this
# case. Skip it rather than letting the FK violation
# abort the whole transaction.
logger.warning(
"argument_aggregator: skipped unknown claim_id %s for arg %s",
cid, arg_id,
)
continue
try:
# Per-row savepoint: even after the validation above,
# wrap the INSERT so any unexpected constraint failure
# rolls back to the savepoint instead of poisoning the
# surrounding transaction (asyncpg nests transaction()
# as SAVEPOINT when already inside one).
async with conn.transaction():
await conn.execute(
"""INSERT INTO legal_argument_propositions
(argument_id, claim_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING""",
arg_id, cid,
)
except Exception as e: # noqa: BLE001
# Likely FK violation if the LLM hallucinated
# a claim_id. Log and continue.
logger.warning(
"argument_aggregator: skipped bad claim_id %s for arg %s: %s",
cid, arg_id, e,
)
inserted += 1
party_counts[party] = len(arguments)
party_counts[group_key] = len(arguments)
result: dict = {
"status": "completed",
@@ -316,9 +369,9 @@ async def get_legal_arguments(
async with pool.acquire() as conn:
if party and party in ALLOWED_PARTIES:
rows = await conn.fetch(
"""SELECT id, case_id, party, argument_index, argument_title,
argument_body, legal_topic, priority, cited_precedents,
created_at, updated_at
"""SELECT id, case_id, party, party_name, argument_index,
argument_title, argument_body, legal_topic, priority,
cited_precedents, created_at, updated_at
FROM legal_arguments
WHERE case_id = $1 AND party = $2
ORDER BY priority, argument_index""",
@@ -326,12 +379,12 @@ async def get_legal_arguments(
)
else:
rows = await conn.fetch(
"""SELECT id, case_id, party, argument_index, argument_title,
argument_body, legal_topic, priority, cited_precedents,
created_at, updated_at
"""SELECT id, case_id, party, party_name, argument_index,
argument_title, argument_body, legal_topic, priority,
cited_precedents, created_at, updated_at
FROM legal_arguments
WHERE case_id = $1
ORDER BY party, priority, argument_index""",
ORDER BY party, party_name, priority, argument_index""",
case_id,
)

View File

@@ -7,7 +7,7 @@ from __future__ import annotations
import json
import logging
from datetime import datetime
from datetime import datetime, timezone
from uuid import UUID, uuid4
from legal_mcp.services import db
@@ -38,7 +38,7 @@ async def log_action(
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
uuid4(), action, case_id, document_id,
json.dumps(details or {}, ensure_ascii=False, default=str),
user, datetime.utcnow(),
user, datetime.now(timezone.utc),
)
logger.info("AUDIT: %s | case=%s | user=%s | %s", action, case_id, user,
json.dumps(details or {}, ensure_ascii=False)[:200])

View File

@@ -35,29 +35,65 @@ logger = logging.getLogger(__name__)
# ── Block configuration ───────────────────────────────────────────
# Output token limits per Anthropic docs:
# Opus 4.7: up to 128K output tokens (new tokenizer — ~35% more tokens)
# Sonnet 4.6: up to 64K output tokens
# Streaming required when max_tokens > 21,333
# Generation is structurally deterministic (#204 / WS5): every AI block is
# pinned to ONE model (Opus 4.8) with a per-block reasoning `effort`. The
# generation path is claude_session.query → `claude -p` (subscription, near-zero
# cost; local-only — see reference_claude_generation_path / claude_session
# docstring). Opus 4.7/4.8 REMOVED temperature/top_p/top_k (sending them → HTTP
# 400); the only knob is `effort` (low/medium/high/xhigh/max; default high).
#
# DEPRECATED FIELDS — DO NOT REINTRODUCE: earlier revisions carried per-block
# `temp` (0/0.1/0.4) and `model` ("sonnet"/"opus"/"script"). Both were DEAD
# METADATA: write_block called claude_session.query(prompt, …) WITHOUT model/
# temp/effort, so generation ran on the CLI's default model with no temperature
# knob at all (the table only stored a number in decision_blocks.temperature and
# fed MODEL_MAP→timeout). They are removed here so the table no longer misleads.
# The conceptual origin (gen_type → thinking-budget) now lives in `effort`; see
# docs/block-schema.md §3.
#
# `gen_type` is retained (documentation/UI metadata + audit `generation_type`).
# `model` below is the DISPATCH key only: "script" = template-fill (no LLM),
# "ai" = generated via the pinned Opus model. It is NOT a model alias anymore.
#
# Output token note (Anthropic): Opus 4.8 supports large outputs; streaming is
# handled by the CLI. `max_tokens` is advisory context for callers, not sent.
GENERATION_MODEL = "claude-opus-4-8" # single pinned model for every AI block (#204)
# 1M-context build of the SAME pinned model (#216). The default build runs a
# 200K-token context (~400K Hebrew chars at ~2 chars/token); large prompts —
# notably block-yod, which carries the full case as source-context — overflow it.
# Opus 4.8 offers a 1M-token window at standard pricing; `claude -p` exposes it
# via the `[1m]` model id directly (no beta header — verified CLI 2.1.196). We
# escalate to it only when the prompt is large, so small blocks stay on the
# cheaper/faster 200K build. NOT a parallel model (G2) — same model, wider window.
GENERATION_MODEL_1M = "claude-opus-4-8[1m]"
# Escalate to the 1M build above this prompt size. Set below the 200K-token wall
# (~400K chars) with headroom for output tokens (max_tokens up to 16K) + tokenizer
# variance, so we switch before the standard build can overflow.
_CTX_1M_THRESHOLD_CHARS = 350_000
BLOCK_CONFIG = {
"block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "temp": 0, "model": "script"},
"block-bet": {"index": 2, "title": "הרכב הוועדה", "gen_type": "template-fill", "temp": 0, "model": "script"},
"block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "temp": 0, "model": "script"},
"block-dalet":{"index": 4, "title": "החלטה", "gen_type": "template-fill", "temp": 0, "model": "script"},
"block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "temp": 0.2, "model": "sonnet", "max_tokens": 4096},
"block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 16384},
"block-zayin":{"index": 7, "title": "טענות הצדדים", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 16384},
"block-chet": {"index": 8, "title": "הליכים", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 8192},
"block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "temp": 0.2, "model": "opus", "max_tokens": 16384},
"block-yod": {"index": 10, "title": "דיון והכרעה", "gen_type": "rhetorical-construction", "temp": 0.4, "model": "opus", "max_tokens": 16384},
"block-yod-alef": {"index": 11, "title": "סיכום", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 8192},
"block-yod-bet": {"index": 12, "title": "חתימות", "gen_type": "template-fill", "temp": 0, "model": "script"},
"block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "model": "script"},
"block-bet": {"index": 2, "title": "הרכב הוועדה", "gen_type": "template-fill", "model": "script"},
"block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "model": "script"},
"block-dalet":{"index": 4, "title": "החלטה", "gen_type": "template-fill", "model": "script"},
"block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "model": "ai", "effort": "medium", "max_tokens": 4096},
"block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "model": "ai", "effort": "low", "max_tokens": 16384},
"block-zayin":{"index": 7, "title": "טענות הצדדים", "gen_type": "paraphrase", "model": "ai", "effort": "high", "max_tokens": 16384},
"block-chet": {"index": 8, "title": "הליכים", "gen_type": "reproduction", "model": "ai", "effort": "medium", "max_tokens": 8192},
"block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "model": "ai", "effort": "high", "max_tokens": 16384},
"block-yod": {"index": 10, "title": "דיון והכרעה", "gen_type": "rhetorical-construction", "model": "ai", "effort": "xhigh", "max_tokens": 16384},
"block-yod-alef": {"index": 11, "title": "סיכום", "gen_type": "paraphrase", "model": "ai", "effort": "high", "max_tokens": 8192},
"block-yod-bet": {"index": 12, "title": "חתימות", "gen_type": "template-fill", "model": "script"},
}
MODEL_MAP = {
"sonnet": "claude-sonnet-4-20250514",
"opus": "claude-opus-4-7",
}
# Default effort when a block lacks an explicit one (defensive; every AI block
# above sets one). High is the safe default per the generation-path reference.
DEFAULT_EFFORT = "high"
# Blocks that take longer (deep reasoning) get the LONG timeout. The interim set
# [he, vav, tet, zayin, chet] and the discussion block all run on the same pinned
# Opus model, so timeout is driven by effort, not by a model split.
_LONG_EFFORTS = frozenset({"high", "xhigh", "max"})
# ── Template blocks (א-ד, יב) ────────────────────────────────────
@@ -120,11 +156,14 @@ TEMPLATE_WRITERS = {
BLOCK_PROMPTS = {
"block-he": """כתוב את בלוק הפתיחה (בלוק ה) של החלטת ועדת ערר.
## כללים:
- פתח ב"לפנינו ערר..." או "עניינה של החלטה זו..."
- הגדר "להלן" מרכזיים: הוועדה המקומית, התכנית/הבקשה, המגרש
- 1-2 סעיפים בלבד
- אין ניתוח, אין ערכי שיפוט, אין ציטוטים מצדדים
## מבנה קבוע (חובה — אותו מבנה בכל תיק):
- **המשפט הראשון פותח תמיד במילה "לפנינו"** — נוסח קבוע: "לפנינו ערר על החלטת
[הוועדה המקומית] מיום [תאריך] בעניין [נושא הבקשה/התכנית]." (אל תשתמש ב"עניינה של
החלטה זו" או בכל פתיח חלופי — הפתיח אחיד.)
- סעיף 1: הצגת הערר במשפט הקבוע לעיל + הגדרת ה"להלן" המרכזיים בסדר קבוע:
הוועדה המקומית → התכנית/הבקשה → המגרש/המקרקעין.
- סעיף 2 (רק אם נדרש להשלמת "להלן" נוספים): הגדרות-נוספות בלבד.
- **בדיוק 1-2 סעיפים.** אין ניתוח, אין ערכי שיפוט, אין ציטוטים מצדדים.
- מספור: 1.
## פרטי התיק:
@@ -153,16 +192,16 @@ BLOCK_PROMPTS = {
## כללים קריטיים:
- **סנתז טענות דומות** — אל תרשום כל טענה בנפרד. קבץ טענות דומות לנושא אחד. למשל: כל הטענות על הודעות → סעיף אחד, כל הטענות על רכוש משותף → סעיף אחד.
- גוף שלישי: "העוררים טוענים כי...", "הוועדה המקומית ציינה כי..."
- **מבנה קבוע עם 3 חלקים:**
1. "טענות העוררים" — 8-12 סעיפים מקובצים לפי נושא
2. "עמדת הוועדה המקומית" — 5-8 סעיפים
3. "עמדת מבקשי ההיתר" (אם יש) — 5-10 סעיפים
- **מבנה: חלק (סעיף עם כותרת-משנה) לכל צד שמופיע בטענות שחולצו למטה** — לפי הכותרות שם (### ...). אל תמציא צדדים שאינם מופיעים ואל תשמיט צד שמופיע.
- "טענות העוררים" — 8-12 סעיפים מקובצים לפי נושא.
- **צד-משיב/מתנגד: כתוב חלק נפרד לכל כתב-תשובה** שמופיע (למשל "עמדת משיבות 2-3" ו-"עמדת משיבים 4-6" בנפרד) — 5-10 סעיפים לכל אחד. **אל תמזג משיבים שונים לסעיף אחד**, ואם עמדותיהם מנוגדות — שקף זאת.
- "עמדת הוועדה המקומית" / "עמדת מבקשי ההיתר" (אם מופיעים) — 5-8 סעיפים.
- כותרת: "תמצית טענות הצדדים"
- נאמנות למקור — לא להמציא טענות, אבל כן לאחד ולסכם טענות חוזרות
- אין ניתוח, אין מסקנות, אין הערכה ("טענה חלשה/חזקה")
- רק מכתבי טענות מקוריים (לא השלמות טיעון)
- מספור רציף
- **יעד אורך: 800-1500 מילים**
- **יעד אורך: 800-1500 מילים** (יותר כשיש כמה כתבי-תשובה נפרדים — כל צד מקבל את מלוא ההתייחסות)
## טענות שחולצו (קבץ טענות דומות לנושאים):
{claims_context}
@@ -245,6 +284,7 @@ BLOCK_PROMPTS = {
- **ללא כפילות** — הפנה לבלוקים קודמים: "כאמור בסעיף X לעיל"
- **מספור רציף** — המשך מספור מהבלוק הקודם
- מותרות כותרות-משנה כשיש נושאים נפרדים לחלוטין
- **צדדים מרובים** — כשיש כמה משיבים/מתנגדים או כתבי-תשובה נפרדים (ראה הכותרות שתחת "טענות" לעיל, למשל "משיבות 2-3" מול "משיבים 4-6"), התייחס לעמדת כל אחד לגופה ואל תמזג אותם ל"טענות המשיבים" גורפות. אם משיבים שונים נוקטים **עמדות מנוגדות זו לזו** — ציין זאת במפורש והכרע ביניהן בנימוק; אם עמדותיהם משלימות — ניתן לאגד תוך שמירת הייחוס.
## כיוון מאושר (חובה):
{direction_context}
@@ -297,11 +337,38 @@ STRUCTURE_GUIDANCE = {
"partial_acceptance": "קבלה חלקית — מיפוי מתחים: מה מתקבל ולמה, מה נדחה ולמה, איזון.",
}
_RATIO_SECTION_HE = {"background": "רקע", "claims": "טענות", "discussion": "דיון", "summary": "סיכום"}
async def _structure_guidance_with_corpus(outcome: str, practice_area: str) -> str:
"""Outcome structure guidance + the CORPUS-MEASURED section ratios (T10). The
measured ratios come from style_distance.measure_corpus_ratios over style_corpus,
which grows with every enrolled final — so the writer's structural target reflects
Dafna's ACTUAL current distribution, not a frozen constant (this is the structural
half of the voice-profile that learns from each decision). Deterministic (no LLM);
best-effort. Shared by both writer entry points (G2)."""
sg = STRUCTURE_GUIDANCE.get(outcome, "")
if practice_area == "betterment_levy":
sg = (sg + " | היטל השבחה: "
+ " ".join(PRACTICE_AREA_OVERRIDES["betterment_levy"]["discussion_rules"])).strip()
try:
from legal_mcp.services.style_distance import measure_corpus_ratios
entry = (await measure_corpus_ratios() or {}).get(outcome) or {}
secs = entry.get("sections") or {}
if secs:
parts = [f"{_RATIO_SECTION_HE.get(s, s)} ~{round(p)}%" for s, p in secs.items()]
sg = (sg + f" | מבנה-יעד מדוד מהקורפוס ({entry.get('n', 0)} החלטות {outcome}): "
+ " · ".join(parts)).strip()
except Exception as e:
logger.warning("corpus ratios not loaded for structure guidance: %s", e)
return sg
async def write_block(
case_id: UUID,
block_id: str,
instructions: str = "",
effort_override: str | None = None,
) -> dict:
"""כתיבת בלוק יחיד בהחלטה.
@@ -309,6 +376,11 @@ async def write_block(
case_id: מזהה התיק
block_id: מזהה הבלוק (block-alef, block-he, block-yod, ...)
instructions: הנחיות נוספות
effort_override: optional per-call reasoning effort (low/medium/high/
xhigh/max). When set, overrides BLOCK_CONFIG[block_id].effort for
THIS call only — used by the #208 model/effort calibration harness
to A/B efforts without mutating the pinned defaults. Production
callers leave it None and get the deterministic per-block effort.
Returns:
dict עם content, word_count, block_id, generation_type
@@ -352,12 +424,8 @@ async def write_block(
post_hearing_context = await _build_post_hearing_context(case_id)
outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
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()
structure_guidance = await _structure_guidance_with_corpus(
outcome, case.get("practice_area", ""))
# Content checklist — tells block-yod WHAT topics to cover
content_checklist = ""
@@ -406,33 +474,52 @@ async def write_block(
if not dir_doc.get("approved"):
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
# Guard against context overflow before calling claude -p.
# Sonnet: 200K context → ~800K chars max; Opus: 200K context → same.
# In practice the CLI has crashed on prompts above ~400K chars, so use
# that as a conservative ceiling (well below the token limit).
_MAX_PROMPT_CHARS = 400_000
# Pick the model build by prompt size (#216). Above the 200K-token wall we
# escalate to the 1M-context build (`[1m]`) instead of failing the block —
# block-yod legitimately carries the whole case as source-context. The 400K
# ceiling was an artifact of the old 200K-only build, NOT a model limit.
gen_model = GENERATION_MODEL_1M if len(prompt) > _CTX_1M_THRESHOLD_CHARS else GENERATION_MODEL
# Final guard: even the 1M build is finite (~2M Hebrew chars of input). Cap at
# 1.5M chars (~750K tokens) to leave room for output + a safety margin under 1M.
_MAX_PROMPT_CHARS = 1_500_000
if len(prompt) > _MAX_PROMPT_CHARS:
raise RuntimeError(
f"Prompt too large for {block_id}: {len(prompt):,} chars "
f"(limit {_MAX_PROMPT_CHARS:,}). "
f"(limit {_MAX_PROMPT_CHARS:,}, even on the 1M-context build). "
f"source_context: {len(source_context):,} chars. "
f"Reduce documents or call extract_appraiser_facts first."
f"Reduce source-context (summaries / appraiser_facts / focused RAG)."
)
# Call Claude via Claude Code session (no API)
model_key = block_cfg["model"]
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
content = await claude_session.query(prompt, timeout=timeout, tools="") # prose gen — no tool_use → no error_max_turns
# Call Claude via Claude Code session (no API). #204: pin the model + per-block
# reasoning effort so generation is structurally deterministic — these were
# previously NOT forwarded (the source of inconsistency). model/effort flow
# through claude_session.query → `claude -p --model … --effort …`.
effort = effort_override or block_cfg.get("effort", DEFAULT_EFFORT)
timeout = claude_session.LONG_TIMEOUT if effort in _LONG_EFFORTS else claude_session.DEFAULT_TIMEOUT
content = await claude_session.query(
prompt,
timeout=timeout,
model=gen_model,
effort=effort,
tools="", # prose gen — no tool_use → no error_max_turns
)
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 = _build_result(block_id, content, block_cfg, model_used=gen_model)
# Record the EFFECTIVE effort (override wins) so the harness can attribute
# the measured distance to the effort that actually produced the text.
if result.get("effort") is not None:
result["effort"] = effort
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,
model_used: str = GENERATION_MODEL) -> dict:
word_count = len(content.split())
is_ai = block_cfg["model"] == "ai"
return {
"block_id": block_id,
"block_index": block_cfg["index"],
@@ -440,8 +527,15 @@ def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
"content": content,
"word_count": word_count,
"generation_type": block_cfg["gen_type"],
"model_used": block_cfg["model"],
"temperature": block_cfg["temp"],
# AI blocks record the model build actually used (200K or [1m], #216);
# template blocks record "script".
"model_used": model_used if is_ai else block_cfg["model"],
# The real generation knob (#204). None for template/script blocks.
"effort": block_cfg.get("effort", DEFAULT_EFFORT) if is_ai else None,
# DEPRECATED: temperature is not a real knob on Opus 4.7/4.8 (sending it
# → HTTP 400). Kept only to satisfy decision_blocks.temperature
# NUMERIC(3,2); always 0. Read `effort` instead.
"temperature": 0,
}
@@ -522,24 +616,63 @@ async def _build_claims_context(case_id: UUID) -> str:
return "(לא חולצו טענות)"
# Filter out claims from block-zayin (decision summary) — use only
# claims extracted from original pleadings (appeal, response, etc.)
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"]
# claims extracted from original pleadings (appeal, response, etc.).
# Also drop claim_type='protocol' (oral arguments raised at the hearing):
# block ז carries ORIGINAL written pleadings only (INV-WR4); hearing-raised
# arguments belong to block ח (proceedings) and to analyze_protocol's
# comparative case-knowledge, not to the parties'-claims summary.
source_claims = [
c for c in claims
if c.get("source_document", "") != "block-zayin"
and c.get("claim_type", "claim") != "protocol"
]
if not source_claims:
# Fallback to all claims if no source claims exist
source_claims = claims
# Fallback to all non-block-zayin claims if no source claims exist.
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims
# Group by (party_role, brief) so a multi-litigant side (respondent /
# permit_applicant) whose claims come from distinct pleadings reads as
# SEPARATE positions — e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6" — instead
# of one merged "טענות המשיבים" (#224). The brief label is the source
# pleading, and which sides split is SPLIT_PARTIES — the SAME rule the
# argument aggregator uses, so block ז and the arguments panel agree (G2).
from legal_mcp.services.argument_aggregator import SPLIT_PARTIES
lines = []
current_role = ""
role_heb = {"appellant": "טענות העוררים", "respondent": "טענות המשיבים",
"committee": "עמדת הוועדה המקומית", "permit_applicant": "עמדת מבקשי ההיתר"}
claim_num = 0
role_order = ["appellant", "committee", "respondent", "permit_applicant"]
groups: dict[tuple[str, str], list[dict]] = {}
for c in source_claims:
if c["party_role"] != current_role:
current_role = c["party_role"]
lines.append(f"\n### {role_heb.get(current_role, current_role)}")
claim_num += 1
lines.append(f"טענה #{claim_num}: {c['claim_text'][:400]}")
lines.append(f"\n**סה\"כ {claim_num} טענות. ענה על כל טענה מהותית; טענות [bundle] — אגד; טענות [skip] — ציון קצר בלבד.**")
role = c.get("party_role", "") or ""
# Prefer the stored party_name (stamped by the extractor, #224); fall
# back to source_document for legacy claims predating the stamping.
if role in SPLIT_PARTIES:
brief = (c.get("party_name") or "").strip() or (c.get("source_document") or "").strip()
else:
brief = ""
groups.setdefault((role, brief), []).append(c)
def _sort_key(k: tuple[str, str]) -> tuple[int, str]:
role, brief = k
idx = role_order.index(role) if role in role_order else len(role_order)
return (idx, brief)
lines: list[str] = []
claim_num = 0
for role, brief in sorted(groups.keys(), key=_sort_key):
header = role_heb.get(role, role or "טענות נוספות")
if brief:
header = f"{header}{brief}"
lines.append(f"\n### {header}")
for c in groups[(role, brief)]:
claim_num += 1
lines.append(f"טענה #{claim_num}: {c['claim_text'][:400]}")
lines.append(
f"\n**סה\"כ {claim_num} טענות. שמור על ההפרדה בין הצדדים לעיל — "
f"לכל צד/כתב-תשובה עמדה נפרדת (אל תמזג משיבים שונים). ענה על כל טענה "
f"מהותית; טענות [bundle] — אגד; טענות [skip] — ציון קצר בלבד.**"
)
return "\n".join(lines)
@@ -983,6 +1116,22 @@ async def _build_style_context(practice_area: str = "") -> str:
("anti_patterns", "אנטי-דפוסים (להימנע)"),
):
ov = await db.get_methodology_overrides(cat)
if cat == "anti_patterns":
# Anti-patterns are STRUCTURAL INVARIANTS of Dafna's style (no
# markdown headers, no bullet lists, no mid-paragraph mini-lists —
# she writes continuous legal narrative). They must reach the writer
# ALWAYS, from the SAME canonical list style_distance measures against
# (lessons.ANTI_PATTERNS) — otherwise the loop detects them but never
# corrects them, and drafts keep emitting them (the gap that left
# 8137 with 28 hits). Chair additions layer on top; they never
# remove the canonical ones.
from legal_mcp.services.lessons import ANTI_PATTERNS as _ANTI
learned.append(f"\n**{label} — כתוב נרטיב משפטי רציף; הימנע מ:**")
for ap in _ANTI:
learned.append(f"- {ap['note']}")
for k, v in (ov or {}).items():
learned.append(f"- (יו\"ר) {k}: {json.dumps(v, ensure_ascii=False)}")
continue
if ov:
learned.append(f"\n**{label} — ערכי היו\"ר (גוברים על ברירת-המחדל):**")
for k, v in ov.items():
@@ -990,7 +1139,8 @@ async def _build_style_context(practice_area: str = "") -> str:
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)
lessons = await db.get_recent_decision_lessons(
limit=config.WRITER_LESSONS_LIMIT, practice_area=practice_area)
if lessons:
learned.append("\n**לקחים מהחלטות קודמות (decision_lessons):**")
for ls in lessons:
@@ -1078,12 +1228,8 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = ""
post_hearing_context = await _build_post_hearing_context(case_id)
outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
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()
structure_guidance = await _structure_guidance_with_corpus(
outcome, case.get("practice_area", ""))
# Content checklist + methodology for block-yod
content_checklist = ""

View File

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

View File

@@ -144,9 +144,12 @@ def _split_into_sections(text: str) -> list[tuple[str, str]]:
markers: list[tuple[int, str]] = []
for pattern, section_type in SECTION_PATTERNS:
# ^ + MULTILINE: line start only. Optional leading spaces/tabs and an
# ^ + MULTILINE: line start only. Optional leading spaces/tabs, an
# optional Markdown ATX header prefix (``## ``/``### ``), and an
# optional ordinal prefix ("5.", "5)", "ג.") before the keyword.
anchored = rf"^[ \t]*(?:\d+[.)]\s*|[א-ת][.)]\s*)?(?:{pattern})"
# The Markdown prefix handles Mistral OCR output where section
# titles are rendered as ``## נימוקי הערר`` etc.
anchored = rf"^[ \t]*(?:#{1,3}\s+)?(?:\d+[.)]\s*|[א-ת][.)]\s*)?(?:{pattern})"
for match in re.finditer(anchored, text, re.MULTILINE):
markers.append((match.start(), section_type))

View File

@@ -236,7 +236,18 @@ async def extract_and_store(case_law_id: UUID) -> dict:
if not row:
return {"extracted": 0, "linked": 0, "new": 0, "skipped": 0, "error": "not_found"}
text = row["full_text"] or ""
# A citation counts as the DECIDING body's reliance ONLY when it appears in
# the discussion/ruling sections — never where a party cites a ruling in its
# own arguments (block ז). Reuse the halacha extractor's section selection
# (G2: a single definition of "reasoning sections" — legal_analysis/ruling/
# conclusion + the discussion-anchor, excluding facts/claims/intro) so a
# ruling cited only in appellant_claims/parties_claims is never attributed to
# the chair. Fall back to full_text only for un-chunked rows.
from legal_mcp.services import halacha_extractor as _hx
disc_chunks, _used_fallback = await _hx._select_extractable_chunks(case_law_id)
text = "\n".join((c.get("content") or "") for c in disc_chunks).strip()
if not text:
text = row["full_text"] or ""
own_norm = _normalize_case_number(row["case_number"] or "")
extracted = 0
@@ -388,12 +399,16 @@ async def list_citations_to_case_law(case_law_id: UUID) -> list[dict]:
pic.cited_case_number,
pic.match_context,
pic.match_pattern,
pic.treatment,
pic.confidence::float AS confidence,
pic.created_at,
cl.case_number AS source_case_number,
cl.case_name AS source_case_name,
cl.chair_name AS source_chair_name,
cl.district AS source_district
cl.district AS source_district,
cl.precedent_level AS source_precedent_level,
cl.court AS source_court,
cl.date AS source_date
FROM precedent_internal_citations pic
JOIN case_law cl ON cl.id = pic.source_case_law_id
WHERE pic.cited_case_law_id = $1
@@ -432,3 +447,42 @@ async def get_cited_case_law_ids(source_case_law_ids: list[UUID]) -> dict[str, l
for r in rows:
out.setdefault(r["source_id"], []).append(r["cited_id"])
return out
async def relink_orphan_citations(case_law_id: "UUID | None" = None) -> int:
"""Back-fill ``cited_case_law_id`` on orphan citation edges that now resolve.
Citations are resolved to a corpus row only at extraction time (see
``extract_and_store``). When a cited ruling is uploaded LATER, its existing
orphan edges (``cited_case_law_id IS NULL``) are never re-resolved, so the
citation graph under-counts real connectivity. Call on every precedent
upload (``case_law_id`` set → link only edges that resolve to that row) or as
a one-off sweep (``case_law_id=None`` → re-resolve every orphan). Reuses the
canonical resolver ``_resolve_case_law_id`` (no parallel matching logic, G2);
idempotent — a second run links nothing new.
"""
pool = await db.get_pool()
async with pool.acquire() as conn:
orphans = await conn.fetch(
"SELECT DISTINCT cited_case_number FROM precedent_internal_citations "
"WHERE cited_case_law_id IS NULL AND coalesce(cited_case_number, '') <> ''"
)
linked = 0
for r in orphans:
cited = await _resolve_case_law_id(_normalize_case_number(r["cited_case_number"]))
if cited is None:
continue
if case_law_id is not None and cited != case_law_id:
continue
async with pool.acquire() as conn:
res = await conn.execute(
"UPDATE precedent_internal_citations "
"SET cited_case_law_id = $1, confidence = GREATEST(confidence, 0.90) "
"WHERE cited_case_law_id IS NULL AND cited_case_number = $2",
cited, r["cited_case_number"],
)
try:
linked += int(res.split()[-1])
except (ValueError, IndexError):
pass
return linked

View File

@@ -227,8 +227,15 @@ def _infer_claim_type(doc_type: str, source_name: str) -> str:
- 'claim' = from appeal documents (כתב ערר)
- 'response' = from original response documents (כתב תשובה)
- 'reply' = from supplementary responses (תגובה, השלמת טיעון)
- 'protocol' = oral arguments raised at the hearing (פרוטוקול דיון)
"""
name_lower = source_name.lower() if source_name else ""
# A hearing protocol carries oral arguments — tagged distinctly so the
# comparative protocol analysis (analyze_protocol) and block-chet
# (proceedings) can tell them apart from the original written pleadings
# (INV-WR4: block ז stays original-pleadings-only).
if doc_type == "protocol" or "פרוטוקול" in name_lower:
return "protocol"
if doc_type == "appeal" or "כתב ערר" in name_lower:
return "claim"
if "כתב תשובה" in name_lower:
@@ -353,14 +360,29 @@ async def extract_and_store_claims(
claims = await extract_claims_with_ai(text, doc_type, party_hint)
if not claims:
# Stamp the document as analysed even with zero claims, so it is not
# perpetually flagged "not-analysed" (#201 — the not-analysed flag is
# about whether extraction *ran*, not whether it found anything).
await db.mark_document_claims_extracted(document_id, status="no_claims")
return {"status": "no_claims", "total": 0, "source": source_name}
# Determine claim_type from document type and title
claim_type = _infer_claim_type(doc_type, source_name)
# Stamp party_name at the source (#224). For a multi-litigant side
# (respondent / permit_applicant) the brief label IS the source pleading, so
# opposing briefs (משיבות 2-3 vs משיבים 4-6) stay distinct downstream without
# the aggregator/block-writer having to re-derive it. SPLIT_PARTIES is the
# single rule shared with the aggregator (G2); single-voice sides stay ''.
from legal_mcp.services.argument_aggregator import SPLIT_PARTIES
for c in claims:
c["claim_type"] = claim_type
c["party_name"] = source_name if c.get("party_role") in SPLIT_PARTIES else ""
stored = await db.store_claims(case_id, claims, source_document=source_name)
# Mark this document analysed (WS2 / #201). store_claims already replaced
# this document's prior claims by source_document — so re-running is a clean
# per-document refresh that does NOT touch other documents' claims (the merge).
await db.mark_document_claims_extracted(document_id, status="completed")
# Summarize by role
role_counts: dict[str, int] = {}

View File

@@ -146,8 +146,15 @@ async def query(
"""
full_prompt = f"{system}\n\n{prompt}" if system else prompt
if len(full_prompt) > 150_000:
logger.warning("Large prompt: %d chars — may hit context limits", len(full_prompt))
# ~350K chars ≈ the 200K-token wall of the default Opus build (~2 Hebrew
# chars/token). Above it callers should pass the 1M-context model id
# (`claude-opus-4-8[1m]`) — block_writer escalates automatically (#216).
if len(full_prompt) > 350_000 and "[1m]" not in (model or ""):
logger.warning(
"Large prompt: %d chars on a non-[1m] model (%s) — may overflow the "
"200K-token context. Pass claude-opus-4-8[1m] for the 1M window.",
len(full_prompt), model or "CLI-default",
)
cmd = [
"claude", "-p",

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -43,10 +43,16 @@ HEBREW_FONT = "David"
def _mark_run_rtl(run) -> None:
"""Force David font on all four slots, then add <w:rtl/>."""
"""Force David font on all four slots, then add <w:rtl/>.
INV-EX4 note: this reinforces the template's OWN font (David, == the
template's cs slot) to work around Word's RTL ascii-slot fallback to
Times New Roman — it is NOT an ad-hoc font/size override. Marked
INV-EX4-ok for the single-source guard for that reason.
"""
rPr = run._r.get_or_add_rPr()
if rPr.find(qn("w:rFonts")) is None:
fonts = OxmlElement("w:rFonts")
fonts = OxmlElement("w:rFonts") # INV-EX4-ok: reinforces template font David
fonts.set(qn("w:ascii"), HEBREW_FONT)
fonts.set(qn("w:hAnsi"), HEBREW_FONT)
fonts.set(qn("w:cs"), HEBREW_FONT)
@@ -192,14 +198,29 @@ def _apply_list_numbering(paragraph, num_id: int) -> None:
def _clear_body(doc) -> None:
"""Remove all paragraphs in the document body while keeping sectPr.
"""Remove ALL body content (paragraphs, tables, stray bookmarks) while
keeping only sectPr.
The template ships with sample paragraphs we don't want. Section
properties (page size, margins, bidi) stay intact.
The template ships with sample paragraphs *and three sample tables*
(institutional header, panel, and a **signatures** table at the end —
"ניתנה פה אחד" + יו"ר/מזכירה). Removing only ``w:p`` left those tables
behind: the leftover header/panel tables injected block-א–ד data that was
never extracted from the protocol (violates the empty-unless-extracted
rule), and the leftover signatures table collapsed up against the header
table at the **top** of the document (the chair-reported bug: חתימות צמודות
לבלוק-ד במקום בסוף). The decision is a derived artifact rebuilt purely from
``decision_blocks`` (INV-EX1) — signatures come from block-yod-bet, rendered
last in block order — so the template's sample tables are stale scaffolding
and must go. Section properties (page size, margins, bidi) stay intact.
"""
body = doc.element.body
for p in list(body.findall(qn("w:p"))):
body.remove(p)
sectPr_tag = qn("w:sectPr")
for child in list(body):
# Keep section properties (page setup / bidi). Drop everything else:
# sample paragraphs (w:p), sample tables (w:tbl), and any stray
# bookmark markers the template left dangling between them.
if child.tag != sectPr_tag:
body.remove(child)
# ── Bookmark helpers ──────────────────────────────────────────────
@@ -378,8 +399,15 @@ _INTERIM_BLOCK_ORDER = [
]
def _draft_filename_prefix(mode: str) -> str:
return "טיוטת-ביניים" if mode == "interim" else "טיוטה"
def _draft_naming(mode: str) -> tuple[str, str]:
"""(filename prefix, version separator) per export mode.
interim → ``טיוטה-טענות_הצדדים_{N}.docx`` (chair-requested naming);
final → ``טיוטה-v{N}.docx``.
"""
if mode == "interim":
return "טיוטה-טענות_הצדדים", "_"
return "טיוטה", "-v"
async def export_decision(
@@ -464,16 +492,19 @@ async def export_decision(
if not output_path:
export_dir = config.find_case_dir(case["case_number"]) / "exports"
export_dir.mkdir(parents=True, exist_ok=True)
prefix = _draft_filename_prefix(mode)
existing = sorted(export_dir.glob(f"{prefix}-v*.docx"))
prefix, sep = _draft_naming(mode)
existing = sorted(export_dir.glob(f"{prefix}{sep}*.docx"))
next_ver = 1
for p in existing:
try:
ver = int(p.stem.split("-v")[1])
# Version is the trailing integer after the separator. Using
# rsplit keeps this correct even when the prefix itself contains
# the separator char (e.g. "טיוטה-טענות_הצדדים" with sep="_").
ver = int(p.stem.rsplit(sep, 1)[1])
next_ver = max(next_ver, ver + 1)
except (IndexError, ValueError):
pass
output_path = str(export_dir / f"{prefix}-v{next_ver}.docx")
output_path = str(export_dir / f"{prefix}{sep}{next_ver}.docx")
# Persist through the storage layer (INV-STG1). Under the filesystem
# backend the bytes land at output_path exactly as before; a caller-

View File

@@ -199,27 +199,86 @@ def _find_enclosing_paragraph(element: etree._Element) -> etree._Element | None:
return None
# ── Template style resolution (INV-EX4) ────────────────────────────
# Every inserted paragraph carries a <w:pStyle> pointing at one of the
# template's named styles — font (David), size, and indent come from
# styles.xml, NOT from hand-set rFonts/sz here. Single template = single
# style source (INV-EX4 / G2). docx_exporter.py applies the same styles by
# name via python-docx; here (raw lxml) we resolve name → styleId.
# StyleType → candidate style names in the template (case-insensitive).
# Mirrors the names docx_exporter uses (paragraph.style = "Heading 2"/"Normal"/"Quote").
_STYLE_NAME_CANDIDATES: dict[str, tuple[str, ...]] = {
"body": ("Normal", "Default Paragraph Font", "Body Text"),
"bold": ("Normal", "Body Text"),
"heading": ("Heading 2", "heading 2"),
"quote": ("Quote", "quote", "Intense Quote"),
}
def _resolve_style_ids(members: dict[str, bytes]) -> dict[str, str]:
"""Map each StyleType → the template's w:styleId, read from styles.xml.
Returns {} when styles.xml is absent — callers then fall back to
minimal inline emphasis (so a missing style never crashes rendering).
"""
raw = members.get("word/styles.xml")
if not raw:
return {}
try:
styles_tree = etree.fromstring(raw)
except etree.XMLSyntaxError: # pragma: no cover - corrupt template
return {}
# Build a lowercase lookup over both the style's display name and its id.
by_key: dict[str, str] = {}
for style in styles_tree.iterfind(".//w:style", NSMAP):
sid = style.get(_w("styleId"))
if not sid:
continue
by_key.setdefault(sid.lower(), sid)
name_el = style.find(_w("name"))
if name_el is not None:
nm = name_el.get(_w("val"))
if nm:
by_key.setdefault(nm.lower(), sid)
resolved: dict[str, str] = {}
for style_type, candidates in _STYLE_NAME_CANDIDATES.items():
for cand in candidates:
sid = by_key.get(cand.lower())
if sid is not None:
resolved[style_type] = sid
break
return resolved
def _emphasis_fallback(style: StyleType, *, has_pstyle: bool) -> tuple[bool, bool]:
"""(bold, italic) for a run. When the paragraph carries a resolved
template pStyle, emphasis comes from the style (return no inline override),
except 'bold' which is inline emphasis with no dedicated style. When no
pStyle resolved, fall back to minimal inline emphasis so heading/quote
stay visually distinct."""
if style == "bold":
return True, False
if has_pstyle:
return False, False
return (style == "heading"), (style == "quote")
# ── Paragraph builders ─────────────────────────────────────────────
def _build_run(text: str, *, bold: bool = False, italic: bool = False,
font: str = "David", size_half_pt: int | None = None) -> etree._Element:
"""Build a w:r (run) element with RTL/David defaults and given text."""
def _build_run(text: str, *, bold: bool = False, italic: bool = False) -> etree._Element:
"""Build a w:r (run): RTL marker + optional bold/italic + text.
Font (David) and size are NOT set here — they derive from the paragraph's
template style (INV-EX4). The <w:rtl/> marker is still required so Word
picks the style's complex-script (cs=David) slot rather than the ascii
(Times New Roman) slot for Hebrew (same reason as _mark_run_rtl in
analysis_docx_exporter.py).
"""
r = etree.Element(_w("r"))
rPr = etree.SubElement(r, _w("rPr"))
rFonts = etree.SubElement(rPr, _w("rFonts"))
rFonts.set(_w("ascii"), font)
rFonts.set(_w("hAnsi"), font)
rFonts.set(_w("cs"), font)
rFonts.set(_w("hint"), "cs")
if size_half_pt is not None:
sz = etree.SubElement(rPr, _w("sz"))
sz.set(_w("val"), str(size_half_pt))
szCs = etree.SubElement(rPr, _w("szCs"))
szCs.set(_w("val"), str(size_half_pt))
if bold:
etree.SubElement(rPr, _w("b"))
etree.SubElement(rPr, _w("bCs"))
@@ -235,28 +294,36 @@ def _build_run(text: str, *, bold: bool = False, italic: bool = False,
return r
def _build_paragraph(text: str, *, style: StyleType = "body") -> etree._Element:
"""Build a w:p (paragraph) with RTL + David + given text."""
def _build_paragraph(text: str, *, style: StyleType = "body",
style_id: str | None = None) -> etree._Element:
"""Build a w:p (paragraph) carrying the template's named style.
The paragraph references `style_id` (resolved from the template's
styles.xml) via <w:pStyle> — font/size/indent come from there, not from
hand-set rFonts/sz (INV-EX4). The only explicit paragraph-level override
is the canonical body alignment jc=both, matching docx_exporter's
_set_paragraph_jc (which forces both over style-center headings).
"""
p = etree.Element(_w("p"))
pPr = etree.SubElement(p, _w("pPr"))
if style_id:
pStyle = etree.SubElement(pPr, _w("pStyle"))
pStyle.set(_w("val"), style_id)
bidi = etree.SubElement(pPr, _w("bidi"))
bidi.set(_w("val"), "1")
# Right alignment for body/RTL
# Canonical body alignment (two-sided justification) — overrides any
# style-center, exactly as docx_exporter._set_paragraph_jc does.
jc = etree.SubElement(pPr, _w("jc"))
jc.set(_w("val"), "right")
jc.set(_w("val"), "both")
rPr_p = etree.SubElement(pPr, _w("rPr"))
etree.SubElement(rPr_p, _w("rtl"))
bold = style in ("heading", "bold")
italic = style == "quote"
size = None
if style == "heading":
size = 28 # 14pt
elif style == "quote":
size = 22 # 11pt
run = _build_run(text, bold=bold, italic=italic, size_half_pt=size)
bold, italic = _emphasis_fallback(style, has_pstyle=bool(style_id))
run = _build_run(text, bold=bold, italic=italic)
p.append(run)
return p
@@ -275,7 +342,7 @@ def _wrap_in_ins(elements: list[etree._Element], *, ins_id: int,
def _make_tracked_paragraph_insert(
text: str, *, style: StyleType, ins_id: int, author: str, date_iso: str,
mark_id: int | None = None,
mark_id: int | None = None, style_id: str | None = None,
) -> etree._Element:
"""Build a whole tracked-inserted paragraph.
@@ -286,7 +353,7 @@ def _make_tracked_paragraph_insert(
"""
if mark_id is None:
mark_id = ins_id
p = _build_paragraph(text, style=style)
p = _build_paragraph(text, style=style, style_id=style_id)
pPr = p.find(_w("pPr"))
assert pPr is not None
rPr = pPr.find(_w("rPr"))
@@ -339,6 +406,7 @@ def _apply_insert(
ins_id: int,
author: str,
date_iso: str,
style_ids: dict[str, str] | None = None,
) -> RevisionResult:
"""Apply insert_after / insert_before relative to a bookmark."""
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
@@ -362,6 +430,7 @@ def _apply_insert(
revision.content, style=revision.style,
ins_id=ins_id, mark_id=ins_id + 1,
author=author, date_iso=date_iso,
style_id=(style_ids or {}).get(revision.style),
)
parent = enclosing_p.getparent()
@@ -407,6 +476,7 @@ def _apply_replace(
del_id: int,
author: str,
date_iso: str,
style_ids: dict[str, str] | None = None,
) -> RevisionResult:
"""Replace = delete the existing paragraph + insert new one after it."""
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
@@ -428,6 +498,7 @@ def _apply_replace(
revision.content, style=revision.style,
ins_id=ins_id, mark_id=ins_id + 1,
author=author, date_iso=date_iso,
style_id=(style_ids or {}).get(revision.style),
)
idx = list(parent).index(enclosing_p)
parent.insert(idx + 1, new_p)
@@ -473,6 +544,10 @@ def apply_tracked_revisions(
members, doc_tree, settings_tree = _load_docx_xml(source_path)
_ensure_track_revisions(settings_tree)
# Resolve template style ids once (INV-EX4) — inserted paragraphs inherit
# font/size/indent from these named styles instead of hand-set rFonts/sz.
style_ids = _resolve_style_ids(members)
next_id = _next_revision_id(doc_tree)
batch = RevisionBatchResult()
@@ -480,7 +555,8 @@ def apply_tracked_revisions(
try:
if rev.type in ("insert_after", "insert_before"):
result = _apply_insert(doc_tree, rev, ins_id=next_id,
author=author, date_iso=date_iso)
author=author, date_iso=date_iso,
style_ids=style_ids)
# insert consumes 2 IDs: run-wrapper + paragraph-mark
next_id += 2
elif rev.type == "delete":
@@ -490,7 +566,8 @@ def apply_tracked_revisions(
elif rev.type == "replace":
result = _apply_replace(doc_tree, rev,
ins_id=next_id, del_id=next_id + 2,
author=author, date_iso=date_iso)
author=author, date_iso=date_iso,
style_ids=style_ids)
# replace consumes 3 IDs: ins-run, ins-mark, del
next_id += 3
else:

View File

@@ -1,23 +1,33 @@
"""Text extraction from PDF, DOCX, DOC, and RTF files.
Primary PDF extraction: PyMuPDF direct text (for born-digital PDFs).
Fallback: Google Cloud Vision OCR (for scanned documents).
Fallback: Mistral OCR (for scanned documents or broken OCR layers).
Routing logic (document-level, not per-page):
1. PyMuPDF extracts text from every page.
2. Pages are quality-checked via _text_quality_ok().
3. If ALL pages pass → use PyMuPDF output (free, ~50ms, no API call).
4. If ANY page fails → call Mistral OCR once for the entire PDF.
Mistral returns per-page Markdown; page_offsets are computed from it.
DOC files: converted to DOCX via LibreOffice before extraction.
Post-processing: Hebrew abbreviation quote fixer.
Post-processing: Hebrew abbreviation quote fixer (PyMuPDF path only;
Mistral handles gershayim natively).
"""
from __future__ import annotations
import asyncio
import base64
import io
import logging
import re
import subprocess
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING
import fitz # PyMuPDF
import httpx
from PIL import Image
from docx import Document as DocxDocument
from striprtf.striprtf import rtf_to_text
@@ -25,36 +35,65 @@ from striprtf.striprtf import rtf_to_text
from legal_mcp import config
from legal_mcp.services import storage
if TYPE_CHECKING:
from google.cloud import vision
logger = logging.getLogger(__name__)
# ── Google Cloud Vision client (imported lazily — saves ~550ms at MCP startup) ──
# ── Mistral OCR ───────────────────────────────────────────────────
_vision_client: "vision.ImageAnnotatorClient | None" = None
_MISTRAL_OCR_URL = "https://api.mistral.ai/v1/ocr"
_MISTRAL_OCR_MODEL = "mistral-ocr-latest"
def _get_vision_client() -> "vision.ImageAnnotatorClient":
global _vision_client
if _vision_client is None:
from google.cloud import vision
_vision_client = vision.ImageAnnotatorClient(
client_options={"api_key": config.GOOGLE_CLOUD_VISION_API_KEY}
async def _call_mistral_ocr(path: Path) -> list[str]:
"""Call Mistral OCR API on a PDF. Returns per-page Markdown text list.
The Mistral response contains ``pages[i].markdown`` for each page.
If the response has fewer pages than the PDF, trailing pages are
padded with empty strings by the caller.
"""
if not config.MISTRAL_API_KEY:
raise RuntimeError(
"MISTRAL_API_KEY not configured — cannot OCR scanned PDF. "
"Set the env var in Coolify."
)
return _vision_client
pdf_b64 = base64.b64encode(path.read_bytes()).decode()
async with httpx.AsyncClient(timeout=300.0) as client:
resp = await client.post(
_MISTRAL_OCR_URL,
headers={
"Authorization": f"Bearer {config.MISTRAL_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": _MISTRAL_OCR_MODEL,
"document": {
"type": "document_url",
"document_url": f"data:application/pdf;base64,{pdf_b64}",
},
"include_image_base64": False,
},
)
if resp.status_code != 200:
raise RuntimeError(
f"Mistral OCR returned {resp.status_code}: {resp.text[:400]}"
)
pages = resp.json().get("pages", [])
return [p.get("markdown", "") for p in pages]
# ── Hebrew text quality detection ────────────────────────────────
_HEBREW_RE = re.compile(r'[\u0590-\u05FF]')
_HEBREW_RE = re.compile(r'[֐-׿]')
_WORD_RE = re.compile(r'\S+')
def _text_quality_ok(text: str) -> bool:
"""Check if extracted text is real content vs broken OCR layer.
"""Check if PyMuPDF-extracted text is genuine Hebrew legal content.
Returns True if text appears to be genuine Hebrew legal content.
Returns True if text appears to be real content.
Broken OCR layers from scanned PDFs often have:
- Very short words / single-character fragments
- Each word on its own line (high words-per-line ratio)
@@ -64,26 +103,19 @@ def _text_quality_ok(text: str) -> bool:
if len(words) < 10:
return False
# Average word length — real Hebrew words avg 4-6 chars.
avg_len = sum(len(w) for w in words) / len(words)
if avg_len < 2.5:
return False
# Percentage of single-character "words"
single_char_pct = sum(1 for w in words if len(w) == 1) / len(words)
if single_char_pct > 0.4:
return False
# Words per line — broken OCR puts each word on its own line.
# Real text has 5-15 words per line; broken OCR has ~1-2.
lines = [l for l in text.split("\n") if l.strip()]
if lines:
words_per_line = len(words) / len(lines)
if words_per_line < 3.0:
return False
if lines and len(words) / len(lines) < 3.0:
return False
# Hebrew character ratio among letter characters
letters = re.findall(r'[a-zA-Z\u0590-\u05FF]', text)
letters = re.findall(r'[a-zA-Z֐-׿]', text)
if letters:
hebrew_pct = sum(1 for c in letters if _HEBREW_RE.match(c)) / len(letters)
if hebrew_pct < 0.5:
@@ -92,7 +124,7 @@ def _text_quality_ok(text: str) -> bool:
return True
# ── Hebrew abbreviation quote fixer ──────────────────────────────
# ── Hebrew abbreviation quote fixer (PyMuPDF path only) ──────────
_HEBREW_ABBREV_FIXES: dict[str, str] = {
'עוהייד': 'עוה"ד',
@@ -111,50 +143,133 @@ _HEBREW_ABBREV_FIXES: dict[str, str] = {
'יחייד': 'יח"ד',
'בייכ': 'ב"כ',
# Patterns where double-yod (יי) substitutes for gershayim (״) in born-digital PDFs
'בליימ': 'בל"מ', # בקשה להארכת מועד — appears in RTL legal docs
'תמייא': 'תמ"א', # תכנית מתאר ארצית
'בליימ': 'בל"מ',
'תמייא': 'תמ"א',
}
_ABBREV_PATTERN = re.compile(
'|'.join(re.escape(k) for k in sorted(_HEBREW_ABBREV_FIXES, key=len, reverse=True))
)
# Matches Hebrew law year abbreviations where gershayim was encoded as double-yod.
# e.g. תשכייה → תשכ"ה, תשנייב → תשנ"ב
_HEBREW_YEAR_RE = re.compile(r'(תש[א-ת]+)יי([א-ת])')
def _fix_hebrew_quotes(text: str) -> str:
"""Fix known Hebrew abbreviation quote replacements.
Applied to both Google Vision OCR output and direct PyMuPDF extraction —
some born-digital PDFs encode gershayim (״) as double-yod (יי), producing
the same corruption patterns as OCR.
"""
"""Fix gershayim encoded as double-yod in born-digital PDFs."""
text = _ABBREV_PATTERN.sub(lambda m: _HEBREW_ABBREV_FIXES[m.group()], text)
text = _HEBREW_YEAR_RE.sub(r'\1"\2', text)
return text
# ── Extraction ───────────────────────────────────────────────────
# ── Page joining ──────────────────────────────────────────────────
# Separator used when joining per-page text. Constant so chunker /
# retrofit can reproduce the join when computing page offsets.
PAGE_SEPARATOR = "\n\n"
def _join_pages(pages_text: list[str]) -> tuple[str, list[int]]:
"""Join per-page text with PAGE_SEPARATOR while recording start offsets."""
offsets: list[int] = []
parts: list[str] = []
cursor = 0
for i, pg in enumerate(pages_text):
offsets.append(cursor)
parts.append(pg)
cursor += len(pg)
if i < len(pages_text) - 1:
parts.append(PAGE_SEPARATOR)
cursor += len(PAGE_SEPARATOR)
return "".join(parts), offsets
# ── PDF extraction ────────────────────────────────────────────────
async def _extract_pdf(path: Path) -> tuple[str, int, list[int]]:
"""Extract text from PDF using document-level routing.
Stage 1 — PyMuPDF pre-screen (free, ~50ms, no API call):
Run on every page. Collect per-page text; flag pages where
PyMuPDF returns < 50 chars or _text_quality_ok() fails
(scanned, blank, or broken embedded OCR layer).
Stage 2 — Mistral OCR (triggered when any page fails Stage 1):
Send the entire PDF to Mistral once. Use its per-page Markdown
for ALL pages — consistent source, no mixed plain/Markdown formats.
Mistral handles gershayim natively; no quote-fix applied.
Page offsets are always computed so the chunker can attribute each
chunk to its source page number (multimodal hybrid retrieval).
"""
doc = fitz.open(str(path))
page_count = len(doc)
# Stage 1: PyMuPDF pre-screen
pymupdf_pages: list[str] = []
failed: list[int] = []
for i in range(page_count):
text = doc[i].get_text().strip()
if len(text) > 50 and _text_quality_ok(text):
pymupdf_pages.append(_fix_hebrew_quotes(text))
else:
pymupdf_pages.append("")
failed.append(i)
doc.close()
if not failed:
logger.debug(
"PDF %s: all %d pages digital — PyMuPDF only", path.name, page_count
)
joined, offsets = _join_pages(pymupdf_pages)
return joined, page_count, offsets
# Stage 2: Mistral OCR for entire document
logger.info(
"PDF %s: %d/%d pages failed quality check → Mistral OCR",
path.name, len(failed), page_count,
)
mistral_pages = await _call_mistral_ocr(path)
# Pad if Mistral returns fewer pages than PyMuPDF counted
while len(mistral_pages) < page_count:
mistral_pages.append("")
joined, offsets = _join_pages(mistral_pages[:page_count])
return joined, page_count, offsets
def page_at_offset(offset: int, page_offsets: list[int]) -> int:
"""Return the 1-based page number containing a given char offset.
page_offsets[i] is the start of page (i+1) in the joined text.
"""
if not page_offsets:
return 1
page = 1
for i, start in enumerate(page_offsets):
if start <= offset:
page = i + 1
else:
break
return page
# ── Public entry point ────────────────────────────────────────────
async def extract_text(file_path: str) -> tuple[str, int, list[int] | None]:
"""Extract text from a document file.
Returns:
``(text, page_count, page_offsets)`` where:
- ``text``: concatenated extracted text
- ``text``: extracted text. Plain text for PyMuPDF path;
Markdown for Mistral path (tables, ``##`` headers preserved).
- ``page_count``: number of pages (0 for non-PDF)
- ``page_offsets``: ``page_offsets[i]`` = char start offset of
page (i+1) inside ``text``. ``None`` for non-PDFs (where the
notion of pages doesn't apply). Used by the chunker to assign
a ``page_number`` to each chunk.
- ``page_offsets``: char start of each page inside ``text``,
or ``None`` for non-PDF formats
"""
path = Path(file_path)
suffix = path.suffix.lower()
@@ -173,95 +288,11 @@ async def extract_text(file_path: str) -> tuple[str, int, list[int] | None]:
raise ValueError(f"Unsupported file type: {suffix}")
def _join_pages(pages_text: list[str]) -> tuple[str, list[int]]:
"""Join per-page text with PAGE_SEPARATOR while recording the start
offset of each page in the joined output."""
offsets: list[int] = []
parts: list[str] = []
cursor = 0
for i, pg in enumerate(pages_text):
offsets.append(cursor)
parts.append(pg)
cursor += len(pg)
if i < len(pages_text) - 1:
parts.append(PAGE_SEPARATOR)
cursor += len(PAGE_SEPARATOR)
return "".join(parts), offsets
async def _extract_pdf(path: Path) -> tuple[str, int, list[int]]:
"""Extract text from PDF.
Try direct text first, fall back to Google Cloud Vision for scanned
or broken-OCR pages.
"""
doc = fitz.open(str(path))
page_count = len(doc)
pages_text: list[str] = []
for page_num in range(page_count):
page = doc[page_num]
text = page.get_text().strip()
if len(text) > 50 and _text_quality_ok(text):
pages_text.append(_fix_hebrew_quotes(text))
logger.debug("Page %d: direct extraction (%d chars, quality OK)", page_num + 1, len(text))
else:
reason = "insufficient text" if len(text) <= 50 else "low quality OCR layer"
logger.info("Page %d: Google Vision OCR (%s)", page_num + 1, reason)
pix = page.get_pixmap(dpi=300)
img_bytes = pix.tobytes("png")
ocr_text = await asyncio.to_thread(
_ocr_with_google_vision, img_bytes, page_num + 1
)
pages_text.append(ocr_text)
doc.close()
joined, offsets = _join_pages(pages_text)
return joined, page_count, offsets
def page_at_offset(offset: int, page_offsets: list[int]) -> int:
"""Look up the page number containing a given char offset.
page_offsets[i] is the start of page (i+1) in the joined text;
a chunk starting at ``offset`` belongs to the highest-indexed page
whose start is ``<= offset``. Returns 1-based page number.
"""
if not page_offsets:
return 1
# Linear scan is fine — page_offsets is short (≤ ~200 for our PDFs).
page = 1
for i, start in enumerate(page_offsets):
if start <= offset:
page = i + 1
else:
break
return page
def _ocr_with_google_vision(image_bytes: bytes, page_num: int) -> str:
"""OCR a single page image using Google Cloud Vision API."""
from google.cloud import vision # lazy: keeps MCP startup fast
client = _get_vision_client()
image = vision.Image(content=image_bytes)
response = client.document_text_detection(
image=image,
image_context=vision.ImageContext(language_hints=["he"]),
)
if response.error.message:
raise RuntimeError(
f"Google Vision error on page {page_num}: {response.error.message}"
)
text = response.full_text_annotation.text if response.full_text_annotation else ""
return _fix_hebrew_quotes(text)
# ── Non-PDF formats ───────────────────────────────────────────────
def _extract_doc(path: Path) -> str:
"""Extract text from legacy .doc file by converting to .docx via LibreOffice."""
"""Extract text from legacy .doc via LibreOffice → DOCX conversion."""
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
@@ -296,13 +327,13 @@ def _extract_rtf(path: Path) -> str:
# ── Multimodal page rendering (V9) ───────────────────────────────
# Unchanged — multimodal embedding always uses PyMuPDF-rendered images
# regardless of whether text extraction used PyMuPDF or Mistral.
def _pixmap_to_pil(pix: fitz.Pixmap) -> Image.Image:
"""Convert a PyMuPDF pixmap to PIL.Image (RGB) without going through
PNG bytes. Faster than tobytes('png') → Image.open()."""
"""Convert a PyMuPDF pixmap to PIL.Image (RGB)."""
if pix.alpha:
# Drop alpha channel — voyage multimodal expects RGB.
pix = fitz.Pixmap(pix, 0)
return Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
@@ -314,12 +345,9 @@ def render_pages_for_multimodal(
thumbnail_dir: Path | None = None,
) -> list[tuple[Image.Image, Path | None]]:
"""Render each PDF page as PIL.Image at ``embed_dpi`` for the
multimodal embedder, and optionally save a smaller JPEG thumbnail
at ``thumb_dpi`` to ``thumbnail_dir`` for UI preview.
multimodal embedder, and optionally save JPEG thumbnails.
Returns ``[(pil_image, thumb_path_or_None), ...]`` in page order.
The full-DPI image stays in memory only — only the thumbnail is
persisted to disk.
"""
src = Path(pdf_path)
if not src.is_file():
@@ -338,17 +366,12 @@ def render_pages_for_multimodal(
thumb_path: Path | None = None
if thumbnail_dir is not None and thumb_dpi:
thumb_path = thumbnail_dir / f"p{page_num:03d}.jpg"
# Downsample the same render rather than re-rendering
# with PyMuPDF — far faster.
ratio = thumb_dpi / embed_dpi
thumb_size = (
max(1, int(img.width * ratio)),
max(1, int(img.height * ratio)),
)
thumb = img.resize(thumb_size, Image.Resampling.LANCZOS)
# Persist the thumbnail (a DERIVED, regenerable artifact)
# through the storage layer (INV-STG1). Under the filesystem
# backend it lands at thumb_path exactly as before.
_tbuf = io.BytesIO()
thumb.save(_tbuf, "JPEG", quality=75, optimize=True)
try:
@@ -366,44 +389,28 @@ def render_pages_for_multimodal(
return out
# ── Nevo preamble stripping ──────────────────────────────────────
# ── Nevo preamble stripping ──────────────────────────────────────
_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(
r"^[ \t>*_#]{0,6}(?:"
r"בפנינו|לפנינו|לפניי|הערר שבנדון|ועדת הערר לתכנון|רקע עובדתי|עסקינן|"
r"פסק[ \t\-]{0,3}די(?:ן|נו)|" # פסק-דין / פסק דין / **פסק דין** header (final-nun ן vs דינו)
r"(?:כב(?:וד)?['׳\"]?\s*)?(?:ה?שופט[ת]?|ה?נשיא[ה]?|המשנה לנשיא)\s+[^\n,]{1,40}:" # author line → colon
r"פסק[ \t\-]{0,3}די(?:ן|נו)|"
r"(?:כב(?:וד)?['׳\"]?\s*)?(?:ה?שופט[ת]?|ה?נשיא[ה]?|המשנה לנשיא)\s+[^\n,]{1,40}:"
r")",
re.MULTILINE,
)
def strip_nevo_preamble(text: str) -> str:
"""Remove Nevo database preamble (bibliography, legislation, mini-ratio) from decision text.
"""Remove Nevo database preamble (bibliography, legislation, mini-ratio).
Returns the original text unchanged if no preamble is detected.
Works on both plain text (PyMuPDF) and Markdown (Mistral) since
_DECISION_START already tolerates leading ``[ \t>*_#]{0,6}``.
"""
# 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):
return text
@@ -419,17 +426,7 @@ _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.
"""
"""Return the Nevo מיני-רציו block (editorial holdings summary), or ''."""
if not text:
return ""
start = text.find(_RATIO_MARKER)
@@ -437,9 +434,6 @@ def extract_nevo_ratio(text: str) -> str:
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:

View File

@@ -233,6 +233,17 @@ async def ingest_document(
await db.request_metadata_extraction(case_law_id)
await db.request_halacha_extraction(case_law_id)
await db.recompute_searchable(case_law_id)
# Citations resolve to a corpus row only at extraction time. A ruling
# uploaded AFTER it was first cited leaves orphan edges (NULL link),
# so re-link them to this freshly-ingested row now — the citation
# graph self-heals instead of permanently under-counting (G1). Non-fatal.
try:
from legal_mcp.services import citation_extractor as _ce
relinked = await _ce.relink_orphan_citations(case_law_id)
if relinked:
logger.info("relinked %d orphan citation(s) -> %s", relinked, case_law_id)
except Exception as e: # noqa: BLE001 — relink is best-effort
logger.warning("citation relink failed (non-fatal): %s", e)
await progress("completed", 100,
f"נקלט: {stored_chunks} chunks. חילוץ הלכות ומטא-דאטה ממתינים בתור.")

View File

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

View File

@@ -0,0 +1,256 @@
"""Decision-lesson synthesis (#158 / INV-LRN8).
The learning channels (panel, curator, chair) accumulate overlapping ``decision_lessons``
on the same style dimension. The writer only consumes the 15 most-recent APPROVED ones
per practice_area (a silent cap, #157), so beyond that lessons pile up unused. This pass
**clusters** near-duplicate lessons within a (practice_area, category) shard and **merges**
each cluster into ONE richer, generalised "super-lesson" — so the writer-fed set shrinks
to a small, high-quality profile (Authorial Style Profiling, ספ §0.1) and the cap stops
biting.
Mirrors the canonical-halacha synthesis (V41 / INV-LRN6) on the SAME table (no parallel
store, G2). Invariants:
• INV-AH — the super-lesson is GROUNDED in the source lessons only; the model abstains
rather than invent, and a re-embedding DRIFT guard rejects a rewrite that
drifts from the cluster centroid.
• INV-LRN1/G10 — graduated gate (chair decision 2026-06-28): since every source is
already ``approved``, the super-lesson flows as ``approved`` (reversible —
chair veto in /training restores the sources). A non-approved source ⇒ proposed.
• G2 — single synthesis path; the MCP tool and the backfill script both call
:func:`run_shard` / :func:`synthesize_cluster` here.
• G9 — every outcome (accepted / abstained / drift_rejected / merged-duplicate) returned.
LLM calls go through ``claude_session`` (local ``claude -p`` CLI) only — never from the
FastAPI container (see claude_session docstring).
"""
from __future__ import annotations
import logging
import math
from uuid import UUID
from legal_mcp import config
from legal_mcp.services import claude_session, db, embeddings
logger = logging.getLogger(__name__)
_SYSTEM = (
"אתה עורך-דין בכיר המזקק כללי-סגנון-וכתיבה לבסיס-ידע של ועדת ערר לתכנון ובנייה. "
"תפקידך למזג כמה לקחי-סגנון חופפים לכלל אחד, כללי ומדויק, על *איך* כותבים — לא להמציא "
"כלל חדש ולא להוסיף מהות משפטית."
)
def _cosine(a, b) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
if na == 0 or nb == 0:
return 0.0
return dot / (na * nb)
def _centroid(vecs: list[list[float]]) -> list[float]:
n = len(vecs)
dim = len(vecs[0])
return [sum(v[i] for v in vecs) / n for i in range(dim)]
def _build_prompt(members: list[dict]) -> str:
blocks = []
for i, m in enumerate(members, 1):
blocks.append(f"### לקח {i}\n{m['lesson_text']}")
evidence = "\n\n".join(blocks)
return f"""{_SYSTEM}
לקחי-המקור (כולם מאותו תחום וקטגוריה, חופפים בנושא):
{evidence}
## המשימה
מזג את לקחי-המקור לכלל-סגנון **אחד** עשיר ומוכלל המשותף לכולם. שמר כל ניואנס מובחן שמופיע
באחד הלקחים, אך נסח אותו פעם אחת, נקי וכללי.
## כללים מחייבים (INV-AH — עיגון, ללא הזיה)
1. **עיגון-מקור בלבד.** הכלל חייב לנבוע מלקחי-המקור שלמעלה. אסור להוסיף כלל, חריג או דוגמה שאינם עולים מהם.
2. **סגנון/שיטה בלבד, לא מהות.** אל תכניס הלכה, עובדה, מספר-תיק או תקדים ספציפי — רק *איך* דפנה כותבת.
3. **כללי ובלתי-תלוי-תיק.** הסר פרטים קונקרטיים; נסח כלל רב-תחולה.
4. **רגיסטר נקי** בעברית, משפט אחד עד שלושה, בלי מילות-מסגרת ("יש לזכור ש...") — רק הכלל עצמו.
5. **הימנעות עדיפה על המצאה.** אם הלקחים אינם באמת מתמזגים לכלל אחד מעוגן — החזר grounded=false.
## פלט — JSON בלבד, ללא markdown וללא הסבר:
{{
"lesson_text": "<כלל-הסגנון הממוזג>",
"grounded": true,
"reason": "<משפט קצר: מה אוחד>"
}}"""
def _greedy_clusters(candidates: list[dict], threshold: float) -> list[list[dict]]:
"""Greedy single-link clustering by cosine over candidate embeddings. Each candidate
has an 'embedding' (python list). Returns clusters of size ≥2 only (singletons are
nothing to merge)."""
remaining = [c for c in candidates if c.get("embedding") is not None]
clusters: list[list[dict]] = []
used: set = set()
for i, seed in enumerate(remaining):
if seed["id"] in used:
continue
cluster = [seed]
used.add(seed["id"])
for other in remaining[i + 1:]:
if other["id"] in used:
continue
if _cosine(seed["embedding"], other["embedding"]) >= threshold:
cluster.append(other)
used.add(other["id"])
if len(cluster) >= 2:
clusters.append(cluster)
return clusters
async def _ensure_embeddings(candidates: list[dict]) -> list[dict]:
"""Lazy-backfill: embed any candidate whose stored embedding is NULL, persist it,
and return the candidates with embeddings populated (skips ones that still fail)."""
missing = [c for c in candidates if c.get("embedding") is None]
if missing:
vecs = await embeddings.embed_texts([c["lesson_text"] for c in missing])
for c, v in zip(missing, vecs):
c["embedding"] = list(v)
await db.set_lesson_embedding(c["id"], c["embedding"])
return [c for c in candidates if c.get("embedding") is not None]
async def synthesize_cluster(
members: list[dict],
*,
model: str | None = None,
effort: str | None = None,
drift_floor: float | None = None,
) -> dict:
"""Merge one cluster of lessons. PURE — no DB writes. Returns:
{status, proposed, embedding, members:[ids], drift_cosine, reason}
status ∈ {accepted, abstained, drift_rejected, llm_error, too_small}.
"""
model = model or config.LESSON_SYNTH_MODEL
effort = effort or config.LESSON_SYNTH_EFFORT
drift_floor = config.LESSON_SYNTH_DRIFT_FLOOR if drift_floor is None else drift_floor
ids = [str(m["id"]) for m in members]
base = {"members": ids, "proposed": "", "embedding": None,
"drift_cosine": None, "reason": ""}
if len(members) < 2:
return {**base, "status": "too_small", "reason": "cluster < 2"}
try:
result = await claude_session.query_json(
_build_prompt(members), model=model, effort=effort, tools="",
)
except Exception as e:
logger.warning("synthesize_cluster %s: LLM error: %s", ids, e)
return {**base, "status": "llm_error", "reason": str(e)}
if not isinstance(result, dict) or not result.get("lesson_text"):
return {**base, "status": "llm_error", "reason": "malformed LLM output"}
if not result.get("grounded", True):
return {**base, "status": "abstained",
"reason": result.get("reason") or "model abstained (not grounded)"}
proposed = str(result["lesson_text"]).strip()
if not proposed:
return {**base, "status": "abstained", "reason": "empty proposal"}
# Drift guard: the merged lesson must stay near the cluster centroid.
new_emb = list((await embeddings.embed_texts([proposed]))[0])
centroid = _centroid([m["embedding"] for m in members])
drift = _cosine(new_emb, centroid)
if drift < drift_floor:
return {**base, "status": "drift_rejected", "proposed": proposed,
"drift_cosine": round(drift, 4),
"reason": f"drift {drift:.3f} < floor {drift_floor}"}
return {**base, "status": "accepted", "proposed": proposed, "embedding": new_emb,
"drift_cosine": round(drift, 4), "reason": result.get("reason") or "merged"}
async def run_shard(
practice_area: str,
category: str,
*,
apply: bool,
model: str | None = None,
effort: str | None = None,
drift_floor: float | None = None,
cluster_threshold: float | None = None,
) -> dict:
"""Synthesize all clusters in one (practice_area, category) shard.
Returns {practice_area, category, candidates, clusters:[result...]}. Each result is
a synthesize_cluster outcome augmented with ``applied`` and (when applied) ``new_id``.
With apply=False this is a pure dry-run (no writes beyond lazy embedding backfill).
"""
cluster_threshold = (config.LESSON_SYNTH_CLUSTER_THRESHOLD
if cluster_threshold is None else cluster_threshold)
candidates = await db.fetch_synthesis_candidates(practice_area, category)
candidates = await _ensure_embeddings(candidates)
clusters = _greedy_clusters(candidates, cluster_threshold)
results = []
for members in clusters:
res = await synthesize_cluster(
members, model=model, effort=effort, drift_floor=drift_floor,
)
res["applied"] = False
if apply and res["status"] == "accepted":
# Idempotency: skip if a near-identical synthesis already exists (re-run safe).
dup = await db.nearest_synthesis_lesson(
res["embedding"], category, config.HALACHA_CANONICAL_THRESHOLD,
)
if dup:
res["status"] = "duplicate_skipped"
res["reason"] = f"near existing synthesis {dup[0]} (sim {dup[1]:.3f})"
else:
# graduated gate: all sources are approved (fetch filter) → approved.
row = await db.apply_lesson_synthesis(
corpus_id=members[0]["style_corpus_id"],
lesson_text=res["proposed"],
category=category,
embedding=res["embedding"],
source_ids=[m["id"] for m in members],
review_status="approved",
)
res["applied"] = True
res["new_id"] = str(row.get("id", ""))
results.append(res)
return {"practice_area": practice_area or "*", "category": category,
"candidates": len(candidates), "clusters": results}
async def run_pending(
practice_area: str = "",
category: str = "",
*,
apply: bool,
model: str | None = None,
effort: str | None = None,
drift_floor: float | None = None,
cluster_threshold: float | None = None,
) -> list[dict]:
"""Run synthesis across shards. If practice_area+category are given, one shard;
otherwise iterate every shard with ≥2 live approved lessons. Single entry point (G2)."""
if practice_area and category:
shards = [{"practice_area": practice_area, "category": category}]
else:
shards = await db.synthesis_shards(min_size=2)
if practice_area:
shards = [s for s in shards if s["practice_area"] == practice_area]
if category:
shards = [s for s in shards if s["category"] == category]
out = []
for s in shards:
out.append(await run_shard(
s["practice_area"], s["category"], apply=apply,
model=model, effort=effort, drift_floor=drift_floor,
cluster_threshold=cluster_threshold,
))
return out

View File

@@ -0,0 +1,208 @@
"""סיכום-מנהלים של טענות הצדדים — מסמך פרוזה מזוקק להכנת היו"ר לדיון (WS3, #202).
מסמך זה הוא **נפרד ומובחן מטיוטת-הביניים** (החלטת-יו"ר, תוכנית workflow-redesign §WS3) —
תמצית מנהלים קצרה ומוקפדת של טענות הצדדים והתשובות זו-לזו, להכנה לדיון בעל-פה. אין הוא
משכפל את כתבי-הטענות המלאים: הוא מזקק.
מקור-האמת היחיד (G2): הוא נגזר מ-``legal_arguments`` (טיעונים מאוגדים) ומ-``claims``
הקיימים בתיק — לא מחלץ-מחדש ולא קורא לכתבי-הטענות הגולמיים. אם הטיעונים המאוגדים טרם
חושבו, הוא נופל-חזרה ל-claims הגולמיים (אותו מקור).
הייצור עובר ``claude_session.query`` → ``claude -p`` (CLI, מנוי claude.ai, עלות-שולית-אפס),
נעוץ ל-Opus 4.8 + effort=high — משימת זיקוק/סינתזה. **local-only**: לקונטיינר אין claude CLI;
לכן הכלי רץ רק מ-MCP-server המקומי, וה-endpoint ב-app.py רק מגיש את הקובץ השמור (לא מייצר).
ההזהרה נגד-הזיה (INV-AH): המסמך מתוחם לחלוטין לטענות-התיק עצמן. אסור להמציא טענה, הלכה
או עובדה שאינה מופיעה בקלט; טענה שאינה ברורה — מצוין במפורש, לא משלים.
"""
from __future__ import annotations
import logging
from datetime import date
from pathlib import Path
from uuid import UUID
from legal_mcp import config
from legal_mcp.services import (
argument_aggregator,
block_writer,
claude_session,
db,
git_sync,
storage,
)
logger = logging.getLogger(__name__)
# Saved alongside the legal analysis (same research folder, same git+S3 mirror path).
_SUMMARY_FILENAME = "party-claims-summary.md"
_PARTY_HE = {
"appellant": "העוררים",
"respondent": "המשיבים",
"committee": "הוועדה המקומית",
"permit_applicant": "מבקשי ההיתר",
"unknown": "צד לא מזוהה",
}
def summary_file_path(case_number: str) -> Path:
"""Resolve the saved party-claims-summary.md path for a case."""
return config.find_case_dir(case_number) / "documents" / "research" / _SUMMARY_FILENAME
async def _build_arguments_context(case_id: UUID) -> tuple[str, int, str]:
"""Build the grounded source block from the case's OWN claims/arguments.
Preference order (single source of truth, no re-extraction — G2):
1. ``legal_arguments`` — the aggregated, de-duplicated arguments per party.
2. fallback to raw ``claims`` when arguments were never aggregated.
Returns ``(context_text, item_count, source_kind)`` where ``source_kind`` is
``"arguments"`` or ``"claims"`` (or ``"none"`` when neither exists).
"""
args = await argument_aggregator.get_legal_arguments(case_id)
if args:
by_party: dict[str, list[dict]] = {}
for a in args:
by_party.setdefault(a["party"], []).append(a)
lines: list[str] = []
for party, items in by_party.items():
lines.append(f"\n## {_PARTY_HE.get(party, party)}")
for a in items:
title = (a.get("argument_title") or "").strip()
body = (a.get("argument_body") or "").strip()
topic = (a.get("legal_topic") or "").strip()
header = f"- **{title}**" if title else "-"
if topic:
header += f" [{topic}]"
lines.append(header)
if body:
lines.append(f" {body}")
return "\n".join(lines), len(args), "arguments"
# Fallback — raw claims, the same underlying source.
claims = await db.get_claims(case_id)
# Exclude block-zayin (decision-summary) claims — original pleadings only.
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims
if not source_claims:
return "", 0, "none"
lines = []
current_role = ""
role_he = {"appellant": "טענות העוררים", "respondent": "טענות המשיבים",
"committee": "עמדת הוועדה המקומית", "permit_applicant": "עמדת מבקשי ההיתר"}
n = 0
for c in source_claims:
if c["party_role"] != current_role:
current_role = c["party_role"]
lines.append(f"\n## {role_he.get(current_role, current_role)}")
n += 1
lines.append(f"- טענה #{n}: {c['claim_text']}")
return "\n".join(lines), len(source_claims), "claims"
_PROMPT_TEMPLATE = """אתה מכין עבור יו"ר ועדת הערר (עו"ד דפנה תמיר) **סיכום-מנהלים מזוקק של טענות הצדדים** — מסמך פרוזה קצר שמטרתו אחת: להכין את היו"ר לדיון בעל-פה. זהו מסמך **נפרד** מטיוטת-ההחלטה ואינו חלק ממנה.
## פרטי התיק:
{case_context}
## הקלט — טענות/טיעוני הצדדים (מקור-האמת היחיד):
{arguments_context}
## כללי-כתיבה מחייבים:
- **זיקוק, לא שכפול.** אל תעתיק את כתבי-הטענות. תמצת כל צד ל-25 משפטי-מפתח. היעד: עמוד אחד עד שניים, לא יותר.
- **מבוסס-קלט בלבד (חובה אנטי-הזיה).** הסתמך אך-ורק על הטענות שבקלט לעיל. אסור להמציא טענה, הלכה, פסק-דין, מספר או עובדה שאינם בקלט. טענה שאינה ברורה או חסרה — ציין זאת במפורש ("הטענה לא פורטה"), אל תשלים מדמיונך.
- **ניטרלי.** הצג את עמדת כל צד בנאמנות, בלי להעריך, בלי להכריע ובלי לרמוז על תוצאה. אין זו החלטה — זו הכנה.
- **מבנה קבוע:**
1. פסקת-פתיחה קצרה (משפט–שניים): במה עוסק הערר ומיהם הצדדים.
2. **טענות העוררים** — תמצית מזוקקת.
3. **טענות המשיבים / הוועדה / מבקשי ההיתר** — תמצית מזוקקת (כל צד שקיים בקלט).
4. **נקודות-המחלוקת המרכזיות** — 24 הסוגיות שעליהן ניצבת ההכרעה, כשאלות פתוחות (לא תשובות).
- **סגנון:** עברית משפטית בהירה, גוף-שלישי לתיאור הצדדים. בלי כותרת "החלטה". בלי חתימות.
## מדריך-סגנון (לטון בלבד — אל תיישם מבנה-החלטה):
{style_context}
החזר אך-ורק את גוף מסמך הסיכום (Markdown), בלי הקדמות ובלי הסברים על מה שעשית."""
async def summarize_party_claims(case_id: UUID, case_number: str,
instructions: str = "") -> dict:
"""Generate the distilled executive summary of party claims and save it.
Grounded strictly in the case's own claims/legal_arguments (INV-AH).
Generation is pinned to Opus 4.8 + effort=high (distillation/synthesis).
"""
case = await db.get_case(case_id)
if not case:
raise ValueError(f"Case {case_id} not found")
decision = await db.get_decision_by_case(case_id)
arguments_context, n_items, source_kind = await _build_arguments_context(case_id)
if source_kind == "none":
raise ValueError(
"אין טענות לסכם. הרץ extract_claims (ורצוי aggregate_claims_to_arguments) קודם."
)
case_context = block_writer._build_case_context(case, decision)
style_context = await block_writer._build_style_context(case.get("practice_area", ""))
prompt = _PROMPT_TEMPLATE.format(
case_context=case_context,
arguments_context=arguments_context,
style_context=style_context,
)
if instructions:
prompt += f"\n\n## הנחיות נוספות מהיו\"ר:\n{instructions}"
# Generation: claude_session → claude -p, pinned Opus 4.8 + high effort.
# tools="" — pure prose, no tool_use (avoids error_max_turns). LONG_TIMEOUT:
# full-case context can be large.
content = await claude_session.query(
prompt,
timeout=claude_session.LONG_TIMEOUT,
model="claude-opus-4-8",
effort="high",
tools="",
)
content = (content or "").strip()
if not content:
raise ValueError("הסיכום חזר ריק מ-claude -p.")
today = date.today().strftime("%d.%m.%Y")
header = (
f"# סיכום-מנהלים — טענות הצדדים\n\n"
f"**תיק:** {case['case_number']} \n"
f"**נושא:** {case.get('subject', '')} \n"
f"**הופק:** {today} · מסמך הכנה לדיון (נפרד מטיוטת-ההחלטה)\n\n"
f"---\n\n"
)
document = header + content + "\n"
# Save to data/cases/{n}/documents/research/ — same path as the legal analysis,
# so it is git-synced + S3-mirrored by the existing machinery.
out_path = summary_file_path(case_number)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(document, encoding="utf-8") # noqa: STG1 — mirrored below
try:
key = out_path.resolve().relative_to(Path(config.DATA_DIR).resolve()).as_posix()
await storage.mirror(key, document.encode("utf-8"), bucket=storage.Bucket.DOCUMENTS)
except ValueError:
pass
case_dir = config.find_case_dir(case_number)
if case_dir.exists():
git_sync.commit_and_push(case_dir, f"סיכום-מנהלים: טענות הצדדים ({case_number})")
return {
"case_number": case_number,
"path": str(out_path),
"source_kind": source_kind,
"source_items": n_items,
"word_count": len(content.split()),
"message": f"סיכום-מנהלים נוצר ({len(content.split())} מילים, מקור: {source_kind}).",
}

View File

@@ -68,7 +68,11 @@ def _external_staging_subdir(inputs: dict) -> str:
async def _create_external_record(**kw) -> dict:
"""Adapter: maps canonical inputs (citation) to create_external_case_law(case_number)."""
"""Adapter: maps canonical inputs (citation) to create_external_case_law(case_number).
The chair's typed citation seeds ``citation_formatted`` so the מראה-מקום is never
blank before metadata extraction; the inline enrichment upgrades it to the
canonical form (see ``ingest_precedent``)."""
return await db.create_external_case_law(
case_number=kw["citation"].strip(),
case_name=kw["case_name"],
@@ -84,6 +88,7 @@ async def _create_external_record(**kw) -> dict:
precedent_level=kw.get("precedent_level", ""),
is_binding=kw.get("is_binding", True),
document_id=kw.get("document_id"),
citation_formatted=kw["citation"].strip(),
)
@@ -126,10 +131,28 @@ async def ingest_precedent(
"appeal_subtype": appeal_subtype, "subject_tags": subject_tags,
"is_binding": is_binding, "headnote": headnote, "summary": summary,
}
return await ingest.ingest_document(
result = await ingest.ingest_document(
_EXTERNAL_SPEC, inputs=inputs, file_path=file_path,
document_id=document_id, progress=progress,
)
# Inline metadata enrichment (Gemini, in-container — gemini_session is direct
# REST, no local CLI). UPGRADES the seeded provisional citation_formatted to the
# canonical derived form (parties + reporter + date) so the מראה-מקום is correct
# the moment the chair opens /precedents/[id] — not deferred to the local drainer.
# force_citation=True overwrites the seed only; chair edits aren't reachable yet
# (row just created). Best-effort: on no key / API failure the seed remains and
# the queued metadata drain (request_metadata_extraction, already set) is the
# fallback. Halacha extraction is NOT touched here — it stays local (claude_session).
cid = result.get("case_law_id") if isinstance(result, dict) else None
if cid:
try:
from legal_mcp.services import precedent_metadata_extractor as _pme
r = await _pme.extract_and_apply(UUID(str(cid)), force_citation=True)
if r.get("status") == "completed":
await db.set_case_law_metadata_status(UUID(str(cid)), "completed")
except Exception as e: # noqa: BLE001 — enrichment is best-effort; drainer is fallback
logger.warning("inline metadata enrichment failed for %s (drainer will retry): %s", cid, e)
return result
async def reextract_halachot(
@@ -360,7 +383,10 @@ async def reextract_metadata(
appeal_subtype, and case_name when it equals the citation). User
values are preserved.
**MCP-tool-only path** — same constraint as :func:`reextract_halachot`.
**Container-safe** — unlike :func:`reextract_halachot` (claude CLI, host-only),
metadata extraction runs on Gemini Flash over REST (GOOGLE_GEMINI_API_KEY), so
this path is callable from the FastAPI container too. The final-decision
enrollment loop (``_enroll_final_in_library``) calls it inline on upload.
"""
from legal_mcp.services import precedent_metadata_extractor
@@ -411,6 +437,37 @@ async def get_precedent(case_law_id: UUID | str) -> dict | None:
return None
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
record["related_cases"] = await db.get_case_law_relations(case_law_id)
# Auto-detected citation graph: which decisions cite THIS ruling (incoming
# edges in precedent_internal_citations). Reuses the canonical query (G2) —
# no parallel resolution path. Shaped to match the front-end RelatedCase row
# so the existing "ציטוטים מקושרים" panel renders them with no visual change.
from legal_mcp.services import citation_extractor as _ce
raw = await _ce.list_citations_to_case_law(case_law_id)
record["incoming_citations"] = [
{
"id": r["source_case_law_id"],
"case_number": r.get("source_case_number") or r.get("cited_case_number") or "",
"case_name": r.get("source_case_name") or "",
"court": r.get("source_court") or "",
"precedent_level": r.get("source_precedent_level") or "",
"chair_name": r.get("source_chair_name") or "",
"date": (
r["source_date"].isoformat()
if r.get("source_date") is not None else None
),
"treatment": r.get("treatment") or "",
"confidence": r.get("confidence"),
}
for r in raw
]
# Authority signal (X11 Phase 2, #154): how the citing committee decisions
# TREATED this ruling (followed/distinguished/…) — surfaced so the chair (and
# research agents via the tool output) can argue authority and avoid leaning on
# a repeatedly-distinguished precedent.
record["cited_by"] = (await db.citation_authority([case_law_id])).get(
str(case_law_id),
{"total": 0, "positive": 0, "negative": 0, "unclassified": 0, "by_treatment": {}},
)
return record

View File

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

View File

@@ -0,0 +1,400 @@
"""ניתוח פרוטוקול-דיון השוואתי (WS4 / #203).
מנתח פרוטוקול דיון של ועדת הערר מול כתבי-הטענות הכתובים: אילו טענות **ירדו**
(נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות** — ומחדד את השאלות המשפטיות
לקראת פרק הדיון. התוצאה נכנסת ל"ידע-התיק" (טבלת protocol_analysis), זמינה
לסוכני הניתוח והכתיבה.
בנוסף, מחלץ את **נתוני הכותרת (א–ד)** מהפרוטוקול בפורמט-ההזנה המוכר —
הרכב הוועדה, תאריך הדיון, והצדדים שהופיעו — ומזין אותם חזרה לעמודות הקנוניות
(`cases.hearing_date`, panel דרך decisions; G2: לא כפילות).
הפרדת-אחריות (claude_session.py): כל קריאת-LLM כאן רצה רק מה-MCP server המקומי
(אין claude CLI בקונטיינר). הקריאה היחידה היא דרך claude_session, מעוגנת ל-
Opus 4.8 + effort=high כמתבקש בניתוח-השוואתי (reference_claude_generation_path).
"""
from __future__ import annotations
import json
import logging
from uuid import UUID
from legal_mcp.services import claude_session, db
logger = logging.getLogger(__name__)
# Generation pinned per reference_claude_generation_path: Opus 4.8 rejects
# temperature/top_p/top_k (400) — the only knob is effort. Comparative legal
# reasoning over the protocol vs. pleadings is high-effort.
ANALYSIS_MODEL = "claude-opus-4-8"
ANALYSIS_EFFORT = "high"
HEADER_EFFORT = "medium" # א–ד is mechanical extraction, not reasoning
# Valid change verdicts — mirror the DB CHECK on protocol_analysis.change_type.
VALID_CHANGE_TYPES = {"dropped", "strengthened", "newly_raised"}
VALID_PARTY_ROLES = {"appellant", "respondent", "committee", "permit_applicant", ""}
PARTY_LABELS_HE = {
"appellant": "עוררים",
"respondent": "משיבים",
"committee": "ועדה מקומית",
"permit_applicant": "מבקשי היתר",
}
# ── Comparative analysis: pleadings vs. protocol ──────────────────
COMPARE_PROMPT = """אתה מנתח משפטי בכיר בועדת ערר לתכנון ובנייה. לפניך **פרוטוקול דיון**
ולצדו **הטיעונים המשפטיים שהוגשו בכתב** לפני הדיון. תפקידך: ניתוח השוואתי מדויק
של מה שקרה בדיון ביחס לכתבי-הטענות.
## מה לזהות (לכל צד בנפרד):
1. **dropped (ירדה)** — טענה שהוגשה בכתב אך הצד **זנח** אותה בדיון: לא חזר עליה,
הודה שאינה עומדת, או ויתר עליה במפורש.
2. **strengthened (חוזקה)** — טענה כתובה שהצד **חיזק** בדיון: הוסיף נימוק, אסמכתא,
הבהרה, או דגש שהפך אותה למרכזית.
3. **newly_raised (חדשה)** — טענה/סוגיה ש**עלתה לראשונה בדיון** ולא הופיעה בכתבי
הטענות כלל.
## כללים קריטיים (anti-hallucination — חובה):
- **ציטוט-או-הימנעות:** לכל קביעה חייב להיות `evidence_quote` — ציטוט **מילולי**
מהפרוטוקול (עד 200 תווים) שמבסס אותה. אם אין ציטוט תומך — אל תכלול את הרשומה.
- אל תמציא טענות שלא נאמרו. אל תסיק "ירדה" רק כי לא הוזכרה — רק אם יש ראיה פוזיטיבית
לזניחה/ויתור, או שהיא טענה כתובה מרכזית שכלל לא עלתה והצד עסק בנושא.
- **חידוד-השאלה:** לכל רשומה, נסח את `sharpened_question` — השאלה המשפטית הממוקדת
שהשינוי בדיון מחדד (לטובת פרק הדיון). אם לא רלוונטי — השאר ריק.
- שייך כל רשומה ל-`party_role` הנכון: appellant / respondent / committee / permit_applicant.
- אם רשומה תואמת טיעון כתוב קיים — החזר את ה-`argument_id` שלו מהרשימה למטה. לטענה חדשה
(newly_raised) — argument_id ריק.
## פלט:
החזר JSON array בלבד — ללא markdown, ללא הסברים:
[
{{
"change_type": "dropped" | "strengthened" | "newly_raised",
"party_role": "appellant" | "respondent" | "committee" | "permit_applicant",
"argument_id": "uuid-של-הטיעון-הכתוב או null",
"argument_title": "כותרת קצרה של הטענה/הסוגיה",
"summary": "מה השתנה בדיון, במשפט-שניים",
"sharpened_question": "השאלה המשפטית שהשינוי מחדד, או ריק",
"evidence_quote": "ציטוט מילולי מהפרוטוקול",
"page_number": null
}}
]
אם אין שינויים בני-ביסוס — החזר [].
## הטיעונים שהוגשו בכתב:
{arguments_json}
## פרוטוקול הדיון:
--- תחילת פרוטוקול ---
{protocol_text}
--- סוף פרוטוקול ---
"""
# ── Header (א–ד) extraction in the known feed format ──────────────
HEADER_PROMPT = """אתה מחלץ נתוני-כותרת מפרוטוקול דיון של ועדת ערר לתכנון ובנייה.
חלץ אך-ורק עובדות מנהליות המופיעות במפורש בפרוטוקול — אל תמציא ואל תסיק.
## פלט:
החזר JSON object בלבד (ללא markdown):
{{
"hearing_date": "YYYY-MM-DD אם תאריך הדיון מופיע, אחרת ריק",
"panel_members": ["שמות חברי ההרכב כפי שמופיעים, כולל תוארם; ריק אם לא צוין"],
"appellants_present": ["שמות העוררים/באי-כוחם שהופיעו בדיון"],
"respondents_present": ["שמות המשיבים/באי-כוחם שהופיעו בדיון"]
}}
שדה שלא צוין בפרוטוקול — החזר ריק ([] או "").
## פרוטוקול:
--- תחילת פרוטוקול ---
{protocol_text}
--- סוף פרוטוקול ---
"""
# A single protocol rarely exceeds the model's context, but cap defensively so a
# pathological OCR dump doesn't blow the prompt budget.
MAX_PROTOCOL_CHARS = 120_000
def _protocol_scope(doc: dict) -> str:
"""Protocol scope from ``metadata.protocol_scope``.
Absent/'' defaults to ``'appeal'`` (ועדת הערר), matching the
document-type-editor convention where only the non-default ``'lower'``
(ועדה מקומית/מחוזית) is persisted.
"""
meta = doc.get("metadata") or {}
return (meta.get("protocol_scope") or "").strip() or "appeal"
def _find_protocol(
docs: list[dict], document_id: UUID | None = None,
) -> dict | None:
"""The ועדת-הערר hearing protocol to compare against the pleadings.
- ``document_id`` given → return exactly that document (explicit target),
so a case with several protocols can be pointed at the right hearing.
- Otherwise prefer a protocol scoped to the appeals committee
(``scope != 'lower'``). A ``'lower'`` protocol is the local/district
committee's proceedings — it feeds background (block ו) only and must NOT
drive the hearing-vs-pleadings comparison (#223). If every protocol is
lower-scoped there is no ערר-hearing to compare → return None.
- Among appeal-scoped protocols the most recent (by ``created_at``) wins —
the operative hearing; ties fall back to list order.
"""
if document_id is not None:
return next(
(d for d in docs if str(d.get("id")) == str(document_id)), None,
)
protocols = [d for d in docs if d.get("doc_type") == "protocol"]
if not protocols:
# Legacy fallback: untyped docs whose title says "פרוטוקול".
protocols = [d for d in docs if "פרוטוקול" in (d.get("title") or "")]
appeal_scoped = [d for d in protocols if _protocol_scope(d) != "lower"]
if not appeal_scoped:
return None
appeal_scoped.sort(key=lambda d: d.get("created_at") or "", reverse=True)
return appeal_scoped[0]
def _compact_arguments(arguments: list[dict]) -> list[dict]:
"""Strip aggregated arguments down to what the comparison needs."""
out = []
for a in arguments:
out.append({
"argument_id": str(a["id"]),
"party_role": a.get("party", ""),
"title": a.get("argument_title", ""),
"body": a.get("argument_body", ""),
"topic": a.get("legal_topic", ""),
})
return out
def _normalize_change(raw: dict, valid_argument_ids: set[str]) -> dict | None:
"""Validate & normalize one comparative-analysis row from Claude.
Returns None for unusable rows (missing change_type/summary, or — per the
anti-hallucination gate — no supporting evidence_quote).
"""
if not isinstance(raw, dict):
return None
change_type = (raw.get("change_type") or "").strip()
if change_type not in VALID_CHANGE_TYPES:
return None
summary = (raw.get("summary") or "").strip()
evidence_quote = (raw.get("evidence_quote") or "").strip()
# INV-AH (quote-or-retract): drop any verdict the model couldn't anchor to a
# verbatim protocol excerpt — at source, so unfounded rows never reach the DB.
if not summary or not evidence_quote:
return None
party_role = (raw.get("party_role") or "").strip()
if party_role not in VALID_PARTY_ROLES:
party_role = ""
# argument_id only kept when it actually belongs to this case's arguments;
# a newly_raised point has none, and a hallucinated id is dropped (FK safety).
argument_id = None
raw_aid = raw.get("argument_id")
if raw_aid and str(raw_aid) in valid_argument_ids:
try:
argument_id = UUID(str(raw_aid))
except (ValueError, TypeError):
argument_id = None
page = raw.get("page_number")
if not isinstance(page, int):
page = None
return {
"change_type": change_type,
"party_role": party_role,
"argument_id": argument_id,
"argument_title": (raw.get("argument_title") or "").strip(),
"summary": summary,
"sharpened_question": (raw.get("sharpened_question") or "").strip(),
"evidence_quote": evidence_quote[:200],
"page_number": page,
}
async def _extract_header(protocol_text: str, case_id: UUID) -> dict:
"""Extract א–ד header data and write it back to canonical case fields.
Returns the raw extracted feed (for provenance) plus a record of what was
written back. Header data is NOT stored in protocol_analysis — it lives in
the canonical `cases`/`decisions` columns (G2: single source of truth).
"""
prompt = HEADER_PROMPT.format(protocol_text=protocol_text[:MAX_PROTOCOL_CHARS])
try:
feed = await claude_session.query_json(
prompt, model=ANALYSIS_MODEL, effort=HEADER_EFFORT, tools="",
)
except Exception as e: # noqa: BLE001 — surface, don't swallow (§6 no silent swallow)
logger.warning("protocol header extraction failed for case %s: %s", case_id, e)
return {"status": "header_failed", "error": str(e)}
if not isinstance(feed, dict):
return {"status": "header_no_data"}
applied: dict = {}
# hearing_date → canonical cases.hearing_date (only if the case lacks one,
# so a chair-entered date is never overwritten by extraction).
hearing_date = (feed.get("hearing_date") or "").strip()
if hearing_date:
from datetime import date as date_type
try:
parsed = date_type.fromisoformat(hearing_date)
case = await db.get_case(case_id)
if case and not case.get("hearing_date"):
await db.update_case(case_id, hearing_date=parsed)
applied["hearing_date"] = hearing_date
except ValueError:
logger.info("protocol header: unparseable hearing_date %r", hearing_date)
# Attendees → cases.hearing_attendees (#226). Unlike hearing_date there is no
# chair-entry path to protect, and re-running to point at the correct ערר
# protocol (#223) must refresh who appeared — so write whenever the operative
# protocol yielded any names, but never clobber good data with an empty
# extraction (all-empty feed → leave the prior snapshot intact).
attendees = {
"panel_members": feed.get("panel_members") or [],
"appellants_present": feed.get("appellants_present") or [],
"respondents_present": feed.get("respondents_present") or [],
}
if any(attendees.values()):
await db.update_case(case_id, hearing_attendees=attendees)
applied["hearing_attendees"] = True
return {
"status": "ok",
"feed": {
"hearing_date": hearing_date,
**attendees,
},
"applied_to_case": applied,
}
async def analyze_protocol(
case_id: UUID, target_document_id: UUID | None = None,
) -> dict:
"""Comparative analysis of the case's hearing protocol vs. its pleadings.
``target_document_id`` pins the analysis to a specific protocol document —
required when a case holds several protocols (e.g. a lower-committee annex
plus the ועדת-הערר hearing) and the auto-pick would be ambiguous (#223).
1. Locates the protocol document.
2. Pulls the aggregated legal_arguments (the written-pleadings baseline).
3. Asks Claude (Opus 4.8, effort=high) to classify each as dropped /
strengthened, and to surface newly-raised arguments + sharpened questions.
4. Stores the verdicts in protocol_analysis (case-knowledge, idempotent).
5. Extracts the א–ד header feed and writes hearing_date back to the case.
Returns a serializable summary dict.
"""
docs = await db.list_documents(case_id)
protocol = _find_protocol(docs, document_id=target_document_id)
if not protocol:
if target_document_id is not None:
return {
"status": "no_protocol",
"message": f"מסמך {target_document_id} לא נמצא בתיק.",
}
return {
"status": "no_protocol",
"message": (
"לא נמצא פרוטוקול ועדת-ערר בתיק (doc_type='protocol' עם "
"protocol_scope שאינו 'lower'). העלה פרוטוקול-דיון, או תייג את "
"הפרוטוקול הקיים כ-appeal, והרץ שוב."
),
}
document_id = UUID(protocol["id"])
protocol_text = await db.get_document_text(document_id)
if not protocol_text or not protocol_text.strip():
return {
"status": "no_text",
"message": "לפרוטוקול אין טקסט מחולץ. ודא שהעיבוד הסתיים והרץ שוב.",
"document_id": str(document_id),
}
# Use the aggregator's read path (single source of truth — no parallel
# query for the same legal_arguments data, G2).
from legal_mcp.services import argument_aggregator
arguments = await argument_aggregator.get_legal_arguments(case_id)
if not arguments:
return {
"status": "no_arguments",
"message": (
"אין טיעונים מאוגדים להשוואה. הרץ extract_claims + "
"aggregate_claims_to_arguments על כתבי-הטענות תחילה."
),
"document_id": str(document_id),
}
compact_args = _compact_arguments(arguments)
valid_argument_ids = {a["argument_id"] for a in compact_args}
prompt = COMPARE_PROMPT.format(
arguments_json=json.dumps(compact_args, ensure_ascii=False, indent=2),
protocol_text=protocol_text[:MAX_PROTOCOL_CHARS],
)
try:
raw_result = await claude_session.query_json(
prompt, model=ANALYSIS_MODEL, effort=ANALYSIS_EFFORT, tools="",
)
except RuntimeError as e:
msg = str(e)
if "Claude CLI not found" in msg:
return {
"status": "llm_unavailable",
"message": (
"Claude CLI לא זמין. הניתוח ההשוואתי חייב לרוץ מה-MCP server "
"המקומי, לא מהקונטיינר."
),
"document_id": str(document_id),
}
return {"status": "error", "message": msg, "document_id": str(document_id)}
if not isinstance(raw_result, list):
logger.warning(
"analyze_protocol: Claude returned non-list (%s) for case %s",
type(raw_result).__name__, case_id,
)
raw_result = []
rows: list[dict] = []
for entry in raw_result:
norm = _normalize_change(entry, valid_argument_ids)
if norm:
rows.append(norm)
stored = await db.replace_protocol_analysis(case_id, document_id, rows)
# Extract header (א–ד) and write hearing_date back to the canonical case.
header = await _extract_header(protocol_text, case_id)
by_change: dict[str, int] = {}
for r in rows:
by_change[r["change_type"]] = by_change.get(r["change_type"], 0) + 1
return {
"status": "completed",
"document_id": str(document_id),
"protocol_title": protocol.get("title", ""),
"total": stored,
"by_change": by_change,
"arguments_compared": len(compact_args),
"header": header,
}

View File

@@ -15,8 +15,14 @@ import os
import re
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
from typing import Any
# Display timezone (INV-UI9): timestamps surfaced to humans are rendered in
# Israel time deterministically, regardless of the process clock (the container
# may run UTC or Asia/Jerusalem). Storage stays UTC; this is display-only.
IL_TZ = ZoneInfo("Asia/Jerusalem")
# Placeholder strings — any of these means "not yet filled"
CHAIR_POSITION_PLACEHOLDERS = (
"[ימולא ע\"י יו\"ר הוועדה]",
@@ -191,7 +197,7 @@ def parse(file_path: Path) -> dict[str, Any]:
date_str = date_match.group(1) if date_match else ""
stat = file_path.stat()
mtime_iso = datetime.fromtimestamp(stat.st_mtime).isoformat()
mtime_iso = datetime.fromtimestamp(stat.st_mtime, tz=IL_TZ).isoformat()
result: dict[str, Any] = {
"header": {
@@ -354,7 +360,7 @@ def update_chair_position(
"saved": True,
"section_id": section_id,
"preview": preview,
"timestamp": datetime.now().isoformat(),
"timestamp": datetime.now(IL_TZ).isoformat(),
}

View File

@@ -15,6 +15,7 @@ import re
from uuid import UUID
from legal_mcp.services import db
from legal_mcp.services.learning_loop import compute_diff_stats
from legal_mcp.services.lessons import ANTI_PATTERNS, GOLDEN_RATIOS, canonical_outcome
logger = logging.getLogger(__name__)
@@ -122,6 +123,93 @@ def golden_ratio_adherence(block_word_counts: dict[str, int], outcome: str) -> d
return {"outcome": outcome, "total_words": total, "sections": sections, "max_deviation": max_dev}
def split_final_by_section(final_text: str) -> dict[str, str]:
"""Group a signed final decision into golden-ratio sections (#208 calibration).
Reuses the SAME structure-aware splitter as measure_corpus_ratios
(chunker._split_into_sections + _CHUNK_SECTION_TO_GOLDEN) — no parallel
parsing path (G2). Returns {golden_section: concatenated_text} for the
sections that map to an AI block (background/claims/discussion/summary).
A section type that does not map (e.g. headers) is dropped, never silently
folded into another section. Best-effort: an unsplittable final returns {}.
"""
from legal_mcp.services.chunker import _split_into_sections
by_section: dict[str, list[str]] = {}
for stype, stext in _split_into_sections(final_text or ""):
g = _CHUNK_SECTION_TO_GOLDEN.get(stype)
if g and stext.strip():
by_section.setdefault(g, []).append(stext.strip())
return {sec: "\n\n".join(parts) for sec, parts in by_section.items()}
def block_distance_to_final(
block_id: str,
regenerated_text: str,
final_section_text: str,
outcome: str,
section_target_total_words: int | None = None,
) -> dict:
"""Distance of ONE regenerated block from the chair's matching final section.
The per-(block, effort) measurement cell for the #208 model/effort
calibration harness. Pure/deterministic (no LLM, no DB) — reuses the
existing style-distance primitives so the harness has no parallel metric
path (G2 / INV-G8 eval-harness):
• change_percent — compute_diff_stats(regen, final_section)
(learning_loop, the SAME diff the pairing
ledger stores). Lower ⇒ the draft already
reads like the final ⇒ less chair rewriting.
• anti_pattern_total — count_anti_patterns(regen) (lessons.ANTI_PATTERNS).
Lower ⇒ closer to Dafna's continuous-narrative
voice; the CLEANEST style signal (07-learning §0.7).
• golden_ratio_deviation_pp — |regen %-of-total final %-of-total| for this
block's section. 0 ⇒ same structural weight as
the final. Requires the final's total words
(section_target_total_words); otherwise None
(we never fabricate a denominator).
Returns the three metrics + a single composite `distance` (normalized,
lower=closer) the harness ranks efforts by.
"""
outcome = canonical_outcome(outcome)
diff = compute_diff_stats(regenerated_text or "", final_section_text or "")
change_percent = diff["change_percent"]
anti_total = count_anti_patterns(regenerated_text or "")["total"]
section = _BLOCK_TO_SECTION.get(block_id)
regen_words = len((regenerated_text or "").split())
final_words = len((final_section_text or "").split())
ratio_dev: float | None = None
if section and section_target_total_words and section_target_total_words > 0:
# Replace the final's own block contribution with the regen's, holding
# the rest of the final constant, to compare structural weight fairly.
regen_total = section_target_total_words - final_words + regen_words
if regen_total > 0:
regen_pct = regen_words / regen_total * 100
final_pct = final_words / section_target_total_words * 100
ratio_dev = round(abs(regen_pct - final_pct), 1)
# Composite: normalize each component to ~[0,1] and average the present ones.
# change_percent/100, anti_total/10 (10+ hits is already very bad), ratio/20.
comps: list[float] = [min(change_percent / 100.0, 1.0), min(anti_total / 10.0, 1.0)]
if ratio_dev is not None:
comps.append(min(ratio_dev / 20.0, 1.0))
distance = round(sum(comps) / len(comps), 4)
return {
"block_id": block_id,
"section": section,
"regen_words": regen_words,
"final_words": final_words,
"change_percent": change_percent,
"anti_pattern_total": anti_total,
"golden_ratio_deviation_pp": ratio_dev,
"distance": distance,
}
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)

View File

@@ -0,0 +1,83 @@
"""Block-level style-exemplar extraction (channel B of Style Acquisition).
Splits one of Dafna's decisions into section→paragraph units, embeds them
(Voyage), and stores them in `style_exemplars` so the writer can retrieve real
block-level prose by section/outcome/practice_area (07-learning §0.2 channel B).
This is the SINGLE source of truth for exemplar extraction (G2): both the
one-time backfill (`scripts/backfill_style_exemplars.py`) and the live
final-enrollment path (`_enroll_final_in_library`) call `extract_and_store`.
Before this, exemplars were frozen at the seed backfill — new finals enrolled
into style_corpus but were never broken into exemplars, so the richest style
channel never grew. Embedding is Voyage-over-REST → container-safe.
"""
from __future__ import annotations
import logging
from legal_mcp.services import db, embeddings
from legal_mcp.services.chunker import _split_into_sections
logger = logging.getLogger(__name__)
# chunker section_type → style_exemplars.section
_SECTION_MAP = {
"facts": "background",
"appellant_claims": "claims",
"respondent_claims": "claims",
"legal_analysis": "discussion",
"conclusion": "summary",
"ruling": "summary",
"intro": "other",
"other": "other",
}
MIN_WORDS = 25 # skip tiny fragments
MAX_WORDS = 450 # skip over-long blobs (likely un-split)
MAX_PER_SECTION = 15
def _paragraphs(section_text: str) -> list[str]:
"""Split a section into paragraph units (blank-line separated; fall back to lines)."""
raw = [p.strip() for p in section_text.split("\n\n")]
if len(raw) <= 1:
raw = [p.strip() for p in section_text.split("\n")]
out = []
for p in raw:
wc = len(p.split())
if MIN_WORDS <= wc <= MAX_WORDS:
out.append(p)
return out[:MAX_PER_SECTION]
def units_for(full_text: str) -> list[tuple[str, str]]:
"""(section, paragraph) units for a decision — pure, no I/O."""
units: list[tuple[str, str]] = []
for section_type, section_text in _split_into_sections(full_text or ""):
section = _SECTION_MAP.get(section_type, "other")
for para in _paragraphs(section_text):
units.append((section, para))
return units
async def extract_and_store(
decision_number: str, source: str, full_text: str,
practice_area: str = "", outcome: str = "",
) -> int:
"""Idempotently (re)build a decision's block-level style exemplars: split →
embed → replace. Returns the number of exemplars stored. Raises on failure;
callers in best-effort paths (enroll) should wrap in try/except."""
units = units_for(full_text)
if not units:
return 0
texts = [u[1] for u in units]
vecs = await embeddings.embed_texts(texts, input_type="document")
await db.delete_style_exemplars(decision_number, source)
for (section, para), vec in zip(units, vecs):
await db.insert_style_exemplar(
decision_number=decision_number, source=source,
practice_area=practice_area, outcome=outcome,
section=section, paragraph_text=para, word_count=len(para.split()),
embedding=vec,
)
return len(units)

View File

@@ -13,6 +13,7 @@ from uuid import UUID
import httpx
from legal_mcp import config
from legal_mcp.case_status_model import STATUS_ORDER # status SSoT
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
@@ -341,16 +342,8 @@ async def case_update(
"""
from datetime import date as date_type
# Ordered core lifecycle — regression protection (forward-only).
# Single source of truth, mirrored by web-ui/src/lib/api/case-status.ts and
# models.CaseStatus. Trimmed from 17 → 10 (decorative statuses removed).
STATUS_ORDER = [
"new", "processing", "documents_ready",
"outcome_set", "direction_approved",
"qa_review", "drafted",
"exported", "reviewed", "final",
]
# Ordered core lifecycle (forward-only regression guard). Single source of
# truth: legal_mcp/case_status_model.py — STATUS_ORDER/BY_KEY derive from it.
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
@@ -363,6 +356,9 @@ async def case_update(
# Only update if advancing or status is unknown to the order
if new_idx >= cur_idx or new_idx == -1:
fields["status"] = status
# per-status on_enter hook (case_status_model) — the single place a
# status's behaviour is declared. No dispatcher wired yet; when the
# first action is added, dispatch BY_KEY[status].on_enter here.
if title:
fields["title"] = title
if subject:

View File

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

View File

@@ -264,6 +264,11 @@ async def document_get_text(case_number: str, doc_title: str = "") -> str:
async def document_list(case_number: str) -> str:
"""רשימת מסמכים בתיק.
כל מסמך כולל `doc_type`, וכן את הסיווג הנגזר `is_primary` (bool) ו-
`doc_category` ("primary"/"secondary") — מסמך-עיקרי (ערר/תשובה/התנגדות/
פרוטוקול/שומה/החלטת-ועדה) מול משני. נגזר מ-`doc_type` (db.PRIMARY_DOC_TYPES),
אינו נכתב ידנית.
Args:
case_number: מספר תיק הערר
"""
@@ -345,11 +350,15 @@ async def extract_claims(
if not docs:
return empty(f"אין מסמכים בתיק {case_number}.")
# Filter to claims documents (appeal, response) or specific doc
# Filter to claims documents or a specific doc. protocol carries oral
# arguments raised at the hearing — extracted with claim_type='protocol'
# (claims_extractor._infer_claim_type) so analyze_protocol can compare them
# against the written pleadings, while block ז (write_block_zayin) excludes
# them to stay original-pleadings-only (INV-WR4).
if doc_title:
docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
else:
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", "protocol")]
if not docs:
return empty("לא נמצאו כתבי טענות בתיק.")
@@ -424,17 +433,24 @@ ALLOWED_DOC_TYPES = {
# Allowed appraiser_side values; '' (empty) clears the tag.
ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding", ""}
# Allowed protocol_scope values (protocol docs only); mirrors web/app.py.
# appeal = ועדת הערר (default when absent) · lower = ועדה מקומית/מחוזית.
ALLOWED_PROTOCOL_SCOPES = {"appeal", "lower"}
async def document_update(
case_number: str,
doc_id: str,
doc_type: str = "",
appraiser_side: str = "",
is_post_hearing: bool | None = None,
protocol_scope: str = "",
) -> str:
"""עדכון תיוג מסמך — doc_type ו/או appraiser_side. ריק = אין שינוי.
"""עדכון תיוג מסמך — doc_type / appraiser_side / is_post_hearing / protocol_scope.
הולידציה זהה ל-PATCH endpoint ב-web/app.py. appraiser_side נשמר ב-
documents.metadata JSONB (מתפרסם משם ע"י extract_appraiser_facts).
הולידציה זהה ל-PATCH endpoint ב-web/app.py. הדגלים נשמרים ב-
documents.metadata JSONB (appraiser_side מתפרסם משם ע"י extract_appraiser_facts;
is_post_hearing נצרך ע"י כותב בלוק-ח; protocol_scope מסווג פרוטוקול בטאב-הסקירה).
Args:
case_number: מספר תיק הערר (לאישור שייכות)
@@ -443,6 +459,9 @@ async def document_update(
permit/appraisal/exhibit/objection/reference). ריק = אין שינוי.
appraiser_side: ערך חדש (committee/appellant/deciding). ריק = אין שינוי;
העבר במפורש מחרוזת ריקה לא-default אם רוצים לנקות.
is_post_hearing: True = התקבל אחרי הדיון · False = מנקה את הדגל · None = אין שינוי.
protocol_scope: היקף פרוטוקול (appeal=ועדת הערר / lower=ועדה מקומית-מחוזית).
ריק = אין שינוי. רלוונטי רק כשהסוג protocol.
"""
case = await db.get_case_by_number(case_number)
if not case:
@@ -468,17 +487,37 @@ async def document_update(
data={"allowed": sorted(ALLOWED_DOC_TYPES)})
updates["doc_type"] = doc_type
# appraiser_side is optional. The MCP tool can't distinguish "skip" from
# "set to empty string", so we use the convention: only update if non-empty.
# To clear, the operator must edit metadata directly (rare).
# appraiser_side / protocol_scope are optional. The MCP tool can't
# distinguish "skip" from "set to empty string", so we use the convention:
# only update if non-empty. To clear, edit metadata directly (rare).
# is_post_hearing is a tri-state bool: None skips, True/False set/clear.
metadata = doc.get("metadata") or {}
if isinstance(metadata, str):
metadata = json.loads(metadata)
metadata_dirty = False
if appraiser_side:
if appraiser_side not in ALLOWED_APPRAISER_SIDES:
return err(f"appraiser_side לא תקין: {appraiser_side}",
data={"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s)})
metadata = doc.get("metadata") or {}
if isinstance(metadata, str):
metadata = json.loads(metadata)
metadata["appraiser_side"] = appraiser_side
metadata_dirty = True
if is_post_hearing is not None:
if is_post_hearing:
metadata["is_post_hearing"] = True
else:
metadata.pop("is_post_hearing", None)
metadata_dirty = True
if protocol_scope:
if protocol_scope not in ALLOWED_PROTOCOL_SCOPES:
return err(f"protocol_scope לא תקין: {protocol_scope}",
data={"allowed": sorted(ALLOWED_PROTOCOL_SCOPES)})
metadata["protocol_scope"] = protocol_scope
metadata_dirty = True
if metadata_dirty:
updates["metadata"] = metadata
if not updates:

View File

@@ -516,9 +516,18 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
# ── Interim draft (pre-ruling) ────────────────────────────────────
# Blocks written for the interim draft, in display order.
# Blocks written for the interim draft, in a FIXED order.
# This is the same content the chair sees in the final decision (same template,
# same skill, same prompts) — minus opening, ruling, summary, signatures.
# same skill, same prompts, same single canonical write path — block_writer) —
# minus ruling, summary, signatures.
#
# Determinism (#204 / WS5): the interim draft is STRUCTURALLY deterministic.
# Each block is generated by block_writer.write_block, which now pins the model
# (Opus 4.8) and a per-block reasoning effort, and uses fixed structural prompts
# (e.g. block-he's opening is always "לפנינו ערר…"). The display order on export
# is fixed separately by docx_exporter._INTERIM_BLOCK_ORDER. There is no parallel
# generation path (G2): write_interim_draft is a thin orchestrator over the same
# write_and_store_block used by the full decision.
_INTERIM_BLOCKS = ["block-he", "block-vav", "block-tet", "block-zayin", "block-chet"]
@@ -571,6 +580,86 @@ async def get_appraiser_facts(case_number: str) -> str:
return err(str(e))
async def analyze_protocol(case_number: str, document_id: str = "") -> str:
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות (WS4 / #203).
מזהה אילו טענות **ירדו** (נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות**,
מחדד את השאלות המשפטיות, ומחלץ את נתוני הכותרת (א–ד) מהפרוטוקול. התוצאה נכנסת
ל"ידע-התיק" (טבלת protocol_analysis), זמינה לסוכני הניתוח והכתיבה; תאריך-הדיון
מוזן חזרה לעמודה הקנונית cases.hearing_date.
בוחר אוטומטית את פרוטוקול **ועדת-הערר** (protocol_scope שאינו 'lower');
פרוטוקול ועדה-מקומית ('lower') אינו נכנס להשוואה. כשיש כמה פרוטוקולי-ערר —
ציין document_id כדי לכוון לדיון המדויק (#223).
דורש פרוטוקול מתויג doc_type='protocol' + טיעונים מאוגדים
(aggregate_claims_to_arguments). רץ עם Claude מקומי (Opus 4.8, effort=high);
re-run מחליף את הניתוח הקודם לאותו פרוטוקול (idempotent).
Args:
case_number: מספר תיק הערר
document_id: מזהה מסמך-הפרוטוקול לכיוון מדויק (ריק = בחירה אוטומטית)
"""
from legal_mcp.services import protocol_analyzer
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"])
target_doc_id: UUID | None = None
if document_id.strip():
try:
target_doc_id = UUID(document_id.strip())
except ValueError:
return err(f"document_id לא תקין: {document_id}")
try:
result = await protocol_analyzer.analyze_protocol(
case_id, target_document_id=target_doc_id,
)
await audit.log_action_safe(
"analyze_protocol", case_id=case_id,
details={"status": result.get("status"), "total": result.get("total", 0)},
)
return ok(result)
except Exception as e:
return err(str(e))
async def get_protocol_analysis(case_number: str, change_type: str = "") -> str:
"""קריאת ניתוח-הפרוטוקול שכבר חולץ — ללא הרצת ניתוח-מחדש (INV-TOOL4).
ה-get המקביל ל-analyze_protocol: מחזיר את רשומות ידע-התיק השמורות
(ירדה/חוזקה/חדשה + שאלה-מחודדת + ציטוט-מבסס), בלי קריאת-LLM יקרה.
מחזיר רשימה ריקה אם הניתוח טרם רץ (status=ok, count=0) — לא שגיאה.
Args:
case_number: מספר תיק הערר
change_type: סינון (dropped/strengthened/newly_raised). ריק = הכל.
"""
from legal_mcp.services import protocol_analyzer
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
if change_type and change_type not in protocol_analyzer.VALID_CHANGE_TYPES:
return err(
f"change_type לא תקין: {change_type}",
data={"allowed": sorted(protocol_analyzer.VALID_CHANGE_TYPES)},
)
case_id = UUID(case["id"])
try:
rows = await db.list_protocol_analysis(
case_id, change_type=change_type or None,
)
return ok({
"case_number": case_number,
"count": len(rows),
"analysis": rows,
})
except Exception as e:
return err(str(e))
async def write_interim_draft(case_number: str, instructions: str = "") -> str:
"""כתיבת ארבעת הבלוקים לטיוטת ביניים: רקע (ו), תכניות+היתרים (ט),
טענות הצדדים (ז), הליכים (ח). אם לא חולצו עובדות שמאיות עדיין —
@@ -627,7 +716,7 @@ async def write_interim_draft(case_number: str, instructions: str = "") -> str:
async def export_interim_draft(case_number: str, output_path: str = "") -> str:
"""ייצוא טיוטת ביניים ל-DOCX — אותו עיצוב של טיוטה רגילה (David, RTL,
bookmarks), אבל בסדר חדש: רקע → תכניות+היתרים → טענות → הליכים, ללא
דיון/סיכום/חתימות. שם הקובץ: טיוטת-ביניים-v{N}.docx.
דיון/סיכום/חתימות. שם הקובץ: טיוטה-טענות_הצדדים_{N}.docx.
Args:
case_number: מספר תיק הערר
@@ -658,6 +747,34 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
return err(str(e))
async def summarize_party_claims(case_number: str, instructions: str = "") -> str:
"""הפקת סיכום-מנהלים מזוקק של טענות הצדדים — מסמך פרוזה **נפרד מטיוטת-הביניים**,
להכנת היו"ר לדיון בעל-פה. תמצית קצרה ומוקפדת (לא שכפול כתבי-הטענות), נגזרת
מהטיעונים המאוגדים (legal_arguments) או מ-claims הגולמיים — אותו מקור-אמת,
בלי חילוץ-מחדש. נשמר ל-data/cases/{n}/documents/research/ (git + S3) וניתן-לייצוא.
הייצור עובר claude_session (claude -p), נעוץ Opus 4.8 + effort=high. מתוחם
לטענות-התיק בלבד (anti-hallucination) — אינו ממציא טענה/הלכה/עובדה שאינה בקלט.
Args:
case_number: מספר תיק הערר
instructions: הנחיות נוספות מהיו"ר (אופציונלי)
"""
from legal_mcp.services import party_claims_summary
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"])
try:
result = await party_claims_summary.summarize_party_claims(
case_id, case_number, instructions,
)
return ok(result)
except ValueError as e:
return err(str(e))
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
"""רישום עריכה שהעלה המשתמש כמקור האמת החדש של התיק.

View File

@@ -4,9 +4,18 @@ from __future__ import annotations
from uuid import UUID
from legal_mcp.services import argument_aggregator, db
from legal_mcp.services import argument_aggregator, claims_extractor, db
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
# Party labels reused across this module (display + impact diff).
_PARTY_HE = {
"appellant": "עוררים",
"respondent": "משיבים",
"committee": "ועדה מקומית",
"permit_applicant": "מבקשי היתר",
"unknown": "צד לא מזוהה",
}
async def aggregate_claims_to_arguments(
case_number: str,
@@ -55,16 +64,9 @@ async def get_legal_arguments(
)
# Group by party for nicer display.
party_he = {
"appellant": "עוררים",
"respondent": "משיבים",
"committee": "ועדה מקומית",
"permit_applicant": "מבקשי היתר",
"unknown": "צד לא מזוהה",
}
by_party: dict[str, list[dict]] = {}
for a in args:
label = party_he.get(a["party"], a["party"])
label = _PARTY_HE.get(a["party"], a["party"])
by_party.setdefault(label, []).append(a)
return ok({
@@ -72,3 +74,147 @@ async def get_legal_arguments(
"total": len(args),
"by_party": by_party,
})
# ── Re-analysis with merge + impact diff (WS2 / #201) ───────────────
def _snapshot(args: list[dict]) -> dict:
"""Build a comparable snapshot of aggregated arguments for the impact diff.
Captures, per party: the set of argument titles and a priority histogram —
enough to show the chair what changed without dumping full bodies. The
priority mix is the deterministic "recommendation" signal: it summarises the
balance of threshold/substantive/procedural/relief arguments per side.
"""
by_party: dict[str, dict] = {}
for a in args:
party = a.get("party", "unknown")
bucket = by_party.setdefault(party, {"titles": [], "priorities": {}})
bucket["titles"].append((a.get("argument_title") or "").strip())
pr = a.get("priority", "substantive")
bucket["priorities"][pr] = bucket["priorities"].get(pr, 0) + 1
return {"total": len(args), "by_party": by_party}
def _impact_diff(before: dict, after: dict) -> dict:
"""Diff two argument snapshots → a chair-facing before↔after summary.
Reports, per party, which argument titles were added/removed and how the
priority mix (the "recommendation" balance) shifted. ``changed`` is a quick
boolean the UI/chair can gate on. Nothing here is auto-applied — it is purely
surfaced for the human gate (G10).
"""
parties = sorted(set(before["by_party"]) | set(after["by_party"]))
per_party: list[dict] = []
any_change = False
for party in parties:
b = before["by_party"].get(party, {"titles": [], "priorities": {}})
a = after["by_party"].get(party, {"titles": [], "priorities": {}})
b_titles, a_titles = set(b["titles"]), set(a["titles"])
added = sorted(a_titles - b_titles)
removed = sorted(b_titles - a_titles)
prio_before = dict(sorted(b["priorities"].items()))
prio_after = dict(sorted(a["priorities"].items()))
changed = bool(added or removed or prio_before != prio_after)
any_change = any_change or changed
per_party.append({
"party": party,
"party_he": _PARTY_HE.get(party, party),
"count_before": len(b["titles"]),
"count_after": len(a["titles"]),
"added_arguments": added,
"removed_arguments": removed,
"priority_before": prio_before,
"priority_after": prio_after,
"changed": changed,
})
return {
"changed": any_change,
"total_before": before["total"],
"total_after": after["total"],
"by_party": per_party,
}
async def reanalyze_claims(
case_number: str,
reanalyze_all_primary: bool = False,
) -> str:
"""ניתוח-מחדש מאחד של טענות — מחלץ ממסמכים-עיקריים חדשים/לא-נותחו בלבד,
מאחד עם הטענות הקיימות (לא מוחק הכול), מריץ צבירה-מחדש, ומחזיר בדיקת-השפעה
(diff טיעונים/המלצה לפני↔אחרי) ליו"ר.
Args:
case_number: מספר תיק הערר.
reanalyze_all_primary: True = לחלץ מחדש מכל המסמכים-העיקריים (ולא רק
מאלה שטרם-נותחו). ברירת-מחדל False = רק מסמכים-עיקריים חדשים/לא-נותחו.
מנגנון האיחוד (לא force-delete): כל מסמך מחולץ דרך
``claims_extractor.extract_and_store_claims``, ש-``store_claims`` שלו מחליף רק
את טענות *אותו* מסמך (לפי ``source_document``) — כך טענות ממסמכים שכבר-נותחו
נשמרות. הצבירה-מחדש (``aggregate_claims_to_arguments(force=True)``) מחשבת את
הטיעונים מחדש מתוך **מערך-הטענות המאוחד השלם** — מסלול-החישוב הקנוני, לא מסלול
מקביל (G2).
"""
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"])
# 1. BEFORE snapshot — current aggregated arguments (the impact baseline).
before_args = await argument_aggregator.get_legal_arguments(case_id)
before = _snapshot(before_args)
# 2. Select PRIMARY documents to (re)extract. Default: only those not yet
# analysed (the not-analysed flag). reanalyze_all_primary widens to every
# primary doc. Source of truth for "primary" = is_primary (V47).
if reanalyze_all_primary:
all_docs = await db.list_documents(case_id)
targets = [d for d in all_docs if d.get("is_primary")]
else:
targets = await db.primary_docs_not_analyzed(case_id)
if not targets:
return ok({
"case_number": case_number,
"status": "no_pending_documents",
"message": "אין מסמכים-עיקריים חדשים/לא-נותחו. לא בוצע ניתוח-מחדש.",
"documents_analyzed": [],
"impact": _impact_diff(before, before),
})
# 3. Extract+merge claims from the selected documents only. Each call replaces
# only that document's claims (merge), never the whole case (no force-delete).
analyzed: list[dict] = []
for doc in targets:
text = await db.get_document_text(UUID(doc["id"]))
if not text:
await db.mark_document_claims_extracted(UUID(doc["id"]), status="no_claims")
analyzed.append({"document": doc["title"], "status": "empty_text", "total": 0})
continue
res = await claims_extractor.extract_and_store_claims(
case_id=case_id,
document_id=UUID(doc["id"]),
text=text,
doc_type=doc["doc_type"],
)
analyzed.append({"document": doc["title"], **res})
# 4. Re-aggregate from the now-complete merged claim set. force=True is the
# canonical recompute (deletes only legal_arguments, NOT claims) — same
# path aggregate_claims_to_arguments always uses, not a fork.
agg = await argument_aggregator.aggregate_claims_to_arguments(case_id, force=True)
# 5. AFTER snapshot + impact diff for the chair.
after_args = await argument_aggregator.get_legal_arguments(case_id)
after = _snapshot(after_args)
impact = _impact_diff(before, after)
return ok({
"case_number": case_number,
"status": "completed",
"documents_analyzed": analyzed,
"aggregation": agg,
"impact": impact,
})

View File

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

View File

@@ -46,8 +46,19 @@ async def workflow_status(case_number: str) -> str:
"extraction": doc["extraction_status"],
"chunks": chunk_map.get(doc["id"], 0),
"pages": doc.get("page_count"),
# WS2 / #201: primary/secondary + whether claims were analysed.
"is_primary": doc.get("is_primary", False),
"claims_analyzed": doc.get("claims_analyzed", False),
})
# WS2 / #201: PRIMARY documents added after the analysis ran and not yet
# included → the "not-analysed" condition that should prompt a merging
# re-analysis (reanalyze_claims). Derived from is_primary + claims_extracted_at.
pending_primary = await db.primary_docs_not_analyzed(case_id)
primary_not_analyzed = [
{"title": d["title"], "type": d["doc_type"]} for d in pending_primary
]
# Check draft status
from pathlib import Path
from legal_mcp import config
@@ -64,15 +75,19 @@ async def workflow_status(case_number: str) -> str:
"documents": doc_status,
"total_documents": len(docs),
"total_chunks": sum(chunk_map.values()),
"primary_docs_not_analyzed": primary_not_analyzed,
"has_draft": has_draft,
"draft_size_bytes": draft_size,
"next_steps": _suggest_next_steps(case, docs, has_draft),
"next_steps": _suggest_next_steps(case, docs, has_draft, primary_not_analyzed),
}
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,
primary_not_analyzed: list | None = None,
) -> list[str]:
"""Suggest next steps based on case state."""
steps = []
doc_types = {d["doc_type"] for d in docs}
@@ -85,6 +100,13 @@ def _suggest_next_steps(case: dict, docs: list, has_draft: bool) -> list[str]:
if "response" not in doc_types:
steps.append("העלה תשובת ועדה/משיבים")
# WS2 / #201: a primary document was added but not yet analysed.
if primary_not_analyzed:
titles = ", ".join(d["title"] for d in primary_not_analyzed)
steps.append(
f"מסמך-עיקרי לא-נותח ({titles}) — הרץ ניתוח-מחדש מאחד (reanalyze_claims)"
)
pending = [d for d in docs if d["extraction_status"] == "pending"]
if pending:
steps.append(f"עיבוד {len(pending)} מסמכים ממתינים")
@@ -394,13 +416,134 @@ async def record_chair_feedback(
lesson_extracted=lesson_extracted,
)
# Auto-flow chair-authored STYLE feedback to the writer (closes the dead
# chair_feedback→lesson chain — 27 feedback rows had produced 0 lessons). The
# chair is the highest authority, so a style correction she writes flows
# immediately — the chair IS the gate. It rides the SAME discussion_rules channel
# promote uses (db.append_global_rule, G2), reaching every block, without the
# style_corpus coupling decision_lessons require. SUBSTANCE feedback
# (missing_content/factual_error/other) is case-specific → recorded only.
# (INV-LRN1 graduated gate; 07-learning §1.2.)
_STYLE_FB = {"style", "wrong_tone", "wrong_structure"}
flowed = 0
if lesson_extracted.strip() and category in _STYLE_FB:
try:
flowed = await db.append_global_rule(
"discussion_rules", "universal", [lesson_extracted.strip()],
)
except Exception as e:
logger.warning("chair-feedback auto-flow failed for %s: %s", case_number, e)
msg = f"הערה נרשמה בהצלחה. קטגוריה: {category}."
if flowed:
msg += " הלקח (סגנון) זרם אוטומטית לכותב."
return ok({
"feedback_id": str(feedback_id),
"flowed_to_writer": bool(flowed),
"next_steps": [
"כדי להפיק לקח מההערה, הפעל: analyze_chair_feedback",
"כדי לסמן כמטופל: resolve_chair_feedback",
],
}, message=f"הערה נרשמה בהצלחה. קטגוריה: {category}.")
}, message=msg)
_CURATOR_FINDING_CATEGORIES = {"style", "structure", "lexicon", "tabular", "general"}
# Agent tags ([סגנון]/[מבנה]/[לקסיקון משפטי]/[טבלאי]) → decision_lessons.category
_CURATOR_TAG_TO_CATEGORY = {
"סגנון": "style", "מבנה": "structure",
"לקסיקון משפטי": "lexicon", "לקסיקון": "lexicon", "טבלאי": "tabular",
}
async def record_curator_findings(case_number: str, findings: list[dict]) -> str:
"""לכידת ממצאי-האוצֵר כ-decision_lessons מובְנים (source='curator', proposed) — INV-LRN3.
האוצֵר מזהה דפוסי-סגנון בקריאת הסופי; עד כה הם חיו רק כהערה ארעית בערוץ-התגובות
(אובד, לא נסקר). כאן הם נתפסים מבנית כך שיופיעו בטאב ״אוצֵר״ ויעברו שער-יו"ר (INV-LRN1/G10).
האוצֵר נשאר read-only על התוכן — הרישום הוא הצעה הממתינה לאישור, לא שינוי-קול.
Args:
case_number: מספר התיק הסופי (= decision_number בקורפוס-הסגנון).
findings: רשימת ממצאים, כל אחד {"text": "...", "category"/"tag": "..."}.
category ∈ style/structure/lexicon/tabular/general (או tag עברי).
"""
if not findings:
return err("findings ריק — אין ממצאים לרשום.")
corpus_id = await db.get_style_corpus_id_by_decision(case_number)
if not corpus_id:
return err(
f"לא נמצאה רשומת style_corpus ל-{case_number} — ודא שהסופי נקלט לקורפוס-הסגנון "
"(enroll_style_corpus) לפני רישום ממצאים."
)
# Dedup against lessons already on this corpus (any source) — re-running §A
# must not pile duplicates (INV-LRN3 reliability).
existing = {(_norm(r["lesson_text"])) for r in await db.list_decision_lessons(corpus_id)}
written, skipped_dup, skipped_empty = [], 0, 0
for f in findings:
text = (f.get("text") or "").strip()
if not text:
skipped_empty += 1
continue
if _norm(text) in existing:
skipped_dup += 1
continue
raw_cat = (f.get("category") or f.get("tag") or "general").strip()
category = _CURATOR_TAG_TO_CATEGORY.get(raw_cat, raw_cat)
if category not in _CURATOR_FINDING_CATEGORIES:
category = "general"
row = await db.add_decision_lesson(
corpus_id,
lesson_text=text,
category=category,
source="curator",
created_by="curator",
review_status="proposed",
)
if row:
written.append(str(row["id"]))
existing.add(_norm(text))
return ok({
"corpus_id": str(corpus_id),
"written": len(written),
"skipped_duplicate": skipped_dup,
"skipped_empty": skipped_empty,
"lesson_ids": written,
}, message=(
f"נרשמו {len(written)} ממצאי-אוצֵר (source=curator, ממתינים לשער-יו\"ר ב-/training). "
f"{skipped_dup} כפילויות דולגו."
))
def _norm(s: str) -> str:
"""Normalize lesson text for dedup — collapse whitespace, strip."""
return " ".join((s or "").split())
async def lesson_synthesize_pending(
practice_area: str = "", category: str = "", apply: bool = True,
) -> str:
"""סינתזת-לקחים (#158 / INV-LRN8): ממזגת לקחי-סגנון חופפים ל"לקח-על" אחד עשיר ומוכלל,
כך שהסט שזורם לכותב קטֵן ומשתבח (התקרה limit=15 מפסיקה לחתוך). מאשכלת לפי דמיון (cosine)
בתוך shard של practice_area+category, ומסנתזת מעוגן-מקור (INV-AH) עם שער-drift.
Args:
practice_area: לצמצם ל-shard אחד (ריק = כל התחומים).
category: style/structure/lexicon/tabular (ריק = כל הקטגוריות).
apply: True = כותב (לקח-על approved + מקורות→superseded); False = dry-run.
"""
from legal_mcp.services import lesson_synthesis
shards = await lesson_synthesis.run_pending(practice_area, category, apply=apply)
applied = sum(1 for s in shards for c in s["clusters"] if c.get("applied"))
clusters = sum(len(s["clusters"]) for s in shards)
return ok({
"apply": apply,
"shards": shards,
"clusters_found": clusters,
"synthesized": applied,
}, message=(
f"סינתזת-לקחים: {clusters} אשכולות ב-{len(shards)} shards · "
f"{applied} לקחי-על {'נכתבו (approved)' if apply else 'דמו (dry-run)'}."
))
async def list_chair_feedback(

View File

@@ -0,0 +1,21 @@
"""Adapter-profile compatibility gates for Paperclip migration."""
from __future__ import annotations
import importlib.util
from pathlib import Path
_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "adapter_profiles.py"
_spec = importlib.util.spec_from_file_location("adapter_profiles", _SCRIPT)
profiles = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(profiles)
def test_codex_local_profile_accepts_openai_model_ids():
assert profiles.model_matches_provider("gpt-5.3-codex", "codex_local")
assert profiles.model_matches_provider("o4-mini", "codex_local")
assert profiles.model_matches_provider("codex-mini-latest", "codex_local")
def test_codex_local_profile_rejects_foreign_model_ids():
assert not profiles.model_matches_provider("claude-opus-4-8", "codex_local")
assert not profiles.model_matches_provider("gemini-3.1-pro-preview", "codex_local")

View File

@@ -14,7 +14,9 @@ from lxml import etree
from legal_mcp.services.docx_exporter import (
_BOOKMARK_ID_START,
HEBREW_FONT,
TEMPLATE_PATH,
_add_styled_paragraph,
_clear_body,
_insert_bookmark_end,
_insert_bookmark_start,
_mark_paragraph_rtl,
@@ -168,6 +170,83 @@ def test_block_dalet_does_not_use_title_style() -> None:
assert any("החלטה" in t for t in texts)
def test_block_dalet_is_heading_only_ignores_db_content() -> None:
"""ת2 — block-ד is the 'החלטה' heading marker, NOT a data block.
Whatever content sits in decision_blocks for block-dalet must be ignored;
the only non-empty text line produced is the literal heading 'החלטה'.
"""
doc = Document()
_write_block_to_docx(
doc, "block-dalet", title="כותרת",
content="נתון ישן שצריך להיות מתועלם\nשורה שנייה",
)
text_lines = [p.text for p in doc.paragraphs if p.text.strip()]
assert text_lines == ["החלטה"], (
f"block-dalet must render ONLY the heading 'החלטה', got {text_lines}"
)
# ── _clear_body strips ALL scaffolding (ת1 + ת3 root cause) ────────
# The template ships 3 sample tables (header / panel / signatures). Removing
# only <w:p> left them behind: header/panel tables injected block-א–ד data
# never extracted from the protocol (ת1), and the signatures table floated to
# the top against the header (ת3 — חתימות צמודות לבלוק-ד במקום בסוף). The
# decision is rebuilt purely from decision_blocks (INV-EX1), so the template's
# sample tables are stale scaffolding and must be cleared.
def test_clear_body_removes_tables_and_leaves_only_sectpr() -> None:
doc = Document(str(TEMPLATE_PATH))
body = doc.element.body
# Sanity: the template really ships sample tables (else this test is moot).
assert len(body.findall(qn("w:tbl"))) > 0, "template expected to ship sample tables"
_clear_body(doc)
remaining = [c.tag.split("}")[-1] for c in list(body)]
assert remaining == ["sectPr"], (
f"_clear_body must leave only sectPr, got {remaining}"
)
assert len(body.findall(qn("w:tbl"))) == 0, "no template tables may survive"
assert len(body.findall(qn("w:p"))) == 0, "no template paragraphs may survive"
def test_signatures_block_renders_after_body_not_at_top() -> None:
"""ת3 — block-yod-bet (חתימות) renders LAST, after a cleared body.
Regression for the bug where the leftover template signatures table sat at
the top against the header table. After the _clear_body fix, signatures
come only from block-yod-bet, which the export loop emits last in block
order.
"""
doc = Document(str(TEMPLATE_PATH))
_clear_body(doc)
# Emit a header block, a body block, then signatures last (canonical order).
_write_block_to_docx(doc, "block-alef", title="", content="מדינת ישראל")
_write_block_to_docx(doc, "block-vav", title="", content="רקע עובדתי\n1. המקרקעין.")
_write_block_to_docx(
doc, "block-yod-bet", title="",
content='ניתנה פה אחד היום.\nדפנה תמיר, עו"ד',
)
text_lines = [p.text for p in doc.paragraphs if p.text.strip()]
assert text_lines[0] == "מדינת ישראל", text_lines
# The signatures block (block-yod-bet) emits each line as its own paragraph,
# so its content must occupy the document's tail — after the body block.
body_idx = text_lines.index("רקע עובדתי")
sig_idx = next(i for i, t in enumerate(text_lines) if "ניתנה פה אחד" in t)
assert sig_idx > body_idx, (
f"signatures must come AFTER the body, got tail={text_lines[-3:]}"
)
assert any("דפנה תמיר" in t for t in text_lines[sig_idx:]), text_lines[sig_idx:]
# No template signatures table ("מזכירת ועדת ערר") leaked anywhere.
assert all(
"מזכירת ועדת ערר" not in (p.text or "") for p in doc.paragraphs
), "template signatures-table text leaked into the body"
# ── Heading overrides, numbered-list, dash strip ──────────────────

View File

@@ -130,6 +130,16 @@ def _find_ins_with_runs(tree: etree._Element) -> etree._Element | None:
return None
def _find_enclosing_p(element: etree._Element) -> etree._Element | None:
"""Walk up to the enclosing <w:p>."""
cur = element
while cur is not None:
if cur.tag == _w("p"):
return cur
cur = cur.getparent()
return None
def test_insert_after_ins_has_author_and_date(sample_docx: Path, tmp_path: Path) -> None:
out = tmp_path / "out.docx"
rev = Revision(id="r1", type="insert_after",
@@ -147,7 +157,10 @@ def test_insert_after_ins_has_author_and_date(sample_docx: Path, tmp_path: Path)
assert date_str.endswith("Z") # ISO 8601 UTC
def test_insert_after_uses_rtl_and_david(sample_docx: Path, tmp_path: Path) -> None:
def test_insert_after_uses_rtl_and_template_style(sample_docx: Path, tmp_path: Path) -> None:
"""INV-EX4: an inserted paragraph carries the template's named style via
<w:pStyle> and an <w:rtl/> run marker — but does NOT hand-set rFonts/sz
(font/size come from the style). jc is the canonical body 'both'."""
out = tmp_path / "out.docx"
rev = Revision(id="r1", type="insert_after",
anchor_bookmark="block-alef", content="מוסף")
@@ -162,10 +175,22 @@ def test_insert_after_uses_rtl_and_david(sample_docx: Path, tmp_path: Path) -> N
assert run is not None
rPr = run.find(_w("rPr"))
assert rPr is not None
# RTL marker preserved (so Word picks the style's cs=David slot)…
assert rPr.find(_w("rtl")) is not None
rFonts = rPr.find(_w("rFonts"))
assert rFonts is not None
assert rFonts.get(_w("ascii")) == "David"
# …but NO hand-set font/size on the run (INV-EX4).
assert rPr.find(_w("rFonts")) is None
assert rPr.find(_w("sz")) is None
assert rPr.find(_w("szCs")) is None
# The enclosing paragraph references a template style + canonical jc=both.
para = _find_enclosing_p(ins)
assert para is not None
pPr = para.find(_w("pPr"))
assert pPr is not None
pStyle = pPr.find(_w("pStyle"))
assert pStyle is not None and pStyle.get(_w("val")) # Normal → real styleId
jc = pPr.find(_w("jc"))
assert jc is not None and jc.get(_w("val")) == "both"
# ── apply_tracked_revisions: insert_before ────────────────────────

View File

@@ -0,0 +1,76 @@
"""שומר INV-EX4 — מקור-תבנית-יחיד לכל מפיקי-מסמכי-ההחלטה.
כל מפיק של מסמך-Word הקשור-להחלטה חייב להחיל עיצוב דרך הסגנונות שמוגדרים
בטמפלט היחיד (skills/docx/decision_template.docx), ולא לקבוע פונט/גודל ידנית
(``rFonts`` / ``sz`` / ``szCs``) — אלה נגזרים מ-styles.xml של הטמפלט.
הטסט גורף את מודולי-המפיקים ונכשל אם נבנה אלמנט ``w:rFonts`` / ``w:sz`` /
``w:szCs`` בקוד, אלא אם השורה נושאת את הסמן המפורש ``# INV-EX4-ok`` (חריג
מתועד — למשל חיזוק גופן-התבנית עצמו David כעקיפת באג-RTL של Word).
ספ: docs/spec/06-export.md §INV-EX4.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from legal_mcp.services import (
analysis_docx_exporter,
docx_exporter,
docx_reviser,
docx_retrofit,
)
# Every module that builds/edits a decision-related Word document.
_PRODUCER_MODULES = [
docx_exporter,
analysis_docx_exporter,
docx_reviser,
docx_retrofit,
]
# Construction (not lookup) of a hand-set font/size element:
# OxmlElement("w:rFonts") (python-docx producers)
# etree.SubElement(rPr, _w("sz")) (raw-lxml producer: docx_reviser)
_HANDSET_RE = re.compile(
r'OxmlElement\(\s*["\']w:(?:rFonts|sz|szCs)["\']'
r'|SubElement\([^)]*_w\(\s*["\'](?:rFonts|sz|szCs)["\']'
)
# Producers that build a document FROM SCRATCH must load the single template.
# (docx_reviser/docx_retrofit operate on an already-templated existing doc.)
_FROM_SCRATCH_MODULES = [docx_exporter, analysis_docx_exporter]
def _module_path(module) -> Path:
return Path(module.__file__)
def test_no_handset_font_or_size_in_producers() -> None:
"""INV-EX4: no hand-set rFonts/sz/szCs in any producer (only via the
template's named styles), unless explicitly marked ``# INV-EX4-ok``."""
violations: list[str] = []
for module in _PRODUCER_MODULES:
path = _module_path(module)
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if _HANDSET_RE.search(line) and "INV-EX4-ok" not in line:
violations.append(f"{path.name}:{lineno}: {line.strip()}")
assert not violations, (
"INV-EX4 violation — hand-set font/size outside the template styles "
"(use a named template style, or mark with # INV-EX4-ok):\n "
+ "\n ".join(violations)
)
@pytest.mark.parametrize("module", _FROM_SCRATCH_MODULES)
def test_from_scratch_producers_load_the_single_template(module) -> None:
"""INV-EX4: from-scratch producers reference the one template file."""
source = _module_path(module).read_text(encoding="utf-8")
assert "decision_template.docx" in source, (
f"{_module_path(module).name} must load the single template "
"skills/docx/decision_template.docx (INV-EX4)."
)

View File

@@ -0,0 +1,212 @@
"""Tests for the party-claims executive summary (#202, WS3).
Cover the deterministic, offline parts:
- ``_build_arguments_context`` source selection: legal_arguments preferred,
raw claims fallback, "none" when empty (G2 — single source, no re-extract).
- ``summarize_party_claims`` pins Opus 4.8 + effort=high and grounds the
prompt strictly in the case's own claims (anti-hallucination / INV-AH).
- ``_render_markdown_body`` maps markdown headings/bullets onto template styles.
Generation itself (claude -p) is local-only and not exercised here — the
claude_session call is monkeypatched.
"""
from __future__ import annotations
import asyncio
from uuid import uuid4
from legal_mcp.services import party_claims_summary as pcs
def _run(coro):
return asyncio.run(coro)
# ── source selection (G2: arguments preferred, claims fallback) ────
def test_arguments_preferred_over_claims(monkeypatch):
cid = uuid4()
async def _get_args(_case_id):
return [
{"party": "appellant", "argument_title": "חריגת בנייה",
"argument_body": "הבנייה חורגת מקו הבניין", "legal_topic": "קווי בניין"},
{"party": "respondent", "argument_title": "התיישנות",
"argument_body": "", "legal_topic": ""},
]
async def _get_claims(_case_id, *a, **k): # must NOT be consulted
raise AssertionError("claims fallback used despite arguments existing")
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _get_args)
monkeypatch.setattr(pcs.db, "get_claims", _get_claims)
ctx, n, kind = _run(pcs._build_arguments_context(cid))
assert kind == "arguments"
assert n == 2
assert "העוררים" in ctx and "המשיבים" in ctx
assert "חריגת בנייה" in ctx
def test_falls_back_to_claims_when_no_arguments(monkeypatch):
cid = uuid4()
async def _no_args(_case_id):
return []
async def _claims(_case_id, *a, **k):
return [
{"party_role": "appellant", "claim_text": "טענה אחת", "source_document": "appeal"},
{"party_role": "respondent", "claim_text": "טענה שתיים", "source_document": "response"},
]
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _no_args)
monkeypatch.setattr(pcs.db, "get_claims", _claims)
ctx, n, kind = _run(pcs._build_arguments_context(cid))
assert kind == "claims"
assert n == 2
assert "טענה אחת" in ctx
def test_block_zayin_claims_excluded(monkeypatch):
"""Decision-summary (block-zayin) claims are not original pleadings."""
cid = uuid4()
async def _no_args(_case_id):
return []
async def _claims(_case_id, *a, **k):
return [
{"party_role": "appellant", "claim_text": "מקורית", "source_document": "appeal"},
{"party_role": "appellant", "claim_text": "מסיכום", "source_document": "block-zayin"},
]
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _no_args)
monkeypatch.setattr(pcs.db, "get_claims", _claims)
ctx, n, kind = _run(pcs._build_arguments_context(cid))
assert kind == "claims"
assert n == 1
assert "מקורית" in ctx and "מסיכום" not in ctx
def test_none_when_no_source(monkeypatch):
cid = uuid4()
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments",
lambda _c: _aw([]))
monkeypatch.setattr(pcs.db, "get_claims", lambda _c, *a, **k: _aw([]))
ctx, n, kind = _run(pcs._build_arguments_context(cid))
assert kind == "none" and n == 0 and ctx == ""
async def _aw(v):
return v
# ── generation pins Opus 4.8 + high effort, grounded in claims ─────
def test_summarize_pins_opus_and_high_effort(monkeypatch, tmp_path):
cid = uuid4()
captured: dict = {}
async def _get_case(_case_id):
return {"case_number": "8125-09-24", "title": "t", "appellants": ["א"],
"respondents": ["ב"], "subject": "היטל השבחה", "property_address": "",
"appeal_type": "betterment_levy", "practice_area": "betterment_levy"}
async def _get_decision(_case_id):
return None
async def _args(_case_id):
return [{"party": "appellant", "argument_title": "X",
"argument_body": "Y", "legal_topic": ""}]
async def _fake_query(prompt, *, timeout=None, model=None, effort=None, tools=None):
captured["model"] = model
captured["effort"] = effort
captured["tools"] = tools
captured["prompt"] = prompt
return "## טענות העוררים\nתמצית."
async def _style(_practice_area=""):
return "(style)"
monkeypatch.setattr(pcs.db, "get_case", _get_case)
monkeypatch.setattr(pcs.db, "get_decision_by_case", _get_decision)
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _args)
monkeypatch.setattr(pcs.block_writer, "_build_style_context", _style)
monkeypatch.setattr(pcs.claude_session, "query", _fake_query)
monkeypatch.setattr(pcs.config, "find_case_dir", lambda cn: tmp_path / cn)
monkeypatch.setattr(pcs.config, "DATA_DIR", tmp_path)
async def _mirror(*a, **k):
return None
monkeypatch.setattr(pcs.storage, "mirror", _mirror)
monkeypatch.setattr(pcs.git_sync, "commit_and_push", lambda *a, **k: True)
result = _run(pcs.summarize_party_claims(cid, "8125-09-24"))
# The whole point of #202's generation-path constraint:
assert captured["model"] == "claude-opus-4-8"
assert captured["effort"] == "high"
assert captured["tools"] == "" # prose, no tool_use
# Grounded strictly in the case's own arguments (anti-hallucination).
assert "אנטי-הזיה" in captured["prompt"]
assert "Y" in captured["prompt"] # the actual argument body is in-context
assert result["source_kind"] == "arguments"
out = (tmp_path / "8125-09-24" / "documents" / "research" / "party-claims-summary.md")
assert out.exists()
assert "סיכום-מנהלים" in out.read_text(encoding="utf-8")
def test_summarize_raises_when_no_claims(monkeypatch, tmp_path):
cid = uuid4()
monkeypatch.setattr(pcs.db, "get_case", lambda _c: _aw({"case_number": "9000-01-25"}))
monkeypatch.setattr(pcs.db, "get_decision_by_case", lambda _c: _aw(None))
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", lambda _c: _aw([]))
monkeypatch.setattr(pcs.db, "get_claims", lambda _c, *a, **k: _aw([]))
try:
_run(pcs.summarize_party_claims(cid, "9000-01-25"))
raise AssertionError("expected ValueError on empty source")
except ValueError as e:
assert "טענות" in str(e)
# ── markdown → DOCX rendering uses template heading styles ─────────
def test_render_markdown_body_maps_styles():
from docx import Document
from legal_mcp.services import analysis_docx_exporter as ax
doc = Document()
md = (
"# כותרת ראשית\n\n"
"---\n"
"## טענות העוררים\n"
"- טענה אחת\n"
"1. סעיף ממוספר\n"
"טקסט רגיל.\n"
)
ax._render_markdown_body(doc, md)
styles = [p.style.name for p in doc.paragraphs]
texts = [p.text for p in doc.paragraphs]
# HR is skipped; headings become Heading styles.
assert "כותרת ראשית" in texts
assert "Heading 1" in styles # # / ## → Heading 1
assert any("טענה אחת" in t for t in texts)
assert not any(t.strip() in {"---", "#"} for t in texts)
def test_next_version_is_prefix_scoped(tmp_path):
from legal_mcp.services import analysis_docx_exporter as ax
(tmp_path / "ניתוח-משפטי-v3.docx").write_bytes(b"x")
# An analysis file at v3 must NOT bump the summary's version.
assert ax._next_version(tmp_path, "סיכום-מנהלים-טענות") == 1
assert ax._next_version(tmp_path, "ניתוח-משפטי") == 4

View File

@@ -0,0 +1,200 @@
"""Tests for the comparative protocol analyzer (WS4 / #203).
Covers the pure (non-LLM, non-DB) logic: the anti-hallucination normalization
gate, protocol-document discovery, claim_type tagging for protocols, and the
block-ז filter that keeps oral hearing arguments out of the original-pleadings
summary (INV-WR4).
"""
from __future__ import annotations
import pytest
from legal_mcp.services import protocol_analyzer as pa
from legal_mcp.services.claims_extractor import _infer_claim_type
# ── claim_type tagging — protocol distinct from written pleadings ───────────
@pytest.mark.parametrize("doc_type,title,expected", [
("protocol", "פרוטוקול דיון", "protocol"),
("appeal", "פרוטוקול הדיון מיום 1.1", "protocol"), # title-based
("appeal", "כתב ערר", "claim"),
("response", "כתב תשובה", "response"),
("response", "תגובת המשיבה", "reply"),
])
def test_infer_claim_type(doc_type, title, expected):
assert _infer_claim_type(doc_type, title) == expected
# ── _find_protocol ─────────────────────────────────────────────────────────
def test_find_protocol_by_doc_type():
docs = [
{"id": "1", "doc_type": "appeal", "title": "כתב ערר"},
{"id": "2", "doc_type": "protocol", "title": "פרוטוקול"},
]
assert pa._find_protocol(docs)["id"] == "2"
def test_find_protocol_by_title_fallback():
docs = [
{"id": "1", "doc_type": "reference", "title": "פרוטוקול הדיון"},
{"id": "2", "doc_type": "appeal", "title": "כתב ערר"},
]
assert pa._find_protocol(docs)["id"] == "1"
def test_find_protocol_none():
docs = [{"id": "1", "doc_type": "appeal", "title": "כתב ערר"}]
assert pa._find_protocol(docs) is None
def test_find_protocol_skips_lower_scope():
# A local/district-committee protocol (scope='lower') must NOT be picked for
# the hearing-vs-pleadings comparison; the ועדת-ערר one wins (#223).
docs = [
{"id": "lower", "doc_type": "protocol", "title": "נספח 18 — פרוטוקולי ועדה מקומית",
"metadata": {"protocol_scope": "lower"}, "created_at": "2026-01-01"},
{"id": "appeal", "doc_type": "protocol", "title": "פרוטוקול דיון 23.6.26",
"metadata": {}, "created_at": "2026-06-23"},
]
assert pa._find_protocol(docs)["id"] == "appeal"
def test_find_protocol_all_lower_returns_none():
# If every protocol is lower-scoped there is no ערר-hearing to compare.
docs = [
{"id": "1", "doc_type": "protocol", "title": "פרוטוקול מקומי",
"metadata": {"protocol_scope": "lower"}},
]
assert pa._find_protocol(docs) is None
def test_find_protocol_prefers_most_recent_appeal():
docs = [
{"id": "old", "doc_type": "protocol", "title": "פרוטוקול א",
"metadata": {}, "created_at": "2026-03-01"},
{"id": "new", "doc_type": "protocol", "title": "פרוטוקול ב",
"metadata": {}, "created_at": "2026-06-23"},
]
assert pa._find_protocol(docs)["id"] == "new"
def test_find_protocol_explicit_document_id_overrides():
# Explicit target wins even over scope/recency heuristics — including the
# ability to point at a lower-scoped doc if the caller insists.
docs = [
{"id": "aaaaaaaa-0000-0000-0000-000000000001", "doc_type": "protocol",
"title": "פרוטוקול ערר", "metadata": {}, "created_at": "2026-06-23"},
{"id": "aaaaaaaa-0000-0000-0000-000000000002", "doc_type": "protocol",
"title": "פרוטוקול מקומי", "metadata": {"protocol_scope": "lower"},
"created_at": "2026-01-01"},
]
from uuid import UUID
picked = pa._find_protocol(
docs, document_id=UUID("aaaaaaaa-0000-0000-0000-000000000002"),
)
assert picked["id"] == "aaaaaaaa-0000-0000-0000-000000000002"
def test_find_protocol_unknown_document_id_returns_none():
from uuid import UUID
docs = [
{"id": "aaaaaaaa-0000-0000-0000-000000000001", "doc_type": "protocol",
"title": "פרוטוקול", "metadata": {}},
]
assert pa._find_protocol(
docs, document_id=UUID("bbbbbbbb-0000-0000-0000-000000000009"),
) is None
# ── _normalize_change — anti-hallucination gate (INV-AH) ───────────────────
_AID = "11111111-1111-1111-1111-111111111111"
def test_normalize_change_valid():
row = pa._normalize_change(
{
"change_type": "dropped",
"party_role": "appellant",
"argument_id": _AID,
"argument_title": "טענת השיהוי",
"summary": "העורר ויתר על הטענה בדיון.",
"sharpened_question": "האם נותרה טענת סף?",
"evidence_quote": "ב\"כ העורר: איננו עומדים על טענת השיהוי.",
"page_number": 3,
},
valid_argument_ids={_AID},
)
assert row is not None
assert row["change_type"] == "dropped"
assert str(row["argument_id"]) == _AID
assert row["page_number"] == 3
def test_normalize_change_drops_row_without_evidence_quote():
# quote-or-retract: no verbatim support → row is rejected at source.
row = pa._normalize_change(
{"change_type": "strengthened", "summary": "חוזקה", "evidence_quote": ""},
valid_argument_ids=set(),
)
assert row is None
def test_normalize_change_drops_row_without_summary():
row = pa._normalize_change(
{"change_type": "dropped", "summary": "", "evidence_quote": "ציטוט"},
valid_argument_ids=set(),
)
assert row is None
def test_normalize_change_rejects_bad_change_type():
row = pa._normalize_change(
{"change_type": "modified", "summary": "x", "evidence_quote": "y"},
valid_argument_ids=set(),
)
assert row is None
def test_normalize_change_drops_hallucinated_argument_id():
# An argument_id that isn't one of this case's arguments must be dropped
# (FK safety) — but the row itself, being newly_raised-shaped, survives.
row = pa._normalize_change(
{
"change_type": "newly_raised",
"party_role": "respondent",
"argument_id": "99999999-9999-9999-9999-999999999999",
"summary": "סוגיה חדשה שעלתה בדיון.",
"evidence_quote": "ב\"כ המשיבה העלה לראשונה את שאלת הסמכות.",
},
valid_argument_ids={_AID},
)
assert row is not None
assert row["argument_id"] is None
assert row["change_type"] == "newly_raised"
def test_normalize_change_invalid_party_role_blanked():
row = pa._normalize_change(
{
"change_type": "strengthened",
"party_role": "judge", # not a valid party
"summary": "חוזקה הטענה.",
"evidence_quote": "ציטוט מבסס.",
},
valid_argument_ids=set(),
)
assert row is not None
assert row["party_role"] == ""
def test_normalize_change_truncates_quote():
long_quote = "א" * 500
row = pa._normalize_change(
{"change_type": "dropped", "summary": "x", "evidence_quote": long_quote},
valid_argument_ids=set(),
)
assert row is not None
assert len(row["evidence_quote"]) == 200

View File

@@ -0,0 +1,76 @@
"""Unit tests for the re-analysis impact diff — WS2 / task #201.
`_snapshot` + `_impact_diff` are pure functions (no DB) that produce the
before↔after summary surfaced to the chair after a merging re-analysis. They
must report per-party argument titles added/removed and the shift in the
priority ("recommendation balance") mix, and set ``changed`` correctly.
"""
from __future__ import annotations
from legal_mcp.tools.legal_arguments import _impact_diff, _snapshot
def _arg(party, title, priority="substantive"):
return {"party": party, "argument_title": title, "priority": priority}
def test_no_change_is_flagged_false():
args = [_arg("appellant", "זכות עמידה"), _arg("committee", "שיהוי")]
snap = _snapshot(args)
diff = _impact_diff(snap, snap)
assert diff["changed"] is False
assert diff["total_before"] == diff["total_after"] == 2
# Every party entry reports changed=False.
assert all(p["changed"] is False for p in diff["by_party"])
def test_added_argument_detected():
before = _snapshot([_arg("appellant", "זכות עמידה")])
after = _snapshot([_arg("appellant", "זכות עמידה"), _arg("appellant", "תחולת תמא 38")])
diff = _impact_diff(before, after)
assert diff["changed"] is True
assert diff["total_before"] == 1
assert diff["total_after"] == 2
appellant = next(p for p in diff["by_party"] if p["party"] == "appellant")
assert appellant["added_arguments"] == ["תחולת תמא 38"]
assert appellant["removed_arguments"] == []
assert appellant["party_he"] == "עוררים"
def test_removed_argument_detected():
before = _snapshot([_arg("committee", "שיהוי"), _arg("committee", "סמכות")])
after = _snapshot([_arg("committee", "שיהוי")])
diff = _impact_diff(before, after)
committee = next(p for p in diff["by_party"] if p["party"] == "committee")
assert committee["removed_arguments"] == ["סמכות"]
assert committee["count_before"] == 2
assert committee["count_after"] == 1
assert diff["changed"] is True
def test_priority_mix_shift_is_a_change():
# Same title, but priority moved threshold -> substantive: the recommendation
# balance shifted, so the chair must see a change even with identical titles.
before = _snapshot([_arg("appellant", "סמכות", priority="threshold")])
after = _snapshot([_arg("appellant", "סמכות", priority="substantive")])
diff = _impact_diff(before, after)
appellant = next(p for p in diff["by_party"] if p["party"] == "appellant")
assert appellant["added_arguments"] == []
assert appellant["removed_arguments"] == []
assert appellant["priority_before"] == {"threshold": 1}
assert appellant["priority_after"] == {"substantive": 1}
assert appellant["changed"] is True
assert diff["changed"] is True
def test_new_party_appears():
before = _snapshot([_arg("appellant", "זכות עמידה")])
after = _snapshot([_arg("appellant", "זכות עמידה"), _arg("respondent", "מענה")])
diff = _impact_diff(before, after)
parties = {p["party"] for p in diff["by_party"]}
assert parties == {"appellant", "respondent"}
respondent = next(p for p in diff["by_party"] if p["party"] == "respondent")
assert respondent["count_before"] == 0
assert respondent["count_after"] == 1
assert respondent["added_arguments"] == ["מענה"]

View File

@@ -207,8 +207,9 @@ def test_multiple_revisions_all_tracked_independently(tmp_path: Path) -> None:
def test_rtl_preserved_in_tracked_insertion(tmp_path: Path) -> None:
"""Inserted paragraph must have bidi + rtl + David font so it renders
correctly in Word alongside the user's content."""
"""Inserted paragraph must have bidi + an <w:rtl/> run marker so Hebrew
renders via the template's cs=David slot — but font/size come from the
template's named paragraph style (INV-EX4), not hand-set rFonts/sz."""
user_file = tmp_path / "user.docx"
_make_user_edited_docx(user_file)
docx_retrofit.retrofit_bookmarks(user_file, backup=False)
@@ -229,9 +230,10 @@ def test_rtl_preserved_in_tracked_insertion(tmp_path: Path) -> None:
if any("עברית RTL" in (t.text or "") for t in text_els):
rPr = r.find(_w("rPr"))
assert rPr is not None
# RTL marker preserved…
assert rPr.find(_w("rtl")) is not None
rFonts = rPr.find(_w("rFonts"))
assert rFonts is not None
assert rFonts.get(_w("ascii")) == "David"
# …but NO hand-set font/size on the run (INV-EX4).
assert rPr.find(_w("rFonts")) is None
assert rPr.find(_w("sz")) is None
return
pytest.fail("tracked insertion with 'עברית RTL' not found")

View File

@@ -0,0 +1,145 @@
"""Ad-hoc: executive summary (סיכום מנהלים) DOCX for case 1043-02-26.
Reuses the dafna decision template styles (David font + RTL) via the
analysis_docx_exporter helpers. One-off prep document for chaim's meeting
with the chair — NOT a decision draft.
"""
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "mcp-server" / "src"))
from docx import Document
from legal_mcp.services.analysis_docx_exporter import (
TEMPLATE_PATH,
_clear_body,
_add_paragraph,
_add_runs_with_inline_bold,
_mark_paragraph_rtl,
_mark_run_rtl,
)
CASE = "1043-02-26"
OUT = Path(f"/home/chaim/legal-ai/data/cases/{CASE}/exports/סיכום-מנהלים-v1.docx")
def H1(doc, t):
_add_paragraph(doc, t, "Heading 1")
def H2(doc, t):
_add_paragraph(doc, t, "Heading 2")
def P(doc, t):
p = doc.add_paragraph(style="Normal")
_add_runs_with_inline_bold(p, t)
_mark_paragraph_rtl(p)
return p
def BULLET(doc, t):
p = doc.add_paragraph(style="List Paragraph")
_add_runs_with_inline_bold(p, t)
_mark_paragraph_rtl(p)
return p
def LABEL(doc, label, value):
p = doc.add_paragraph(style="Normal")
r = p.add_run(label + ": ")
r.bold = True
_mark_run_rtl(r)
r2 = p.add_run(value)
_mark_run_rtl(r2)
_mark_paragraph_rtl(p)
return p
def main():
doc = Document(str(TEMPLATE_PATH))
_clear_body(doc)
H1(doc, "סיכום מנהלים — הכנה לדיון")
P(doc, "**ערר 1043-02-26 — הקמת מתקן למיון פסולת (אתר \"קומפוסט דלילה\", תכנית מי/1030)**")
P(doc, "מסמך הכנה פנימי לקראת דיון עם יו\"ר הוועדה. אינו החלטה ואינו טיוטת החלטה.")
H2(doc, "פרטי התיק")
LABEL(doc, "סוג הערר", "רישוי ובנייה (1xxx) — ערר על סירוב בקשה להיתר")
LABEL(doc, "עוררים (מבקשי ההיתר)", "קיבוץ נחשון; חברת אלקטרה אקו גרין פארק")
LABEL(doc, "משיבים", "הוועדה המקומית מטה יהודה; מושבי גפן/תירוש/כפר הריף; קיבוצי כפר מנחם/רבדים; מועצה אזורית יואב (מתנגדים)")
LABEL(doc, "מושא הערר", "החלטת הוועדה המקומית מטה יהודה מיום 9.2.26 לסרב לבקשה (מס' 20240972)")
LABEL(doc, "המקרקעין", "גוש 5093 ח\"ל 4 מגרש 1, אתר \"דלילה\" (~137 דונם), מצפון לכפר מנחם, ליד כביש 383")
LABEL(doc, "תקן ביקורת", "ועדת הערר כמוסד תכנון בעל סמכות מקורית — שיקול דעת תכנוני עצמאי; ביקורת רחבה יותר בחלק המשפטי-פרשני")
H2(doc, "מהות המחלוקת")
P(doc, "מבקשי ההיתר מבקשים להקים מתקן מיון פסולת/קומפוסטציה הכולל מבנה קומפוסטציה אחוד (~19,118 מ\"ר עיקרי), משטחי הבשלה פתוחים ובריכה תפעולית, בצירוף שתי הקלות: הגבהת גובה מ-8 ל-20 מ', והגדלת תכסית מ-3.5% ל-22%. הוועדה המקומית סירבה, בקובעה כי השינוי מהותי ומקומו בעדכון התכנית ולא בהליך רישוי. השאלה המרכזית: האם הבקשה תואמת את תכנית מי/1030, או חורגת ממנה באופן המחייב תיקון תכנית.")
H2(doc, "החלטת הוועדה המקומית (מושא הערר)")
P(doc, "**דחתה** את טענת המתנגדים לפקיעת התכנית (בהסתמך על ע\"א 3213/97 נקר) ואת טענת השימושים האסורים.")
P(doc, "**קיבלה** והפכה לבסיס הסירוב: (1) היקף הבינוי חורג משלב א' לפי הוראת השלביות; (2) הבינוי המונוליטי שונה דרמטית מנספח הבינוי; (3) משטחי ההבשלה הפתוחים מנוגדים לסעיף 6.9 (טיפול במבנים סגורים) — שהפרתו מוגדרת בתכנית כסטייה ניכרת.")
H2(doc, "טענות סף (לדיון תמציתי)")
BULLET(doc, "**פקיעת תכנית** — סעיף הפקיעה (5 שנים) הושמט מהנוסח המאושר (ראו ממצאי ניתוח-העומק).")
BULLET(doc, "**מיצוי התנגדות / \"מועד ב'\"** — האם טענות שהוכרעו בשלב התכנית מועלות מחדש בשלב הרישוי.")
BULLET(doc, "**זכות עמידה** — מועד חתימת חוזה החכירה מול מועד פתיחת הבקשה (הוכרע: קיבוץ נחשון חוכר רשום).")
BULLET(doc, "**פגמי פרסום / היעדר יידוע** (ס' 149(א)(2א)) — היעדר תשריט, אי-יידוע ועדה מקומית יואב.")
BULLET(doc, "**איחור בהגשת ההתנגדויות** — מול אינטרס ההסתמכות (רכישת קרקע ב-35 מיליון ₪).")
H2(doc, "חמש הסוגיות המהותיות")
P(doc, "**סוגיה 1 (מכריעה) — שלביות הביצוע: בינוי מול היקף פעילות**")
BULLET(doc, "השאלה: האם הוראת השלביות (ס' 7.1) חלה על הבינוי הפיזי או רק על היקף ההפעלה?")
BULLET(doc, "עמדות: עוררים — נוגעת להיקף הפעילות; ועדה/משיבים — חלה על הבינוי (4 מבנים ≈ 8,000 מ\"ר מול ~19,118 מבוקשים).")
BULLET(doc, "לאן נוטה: **לטובת הוועדה** — לשון \"הקמת 4 מבני קומפוסט\" מתייחסת לבינוי; נספחים 06, 11 מצביעים על מנגנון פיילוט מדורג מהותי.")
BULLET(doc, "תקדים: עע\"מ 10089/07 אירוס הגלבוע (אין לעקוף תכנון נדרש דרך היתר).")
P(doc, "**סוגיה 2 (מכריעה) — משטחי ההבשלה וסעיף 6.9: \"טיפול\" מול \"אחסון\"**")
BULLET(doc, "השאלה: האם ההבשלה הפתוחה היא \"טיפול\" החוסה תחת 6.9 (→ סטייה ניכרת החוסמת הקלה, ס' 151), או \"אחסון\"?")
BULLET(doc, "עמדות: עוררים (ד\"ר ענבר, נספח 22) — אחסון מוצר סופי נטול ריח; ועדה — חלק בלתי נפרד מהטיפול.")
BULLET(doc, "לאן נוטה: **נחלשה לוועדה** — נספח 07 §122-127: ועדת המשנה לעררים אישרה הבשלה פתוחה כ\"נכון וסביר\" (אך כינתה זאת \"שלב אחרון של טיפול\"). מתח פרשני אמיתי.")
BULLET(doc, "תקדים: עע\"מ 402/03 עמותת העצמאים (ס' 151 — סטייה ניכרת).")
P(doc, "**סוגיה 3 — ההקלות בגובה ובתכסית + טענת \"טעות סופר\"**")
BULLET(doc, "השאלה: האם ההקלות בגדרי הקלה או סטייה ניכרת? והאם התכסית 3.5% היא \"טעות סופר\"?")
BULLET(doc, "לאן נוטה: טענת טעות הסופר **התחזקה מאוד** — נספח 12 (מופקד) מראה ש-3.5% נגזרה מתפיסה שהחממות \"אינן שטח לבניה\"; נספחים 06 §14 ו-07 §130 הורו לתקן; הסכם רמ\"י נוקב ב-30,280 מ\"ר.")
BULLET(doc, "תקדים: עמ\"נ 25955-11-22 ברק-רחביה (פרשנות הרמונית); בג\"ץ 2667/17 מטה בנימין (\"פרשנות אפשרית\").")
P(doc, "**סוגיה 4 — תצורת הבינוי מול נספח בינוי מנחה ונספח נופי מחייב**")
BULLET(doc, "השאלה: האם המבנה המונוליטי + הבריכה חורגים ממרחב הגמישות של נספח מנחה, לנוכח הנספח הנופי המחייב?")
BULLET(doc, "תקדים: **ערר 1033-25 אבו גוש** (תקדים דפנה ישיר — נספח בינוי מנחה אינו המלצה בלבד); בג\"ץ 6525/15 עמק שווה.")
P(doc, "**סוגיה 5 — שיקולים זרים / NIMBY בהחלטת הסירוב**")
BULLET(doc, "השאלה: האם הסירוב נגוע בשיקולים זרים? (עשוי להתייתר אם הבחינה העצמאית מכריעה).")
BULLET(doc, "לאן נוטה: **נתמך בראיות** — תמליל (נספח 19, \"ועדה פוליטית\") + הצוות המקצועי המליץ לאשר (נספח 17). מנגד — בסיס מהותי לא-NIMBY (נספחים 25, 30).")
H2(doc, "ממצאי ניתוח-העומק — מה התחדש לאחר מיצוי 22 נספחי הרקע")
BULLET(doc, "**6.9 (ליבת התיק) נחלש לוועדה** — ועדת המשנה לעררים כבר אישרה הבשלה פתוחה.")
BULLET(doc, "**טעות הסופר בתכסית התחזקה** — שתי ערכאות הורו לתקן את חישוב השטח.")
BULLET(doc, "**פקיעה — הוכרע עובדתית** — סעיף הפקיעה היה בנוסח המופקד (נספח 12) והושמט מהמאושר; נותרה מחלוקת משפטית בלבד (נקר/לויתן מול חמדת הגליל).")
BULLET(doc, "**אופי המתקן מטה למשיבים** — תת\"ל 220 + ויתור על 8 מבני קומפוסט לטובת מתקן תרמי (נספחים 25, 30) → טיעון \"פריסת סלאמי\"/עקיפת תכנון.")
BULLET(doc, "**תיקון עובדתי** — פסה\"ד שדחה את העתירה (נספח 10) ניתן נגד תכנית דרך הגישה, לא נגד מי/1030.")
BULLET(doc, "**פער תחבורתי** — 80-92 משאיות/יום (מוסדות התכנון) מול 320-400 (ד\"ר לינק) — טעון יישוב.")
H2(doc, "פסיקה הדורשת אימות חיצוני (אינה בקורפוס הסמכותי)")
BULLET(doc, "ע\"א 3213/97 **נקר** — \"הדין המתפרסם ברבים מחייב\" (עוגן הוועדה לדחיית הפקיעה).")
BULLET(doc, "עע\"מ 4768/22 **חמדת הגליל** — פקיעת תכנית (עוגן המשיבים).")
BULLET(doc, "ע\"א 482/99 בלפוריה; בג\"ץ 5636/13 מתיישבי תימורים; בג\"ץ 9098/01 גניס; דנ\"א 3993/07 איקאפוד.")
H2(doc, "שאלות פתוחות להכרעת היו\"ר + סדר דיון מומלץ")
BULLET(doc, "(1) סיווג ההבשלה — טיפול (6.9) או אחסון (4.1.1)?")
BULLET(doc, "(2) דין הפקיעה לאור ההשמטה המוכחת מהנוסח המאושר.")
BULLET(doc, "(3) האם התכסית 3.5% היא טעות סופר הניתנת לתיקון פרשני?")
BULLET(doc, "(4) האם תת\"ל 220 + הוויתור על מבני הקומפוסט הופכים את הבקשה ל\"עקיפת תכנון\" (אירוס הגלבוע)?")
BULLET(doc, "(5) הסעד: סירוב מלא / אישור מותנה בהתאמה לשלב א' / החזרה לוועדה המקומית עם הנחיות.")
P(doc, "**סדר דיון מומלץ:** טענות סף (פקיעה → מיצוי/השתק → עמידה/פרסום) ← סוגיה 1 (שלביות) ← סוגיה 2 (6.9) ← סוגיה 3 (הקלות/טעות סופר) ← סוגיה 4 (תצורת בינוי) ← סוגיה 5 (שיקולים זרים).")
H2(doc, "הערכת תרחישים")
P(doc, "התמונה שקולה. לטובת הוועדה: סוגיה 1 (שלביות) וטיעון \"עקיפת תכנון/סלאמי\" נוכח תת\"ל 220 — חזקים. לטובת העוררים: סוגיה 2 (6.9) נחלשה, טעות הסופר התחזקה, ו-NIMBY נתמך בראיות. **התרחיש הסביר ביותר:** קבלה חלקית / החזרה מותנית או דחייה — תלוי בעיקר בשאלת היקף הבינוי בשלב א' ובשאלת \"עקיפת התכנון\". ההכרעה במובהק של יו\"ר הוועדה.")
OUT.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(OUT))
print(f"saved: {OUT}")
if __name__ == "__main__":
main()

View File

@@ -15,6 +15,7 @@
| `pc.sh` | bash | **wrapper לכל קריאות Paperclip API מסוכנים** — מוסיף Authorization, X-Paperclip-Run-Id (audit trail), Content-Type, base URL. תחביר: `pc.sh <METHOD> <PATH> [BODY_JSON]`. אסור `curl` ישיר ל-`$PAPERCLIP_API_URL`. ראה `HEARTBEAT.md §0`. counterpart ב-Python: `web/paperclip_api.py`. | נקרא ע"י סוכנים |
| `sync_agents_across_companies.py` | python | **סנכרון סוכנים מ-CMP (1xxx, master) ל-CMPA (8xxx, mirror)** — Gap #25. משווה adapter_config (model/timeout/instructions/skills/etc), runtime_config (heartbeat), ושדות top-level (budget/metadata/icon/title/role). מסנן אוטומטית local skills שלא קיימים ב-mirror. לוגיקת subset (mirror יכול להחזיק יותר skills כי ה-API מוסיף required runtime skills). תומך `--verify`/`--dry-run`/`--apply [--only NAME]`. גיבוי אוטומטי. דורש `PAPERCLIP_BOARD_API_KEY`. **להריץ אחרי כל שינוי הגדרות ב-CMP.** **⚠ אם `adapter_type` שונה בין CMP ל-CMPA — `--apply` מדלג על הסוכן; `--verify` מדווח אותו רם כ-DRIFT.** בעת מעבר adapter (למשל ל-`deepseek_local`) חובה לעדכן ידנית בשתי החברות. **`--verify` יוצא exit≠0 על כל drift** (needs-sync / adapter-mismatch / missing-in-mirror) — שמיש כ-gate ל-cron/CI (GAP-21/FU-8a). | ידני אחרי כל שינוי |
| `fix_paperclipai_skills_drift.py` | python | סקריפט חד-פעמי (בוצע 2026-05-04) שניקה drift על `paperclipai/*` skills בין CMP ל-CMPA. הסיר `paperclip-dev` מכל 14 הסוכנים, ודאג ש-`paperclip-converting-plans-to-tasks` קיים רק על CEO ו-analyst. תומך `--apply` (ברירת מחדל: dry-run). דורש `PAPERCLIP_BOARD_API_KEY`. נשמר לרפרנס למקרה שhdrift חוזר. | חד-פעמי (בוצע) |
| `classify_citation_treatments.py` | python | **סיווג-טיפול לקצוות-ציטוט (X11 Phase 2, #154)** — לכל קצה ב-`precedent_internal_citations` (החלטת-ועדה מצטטת תקדים) מסווג את ה-`treatment` מתוך ה-`match_context` דרך `corroboration.classify_treatment` (Opus 4.8 @ xhigh, claude_session **מקומי** — לא בקונטיינר): followed/explained=חיובי, distinguished/criticized/questioned/overruled=שלילי. ממלא `precedent_internal_citations.treatment` כך ש-`refresh_verified_layer` לא יספור ציטוט שלילי כסמכות (INV-COR2) ו-`db.citation_authority` יציג פירוק לסוכנים. אידמפוטנטי (מדלג על מסווגים). `--apply`/`--limit N`/`--case-law-id UUID`. **אחרי `--apply` הרץ `build_verified_layer.py`.** דורש `HOME=/home/chaim`. | ידני / אחרי גלי-ציטוט חדשים |
| `adapter_profiles.py` | python (module) | **רישום-פרופילי-אדפטר** — מקור-אמת יחיד ל-3 צירי-הכשל של מעבר-אדפטר: provider/default_model, instructions_mode (`file_path` בטוח-frontmatter מול `content_arg` ששובר `---`), ו-tool_config (`gemini_global` excludeTools / `frontmatter` / `hermes` / `codex_home`). כולל `codex_local` עם משפחת מודלי OpenAI/Codex (`gpt-*`, `o3*`, `o4*`, `codex-*`). מיובא ע"י `migrate_agent_adapter.py`. הוספת אדפטר עתידי = רשומה אחת. לא מורץ ישירות. | תשתית |
| `migrate_agent_adapter.py` | python | **מעבר-אדפטר בטוח לכל סוכן ← כל אדפטר, בשתי החברות יחד (INV-MC1)**. מיישב model↔provider, גורס frontmatter לעותק `.generated/<name>.nofm.md` ל-content_arg adapters (אחרת קריסת `gemini --prompt`/`hermes -q` על `---`), ומשחרר excludeTools גלובלי של gemini (`--relax-tools`). `--check` (preflight בלבד, exit≠0 על שגיאה — שער FU-8a) / `--apply` / `--revert` (שחזור מדויק מ-sidecar `data/adapter-migration-state.json`) / `--verify` (מסמן מצב לא-תואם/א-סימטרי, exit≠0). `--agent "<שם>"\|all --to <adapter> [--model X] [--relax-tools]`. PATCH דרך `/api/agents/{id}` (לא DB). דורש `PAPERCLIP_BOARD_API_KEY`. הרץ עם `mcp-server/.venv/bin/python`. **fallback-חירום כשנגמרים טוקני-Claude; החזר ל-claude_local כשחוזרים.** | ידני לפי צורך |
@@ -70,10 +71,13 @@
| `compute_principle_gold.py` | python | **#153 (נטוש)** — גישת זיהוי-זהב ברמת-עיקרון דרך התאמת-`match_context`→הלכה. **הוחלף** ע"י "מאומת=אזכור" (`build_verified_layer.py`) אחרי שההתאמה נכשלה (match_context=רשימת-הפניות). נשמר לעיון. | deprecated |
| `cull_principles.py` | python | **#152 Phase C — סינון רטרואקטיבי של קורפוס-העקרונות דרך פאנל-3 (הפיך).** מריץ על כל עיקרון 'original' קיים את אותו משטר שה-extractor משתמש בו להבא (`services/panel_extraction.panel_keep_score`, G2): 3 שופטים (Claude מקומי + DeepSeek + Gemini) מצביעים keep+score → כלל-האישור (3 קולות→שורד · 2 וציון≥0.85→שורד · 2 ו<0.85→יו"ר · ≤1→נדחה) → תקרת `HALACHA_PANEL_MAX_NEW`=5 לכל החלטה לפי ציון (`apply_cap`). נדחה → `halachot.review_status='rejected'` + ה-canonical שלו `rejected` (הפיך, גיבוי-CSV ב-`data/audit/` לפני כל כתיבה). מרוסן ב-`usage_limits` (עוצר-רך בתקרת-שימוש, resumable). `--dry-run` (ברירת-מחדל) / `--apply` / `--sample N` (החלטות אקראיות) / `--limit N` / `--no-throttle` / `--verbose`. **חובה מקומי** (3 שופטים). הרץ: `cd mcp-server && HOME=/home/chaim .venv/bin/python ../scripts/cull_principles.py --apply`. | **חד-פעמי** (סינון ראשוני) + ניתן-לחזרה |
| `backfill_canonical_synthesis.py` | python | **V41 Phase 4 — סינתזת-LLM ל-`canonical_statement` (idempotent + resumable).** עובר על canonicals ב-`review_status='pending_synthesis'` (רב-instance ראשונים) ומזקק לכל אחד ניסוח אחד כללי ומעוגן בציטוטי-המופעים (INV-AH) דרך `services/canonical_synthesis.py` (מסלול-יחיד, G2). שערים: עיגון/הימנעות, **drift-floor** (cosine מול המקור, ברירת-מחדל 0.80 — סטייה גדולה→נשמר המקור), ואיסור ציטוטי-תיק חדשים. בכל מקרה הסטטוס מתקדם ל-`pending_review` לשער-היו"ר (G10/INV-LRN6). מודל Opus (`HALACHA_CANONICAL_SYNTH_MODEL`). מרוסן ע"י `usage_limits` (עוצר-רך בתקרת-שימוש, resumable). `--dry-run` (ברירת-מחדל) / `--apply` / `--sample N` (מדגם אקראי לבדיקה) / `--limit N` / `--no-throttle` / `--verbose`. CSV-audit ל-`data/audit/canonical-synthesis-*.csv`. **חובה מקומי** (claude_session). הרץ: `cd mcp-server && HOME=/home/chaim .venv/bin/python ../scripts/backfill_canonical_synthesis.py --apply`. שוטף: כלי-MCP `canonical_synthesize_pending`. | **חד-פעמי** (המסה הראשונית) + idempotent לחדשים |
| `backfill_lesson_synthesis.py` | python | **#158 / INV-LRN8 — סינתזת-LLM של `decision_lessons` (idempotent + resumable).** עובר על shards של `practice_area`+`category` עם ≥2 לקחי-סגנון `approved`, מאשכל near-duplicates (cosine ≥ `LESSON_SYNTH_CLUSTER_THRESHOLD`), וממזג כל אשכול ל"לקח-על" אחד מעוגן-מקור (INV-AH) עם **drift-floor** (`LESSON_SYNTH_DRIFT_FLOOR` מול centroid) דרך `services/lesson_synthesis.py` (מסלול-יחיד, G2). הלקח-על נכתב `source='synthesis'` (`review_status='approved'`, שער-מדורג-הפיך), המקורות→`superseded`. כך הסט שזורם לכותב קטֵן ומשתבח (פותר את חיתוך limit=15, #157). מודל Opus (`LESSON_SYNTH_MODEL`), מרוסן `usage_limits`. `--dry-run` (ברירת-מחדל) / `--apply` / `--practice-area` / `--category` / `--no-throttle` / `--verbose`. CSV-audit ל-`data/audit/lesson-synthesis-*.csv`. **חובה מקומי** (claude_session). הרץ: `cd mcp-server && HOME=/home/chaim .venv/bin/python ../scripts/backfill_lesson_synthesis.py --apply`. שוטף: כלי-MCP `lesson_synthesize_pending`. | idempotent — הרצה לפי צורך/כשמצטברים לקחים |
| `halacha_batch_reconcile.py` | python | **#82.7** — dedup חוצה-פסקים offline (שמרני, **dry-run בלבד**). dedup-on-insert משווה רק תוך-פסק; כאן סף מחמיר (cosine ≥0.95, `--cosine`) ולא-הרסני: מאתר זוגות הלכות near-duplicate בין פסקים שונים (pgvector `<=>` exact) עם איתות לקסיקלי (Jaccard/Levenshtein) ומדווח ל-CSV ב-`data/audit/` לסקירת היו"ר. לא מדלג/ממזג/מוחק. `--include-pending`. **`--link`** רושם את הזוגות שנמצאו כ-`equivalent_halachot` (parallel authority, #84.2 — **deprecated post-V41** — השתמש ב-`backfill_canonical_halachot.py --apply` במקום). רץ עם venv של mcp-server. | **deprecated** — הוחלף ב-`backfill_canonical_halachot.py` (V41). נשמר לצורכי audit |
| `calibrate_halacha_dedup.py` | python | **#82.1** — כיול ספי ה-dedup הלקסיקלי (#82.3) מול gold-set הניקוי. קורא `halacha-cleanup-manifest-*.csv` (זוגות duplicate↔survivor מתויגי-אדם), טוען טקסט-survivor מה-DB, ו-sweep של (jaccard_min × levenshtein_min) עם P/R/F1, מסמן את נקודת-העבודה המוגדרת. אימת ש-(0.55, 0.70) → **precision 1.0** (אפס false-merge), recall 0.30 — מתאים לאיתות-משני שחוסם auto-approve. `--manifest <path>`. רץ עם venv של mcp-server | חד-פעמי — כיול (בוצע 2026-06-06) |
| `ab_halacha_opus48.py` | python | **A/B לא-הרסני לחילוץ הלכות (Claude)** — מריץ מחדש חילוץ הלכות על פסק-דין בודד דרך מודל/effort נבחרים (`AB_MODEL`/`AB_EFFORT`, ברירת-מחדל `claude-opus-4-8`/`xhigh`) ומשווה לסטטיסטיקות ההלכות הקיימות ב-DB **בלי למחוק/לכתוב כלום**. משכפל את `halacha_extractor.extract()` (אותם פרומפטים, בחירת-צ'אנקים, אימות-ציטוט) ומחליף רק את קריאת ה-LLM ב-`claude -p --model --effort`. מפיק `data/ab_halacha_<case>_<effort>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data .venv/bin/python scripts/ab_halacha_opus48.py <case_law_id>`. **ממצא 2026-05-31 (שטיין 1128-08-20):** Opus 4.8@xhigh חילץ 51 מול 124 בייצור (100% quote-verified מול 96%) אך ביטחון מכויל-נמוך יותר (חציון 0.75 מול 0.82) — ולכן **לא** מקטין את תור-האישור-הידני תחת sweep אוטו-אישור conf≥0.78 (26 מול 24). שיפור איכות, לא צמצום-תור. | ידני (החלטת מודל-חילוץ) |
| `ab_halacha_codex.py` | python | **A/B לא-הרסני לחילוץ הלכות (Codex/gpt-5.5)** — עמית ל-`ab_halacha_opus48` אך מחליף את `claude -p` ב-`codex exec --model gpt-5.5` (אימות ChatGPT, ללא OPENAI_API_KEY). אותם פרומפטים ואותו הסקת quote-verification. הפלט האחרון של הסוכן (`-o FILE`) נפענח כ-JSON. `AB_MODEL` (default `gpt-5.5`), `AB_REASONING` low/medium/high/xhigh (default `medium`), `AB_CONCURRENCY` (default 1), `CODEX_BIN`. מפיק `data/ab_halacha_codex_<case>_<model>_<reasoning>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/ab_halacha_codex.py <case_law_id>`. **ממצא 2026-06-17 (8181-21 האוניברסיטה העברית):** gpt-5.5@medium חילץ 27 מול 28 של Opus (quote-verified 100%/100%), ביטחון חציון 0.86 מול 0.78 — אך **0 פריטים מתחת ל-0.7** (לעומת 9/28 של Opus = 32%), דבר המצביע על over-confidence. holding↑ (12 מול 7), procedural↓ (4 מול 7). **מסקנה: ריאלי כ-fallback חירום; לא מוכן לייצור ללא כיול-ביטחון.** | ידני (בנצ'מרק מודל codex) |
| `calibrate_effort.py` | python | **#208 (WS5/Q1, INV-G8 eval-harness) — כיול model×effort של הכותב מול הסופיים.** A/B per-(תיק,בלוק,effort) על `draft_final_pairs` בעלי `final_text`: מייצר מחדש כל בלוק דרך מסלול-הייצור (`block_writer.write_block(effort_override=…)``claude_session.query``claude -p`, Opus 4.8 נעוץ, **מקומי-בלבד**) ומודד מול ה**סקשן** המתאים בסופי דרך `services/style_distance.block_distance_to_final` (מקור-מדידה יחיד, G2): `change_percent` (compute_diff_stats) · `anti_pattern_total` (`lessons.ANTI_PATTERNS`, הסיגנל הנקי-לסגנון) · `golden_ratio_deviation_pp` · `distance` מרוכב. **ממליץ** per-בלוק (style-clean, #213) לפי `anti_pattern_total` **ראשי** (סיגנל-הסגנון הנקי) → סטיית-יחסי-זהב → `distance` (tiebreak קלוש); **`change_percent` מדווח-לא-מדורג** (מערבב סגנון עם שלמות-תוכן, 07-learning §0.7). ההמלצה נושאת דגל-`confidence` ("clear"/"weak" — הובלת-anti < epsilon=max(0.5, 0.20·spread) ⇒ ⚠weak בתוך-הרעש, לא מוצג כוודאי) לצד ברירת-המחדל מ-#204. מפיק `data/eval/effort-calibration-<ts>.{json,md}` ב**כתיבה אינקרמנטלית** — הדוח נכתב מחדש אחרי כל בלוק שמסתיים, כך שקריסה באמצע הגריד לא מאבדת בלוקים שכבר יוצרו (INV-G8). כל תא-(תיק,בלוק,effort) שנכשל (rate-limit חולף, או חריגה) נרשם כ-warning ומדולג — לא מפיל את הריצה. **`block-yod` (דיון) הוצא מברירות-המחדל** (`CALIBRATABLE_BLOCKS`) — דורש כיוון-מאושר (brainstorm→approve_direction) שתיקי-הכיול חסרים ולכן `write_block` זורק; נשאר ב-`VALID_BLOCKS` לכפייה ידנית (`--blocks block-yod`) על-אחריות-המשתמש, כשהשער-per-תא שומר אותו לא-פטאלי. ⚠️ **גודל-מדגם מודפס בראש הדוח — עדות-כיוון, לא רגרסיה** (מעט סופיים-עלויים). `--self-test` (offline, אפס DB/CLI — מוכיח מדידה + דירוג-סגנון + confidence + rerank) · **`--rerank <report.json>`** (offline, אפס LLM/DB — מדרג-מחדש דוח-כיול שמור תחת המפתח החדש, מדפיס טבלה + כותב `*-reranked.md`) · `--dry-run` (תכנון-גריד) · `--efforts`/`--blocks`/`--case`/`--repeats`. **חובה מקומי** (claude CLI; לא בקונטיינר/worktree-ללא-CLI). הרצה: `POSTGRES_PASSWORD=… mcp-server/.venv/bin/python scripts/calibrate_effort.py`. | ידני — לכיול ברירות-effort של הכותב |
| `calibrate_block_yod.py` | python | **#212 (WS5/Q1, INV-G8 eval-harness) — כיול effort ל-בלוק י' (דיון והכרעה) בלבד.** ה-harness ש-#208 לא יכל לכייל: `write_block(block-yod)` זורק "ללא כיוון מאושר" כי בלוק-הדיון דורש `decisions.direction_doc.approved` שתיקי-הכיול חסרים. **התוספת על #208 — שכבת אספקת-כיוון read-only:** לפני ייצור בלוק-הדיון, ה-harness מזרים `direction_doc` שמיש דרך **overlay על קריאת-ה-DB** (monkeypatch של `db.get_decision_by_case`/`db.get_decision` לתהליך-הזה-בלבד) — **אפס כתיבה ל-DB**, מצב-תיק-הייצור לא משתנה (השער reverted ב-finally). **אסטרטגיית-אספקה per-תיק:** (1) **stored** (מועדף, נאמן) — כיוון-מאושר שמור (`approved`+`selected_direction`, או סכמת-8126 עם `direction_id`/`structure`); (2) **reconstructed** (נסיגה, עדות-כיוון-בלבד) — `direction_doc` מינימלי שנגזר מ-`decision.outcome` + סקשן-הדיון של הסופי **באותו תיק** (INV-LRN5: outcome+מצביע-לסופי-עצמי, אפס מהות חוצת-תיקים, מתויג `reconstructed`); (3) **skip** אם אין דיון/כיוון. מודד דרך אותו `style_distance.block_distance_to_final` (מקור-מדידה יחיד, G2) ומשתמש-חוזר ב-`_score_cell`/`recommend_effort` מ-`calibrate_effort` (#208). **ממצא-קורפוס (2026-06-30):** מ-8 סופיים, 7 ניתנים-לכיול; רק 8126-03-25 הוא stored+scorable (8174-12-24 שמור-אך-חסר-סקשן-דיון) → `--allow-reconstructed false` נותן תא-יחיד; ברירת-המחדל (reconstructed פעיל) נותנת 7 → הנסיגה היא מה שמפעיל את ה-harness היום. כתיבה אינקרמנטלית per-תיק + שער-per-תא לא-פטאלי (INV-G8). ⚠️ **גודל-מדגם בראש הדוח — עדות-כיוון, לא רגרסיה.** `--self-test` (offline, אפס DB/CLI — מוכיח מדידה + לוגיקת-אספקת-כיוון + overlay) · `--dry-run` (תכנון-גריד + מצב-אספקה per-תיק, צריך DB) · `--efforts`/`--case`/`--repeats`/`--allow-reconstructed`. ברירת-מחדל נוכחית: `xhigh` (`block_writer.py:72`, 'by feel'). **חובה מקומי** (claude CLI; לא בקונטיינר/worktree-ללא-CLI). הרצת-כיול-חי (לבצע ע"י היו"ר): `POSTGRES_PASSWORD=… POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5433 mcp-server/.venv/bin/python scripts/calibrate_block_yod.py`. | ידני — לכיול ה-effort של בלוק-הדיון (היו"ר מריץ; long, צורך-מכסה) |
| `monitor_halacha_quality.py` | python | מנטר איכות חילוץ הלכות. בודק drift של `avg(confidence)` בין baseline היסטורי לחלון אחרון. מחזיר JSON מטריקות + alert ב-stderr אם drift > threshold (ברירת מחדל 5%). 2 סדרות: trusted (approved+published) ו-all_extracted. תומך `--window N` / `--threshold X` / `--min-sample N` / `--silent` / `--exit-on-alert`. רץ ב-container או מקומית עם `mcp-server/.venv` (אין תלות ב-LLM, רק SQL). **תזמון מומלץ**: `0 8 * * 1` (יום ראשון 08:00, שבועי) | `0 8 * * 1` (לתזמן) |
| `audit_training_corpus.py` | python | audit של `style_corpus` — לכל החלטה: שדות מטא-דאטה מאוכלסים (`summary`/`outcome`/`key_principles`/`appeal_subtype`/`subject_categories`), קישור ל-`documents` (FK + chunks + embeddings). מפיק `data/audit/corpus-YYYY-MM-DD.json` + summary בקונסול. דרוש `POSTGRES_URL` או POSTGRES_*. אין תלויות חיצוניות מלבד asyncpg. **רץ מהמכונה המקומית** (לא קונטיינר) — חיבור ישיר ל-Postgres :5433 | ידני / קדם-עבודה לפני enrichment של מטא-דאטה |
| `backfill_style_exemplars.py` | python | **T1 (style-acquisition)** — מאכלס `style_exemplars` מקורפוס דפנה (`style_corpus` + `internal_committee` chair=דפנה): מפצל לסעיפים (`chunker._split_into_sections`) → פסקאות (25-450 מילים) → embed (Voyage) → שמירה עם `section`/`outcome`/`practice_area`. מאפשר לכותב לאחזר פסקאות-בלוק אמיתיות של דפנה (T2/T3). מקור-סגנון בלבד (INV-LRN5). אידמפוטנטי (מנקה per-decision). `--dry-run` (default) / `--apply`. דורש POSTGRES_URL + Voyage. **רץ מקומית** (venv). | ידני (`python scripts/backfill_style_exemplars.py --apply`) |
@@ -96,9 +100,11 @@
| `leak_guard.py` | python | **המאכף הקנוני של INV-G12 (שער-הפלטפורמה / docs/spec/X15 §4 / R4).** שני כללים קשיחים: (1) `mcp-server/src` ללא סמלי-Paperclip (allowlist מנומק לפי substring); (2) רק `web/agent_platform_port.py` (+ קבצי-המעטפת) מייבאים את לקוח-Paperclip. stdlib-בלבד (אין venv). `leak_guard.py` = סריקת-repo (exit 1 על הפרה); `leak_guard.py <file>...` = קבצים נתונים (ל-hook). משותף ל-spec-guard.sh (hook), ל-CI (`.gitea/workflows/leak-guard.yaml`) ול-`mcp-server/tests/test_platform_port_leak_guard.py`. | CI + hook + pytest |
| `check_undefined_names.py` | python | **CI gate ל-undefined names (מחלקת ה-NameError).** מריץ pyflakes על `web`, `mcp-server/src`, `scripts` ומפיל build (exit 1) רק על "undefined name"/"may be undefined" — לא על imports-לא-בשימוש/f-strings (רעש). זו בדיוק מחלקת-הבאג של PR #249 (שינוי-שם תיק → 500): שם שמופנה אך לא מיובא/מוגדר, חבוי בתוך `background_tasks` עד זמן-ריצה. דורש pyflakes (ה-workflow מתקין ל-venv זמני). משותף ל-CI (`.gitea/workflows/lint.yaml`). | CI |
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
| `host_sync.sh` | bash | מסנכרן את עץ-המארח `~/legal-ai` ל-origin/main (ff-only) כדי שקוד-המארח (כותב/פאנלים/MCP שרצים מהעץ, לא בקונטיינר) יתעדכן אחרי merge; restart מדויק ל-chat/court-fetch/reaper רק כשקבציהם משתנים. בטוח: אף-פעם לא force; tasks.json הדירטי נשמר. סוגר את פער-פריסת-המארח (TaskMaster #160) | `* * * * *` (cron, flock) |
| `notify.py` | python | שליחת מייל התראה מסוכנים via SMTP (Gmail) | נקרא ע"י סוכנים |
| `bidi_table.py` | python | יצירת טבלאות box-drawing עם תמיכה ב-BiDi (עברית+אנגלית) | ספריית עזר |
| `convert_decision_template.py` | python | המרת `data/training/טיוטת החלטה.dotx``skills/docx/decision_template.docx` לטעינה ב-python-docx | להריץ כשמתעדכנת התבנית |
| `regenerate_voice_fingerprint.py` | python | מסנתז מהקורפוס (style_corpus + דוגמאות + יחסים) **הצעה** לעדכון פרופיל-הקול (Opus מקומי). שער-יו"ר קשיח (INV-LRN1): כותב ל-`data/curator-proposals/`, לעולם לא דורס את `docs/daphna-voice-fingerprint.md` — היו"ר סוקרת ומקבעת ידנית. החצי-הפרוזה של #161 (החצי-המבני=ratios ב-PR#345). רץ על המארח (claude CLI). STYLE/METHOD בלבד (INV-LRN5) | ידני / לתזמן תקופתית |
| `deploy-track-changes.sh` | bash | סנכרון skills CMP↔CMPA + בדיקות + הנחיות deploy לארכיטקטורת Track Changes | ידני |
## חד פעמי
@@ -152,6 +158,7 @@
| Script | Type | Purpose | Scheduled |
|--------|------|---------|-----------|
| `ocr_benchmark_mistral.py` | python | **בנצ'מרק OCR — Mistral מול Google Vision** (מחקר חד-פעמי, הוביל למעבר ל-Mistral). מוריד מסמכים מ-MinIO, קורא טקסט קיים מה-DB, שולח ל-Mistral OCR, מחשב מטריקות (כיסוי/ניקיון/עברית%), שומר דוח ל-`data/audit/ocr-benchmark-mistral.md` + טקסטים גולמיים ל-`data/audit/ocr-benchmark-raw/`. הרצה: `mcp-server/.venv/bin/python scripts/ocr_benchmark_mistral.py`. דורש `MISTRAL_API_KEY` ו-mcli alias `legalminio`. **ממצא:** Mistral מנצח ב-4/5 תיקים בדוגמה ומטפל נכון ב-OCR שבור (1044-03-26 שהחזיר "English garbage" ב-Vision). | חד-פעמי (בוצע 2026-06-27) |
| `backfill_missing_precedents.py` | python | **הזנת `missing_precedents` פתוחים לתור-האחזור (X13)** — מסווג כל פער-פתוח; עליון-סדרתי→Tier-0(supremedecisions), נט-format→Tier-1; ועדת-ערר/לא-מזוהה→דילוג. יוצר `court_fetch_jobs` (idempotent). `--apply` (ברירת-מחדל dry-run). אחרי הרצה: drain-court-fetch קולט. | ידני (חד-פעמי/לפי-צורך) |
| `derive_missing_from_cited_only.py` | python | **#143 — איחוד cited_only↔missing_precedents (G2)**: גוזר רשומת `missing_precedents` 'open' לכל stub `cited_only` (פסיקה מצוטטת ללא טקסט), כך ש-31 ה-stubs מופיעים בדף "פסיקה חסרה" (היו היו חפיפה≈0). (1) backfill `citation_norm` (מפתח-dedup designator-aware — `court_citation.citation_dedup_key`) ל-291 הקיימים; (2) לכל stub → `create_missing_precedent(discovery_source='cited_only', linked_case_law_id=stub, notes=מצטטים)` עם dedup. `linked_case_law_id`=זהות-קנונית-ידועה, `status='open'` עד העלאת-טקסט (→ promote-in-place דרך ON CONFLICT). אידמפוטנטי, dry-run / `--apply`. הרצה: `HOME=/home/chaim mcp-server/.venv/bin/python scripts/derive_missing_from_cited_only.py --apply`. | חד-פעמי / re-runnable |
| `backfill_digest_missing_precedents.py` | python | **#136 — חיבור יומונים-לא-מקושרים ל"פסיקה חסרה"**: לכל digest עם `underlying_citation` ו-`linked_case_law_id IS NULL` (461) מריץ את `digest_library.try_autolink` הקנוני (G2) — מקשר אם אפשר, אחרת פותח gap: ערר/בל"מ/unknown → `missing_precedent` (discovery_source='digest', dedup designator-aware), פס"ד בתי-משפט → `court_fetch_job` (X13). dry-run מציג פילוח-tier (369 ערר + 21 unknown → MP; 71 fetchable → court_fetch). אידמפוטנטי. הרצה: `HOME=/home/chaim mcp-server/.venv/bin/python scripts/backfill_digest_missing_precedents.py --apply`. | חד-פעמי / re-runnable |
@@ -168,6 +175,7 @@
| Script | Original Purpose | Superseded By |
|--------|-----------------|---------------|
| `exec_summary_1043.py` | חד-פעמי — רינדור DOCX של סיכום-מנהלים לתיק 1043-02-26 דרך עוזרי-התבנית של `analysis_docx_exporter` (פרוטוטיפ). | `analysis_docx_exporter.build_party_claims_summary_docx()` + endpoint `GET /api/cases/{n}/research/party-claims-summary/export-docx` (WS3 #202; תבניתי, INV-EX4) |
| `backfill_pattern_frequency.py` | עדכון תדירות דפוסי סגנון ב-DB | `web/app.py::_extract_pattern_variants()` |
| `batch_upload_training.py` | העלאת קורפוס אימון (16 קבצים) | Web UI: `/api/training/upload` |
| `benchmark_embeddings.py` | השוואת מודלי embeddings (voyage-3 vs voyage-4) | הושלם — voyage-3-large נבחר |
@@ -198,7 +206,7 @@
| `drain_digests.py` | python | ריקון תור ההעשרה של יומונים (X12): מעבד כל digest בסטטוס `pending` דרך `digest_library.enrich_digest` (חילוץ-LLM Sonnet + embedding + autolink). מקבילי (CONCURRENCY=3, env-tunable), idempotent. מוסיף `~/.local/bin` ל-PATH כדי שה-claude CLI יימצא תחת cron. בודק דגל `drain_controls('legal-digest-drain')` ב-startup → no-op כשכבוי מ-/operations. | דרך `legal-digest-drain.config.cjs` (pm2 cron) + ידני אחרי backfill. חלופת-MCP: `digest_process_pending` |
| `legal-digest-drain.config.cjs` | pm2/js | **תזמון כל שעתיים של `drain_digests.py`** (cron `12 */2 * * *`, `DIGEST_DRAIN_CRON` לעקיפה; דקת-הצתה `:12` כדי לא לחלוק דקה עם metadata-drain `:00` — מונע deadlock של DDL-המיגרציה) — הועבר מ-crontab של המערכת ל-pm2 כדי שיופיע ויהיה שליט בדף `/operations` (הרץ-עכשיו/הפעל/כבה). `autorestart:false` (one-shot per tick). דורש claude CLI + `VOYAGE_API_KEY`. התקנה: `pm2 start scripts/legal-digest-drain.config.cjs && pm2 save`. | pm2 cron (host-side) |
| `renumber_cases.py` | python | **מיגרציה חד-פעמית (בוצעה 2026-06-12)** — תיקון 11 מספרי-תיקים לפורמט קנוני `NNNN-MM-YY` (הוספת ספרות-חודש; 1046-26→1024-02-26 תיקון-סידורי). רץ על ה-host (לא בקונטיינר): DB pool של האפליקציה + `mcli` (MinIO) + Gitea API + Paperclip DB. אטומי per-case עם גיבוי ל-`data/audit/` ואימות-אחרי. FK-ים על `cases.id` (UUID) לא נגעו; משכתב כל עמודה עם `cases/{old}/` (file_path **וגם** image_thumbnail_path שהוא storage-key בלי `/data`), מנרמל זהות חוצת-קורפוס (case_law/style_corpus/style_exemplars/citations — לא תוכן/full_text), מעביר מפתחי-MinIO ב-3 buckets (legal-immutable=WORM copy-only), משנה-שם repo ב-Gitea, ומעדכן שם-פרויקט ב-Paperclip. dry-run כברירת-מחדל; `--apply --tier clean\|archive`. **מיצוי — לא להריץ שוב** (ה-MAPPING היסטורי). | חד-פעמי — בוצע |
| `renumber_cases.py` | python | **מיגרציה חד-פעמית (בוצעה 2026-06-12)** — תיקון 11 מספרי-תיקים לפורמט קנוני `NNNN-MM-YY` (הוספת ספרות-חודש; 1046-26→1024-02-26 תיקון-סידורי). רץ על ה-host (לא בקונטיינר): DB pool של האפליקציה + `mcli` (MinIO) + Gitea API + Paperclip DB. אטומי per-case עם גיבוי ל-`data/audit/` ואימות-אחרי. FK-ים על `cases.id` (UUID) לא נגעו; משכתב כל עמודה עם `cases/{old}/` (file_path **וגם** image_thumbnail_path שהוא storage-key בלי `/data`), מנרמל זהות חוצת-קורפוס (case_law/style_corpus/style_exemplars/citations — לא תוכן/full_text), מעביר מפתחי-MinIO ב-3 buckets (legal-immutable=WORM copy-only), משנה-שם repo ב-Gitea, ומעדכן ב-Paperclip את **שם-הפרויקט + `plugin_state.legal-case-number` + כותרות-issues** (3 המשטחים ש-`get_case_issues` נשען עליהם — בלי (b)+(c) ה-issues נשארים על המספר הישן ו-run-learning/run-halacha מדלגים בשקט עם "no_issue"; תוקן 2026-06-28 אחרי שהפער התגלה ב-8137-11-24). dry-run כברירת-מחדל; `--apply --tier clean\|archive`. **מיצוי — לא להריץ שוב** (ה-MAPPING היסטורי); לתבנית-עתידית: ה-Paperclip-step המעודכן הוא הרפרנס. | חד-פעמי — בוצע |
## סקריפטים שנמחקו (git history בלבד)

View File

@@ -12,8 +12,6 @@ Mapping (removed → kept):
uploading → processing
in_progress → outcome_set
analyst_verified → documents_ready
research_complete → documents_ready
brainstorming → outcome_set
analysis_enriched → direction_approved
ready_for_writing → direction_approved
@@ -50,12 +48,14 @@ from legal_mcp.services import db # noqa: E402
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("status-trim")
# removed status → nearest preceding kept status
# removed status → nearest preceding kept status.
# NOTE (2026-06-30): analyst_verified + research_complete were RE-CANONICALISED
# into the status model (legal_mcp/case_status_model.py, "thinking" phase) — they
# are valid statuses again, so they were removed from this map. Do NOT downgrade
# them; only genuinely-removed markers remain below.
STATUS_MAP = {
"uploading": "processing",
"in_progress": "outcome_set",
"analyst_verified": "documents_ready",
"research_complete": "documents_ready",
"brainstorming": "outcome_set",
"analysis_enriched": "direction_approved",
"ready_for_writing": "direction_approved",

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Backfill — LLM synthesis of decision_lessons (#158 / INV-LRN8).
WHAT THIS DOES
--------------
Walks (practice_area, category) shards that hold ≥2 live APPROVED style lessons,
clusters near-duplicates (cosine), and asks a local ``claude_session`` model (Opus
by default) to merge each cluster into ONE richer, generalised "super-lesson"
grounded in the source lessons (INV-AH) with a drift guard. Accepted merges are
written as ``source='synthesis'`` rows (review_status='approved', graduated gate)
and their sources flip to ``superseded`` (provenance, no longer writer-fed). The
writer-fed set shrinks, so its limit=15 stops truncating (#157).
All logic lives in services/lesson_synthesis.py (G2) — this is the batch driver:
shard ordering, throttling, dry-run reporting and a CSV audit trail.
IDEMPOTENCY / RESUME
--------------------
Re-running is safe: superseded sources are excluded from candidates, and an accepted
merge that matches an existing synthesis (cosine) is skipped (duplicate_skipped).
USAGE
-----
cd ~/legal-ai/mcp-server
.venv/bin/python ../scripts/backfill_lesson_synthesis.py --dry-run # all shards, no writes
.venv/bin/python ../scripts/backfill_lesson_synthesis.py --dry-run --practice-area rishuy_uvniya --category style
.venv/bin/python ../scripts/backfill_lesson_synthesis.py --apply # full throttled run
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import os
import sys
from collections import Counter
from datetime import datetime, timezone
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
from legal_mcp.services import db, lesson_synthesis # noqa: E402
try: # stdlib-only module, importable from system python too
from legal_mcp.services import usage_limits
except Exception: # pragma: no cover
usage_limits = None
AUDIT_DIR = os.path.join(os.path.dirname(__file__), "..", "data", "audit")
def _throttled() -> tuple[bool, str]:
if usage_limits is None:
return False, "usage_limits unavailable"
usage = usage_limits.subscription_usage()
if usage is None:
return False, "usage read failed (proceeding)"
over, _reset, detail = usage_limits.ceiling_status(usage)
return over, detail
def _short(s: str, n: int = 100) -> str:
s = (s or "").replace("\n", " ")
return s if len(s) <= n else s[: n - 1] + ""
async def _run(apply: bool, practice_area: str, category: str,
throttle: bool, verbose: bool) -> int:
if practice_area and category:
shards = [{"practice_area": practice_area, "category": category}]
else:
shards = await db.synthesis_shards(min_size=2)
if practice_area:
shards = [s for s in shards if s["practice_area"] == practice_area]
if category:
shards = [s for s in shards if s["category"] == category]
mode = "APPLY" if apply else "DRY-RUN"
print(f"[{mode}] {len(shards)} shards with ≥2 approved lessons "
f"(throttle={'on' if throttle else 'off'})\n")
if not shards:
print("nothing to do.")
return 0
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
os.makedirs(AUDIT_DIR, exist_ok=True)
audit_path = os.path.join(
AUDIT_DIR, f"lesson-synthesis-{'apply' if apply else 'dryrun'}-{stamp}.csv")
counts: Counter[str] = Counter()
stopped = False
with open(audit_path, "w", newline="", encoding="utf-8") as fh:
w = csv.writer(fh)
w.writerow(["practice_area", "category", "cluster_size", "status",
"drift_cosine", "applied", "reason", "after", "member_ids"])
for n, s in enumerate(shards, 1):
if throttle:
over, detail = _throttled()
if over:
print(f"\n⏸ usage ceiling reached ({detail}) — stopping at "
f"shard {n - 1}/{len(shards)}. Re-run to resume.")
stopped = True
break
pa, cat = s["practice_area"], s["category"]
res = await lesson_synthesis.run_shard(pa, cat, apply=apply)
print(f"[{n}/{len(shards)}] {pa}/{cat}: {res['candidates']} candidates → "
f"{len(res['clusters'])} clusters")
for c in res["clusters"]:
counts[c["status"]] += 1
w.writerow([pa, cat, len(c["members"]), c["status"],
c.get("drift_cosine"), c.get("applied"),
c.get("reason", ""), c.get("proposed", ""),
"|".join(c["members"])])
mark = {"accepted": "", "duplicate_skipped": "=", "abstained": "·",
"drift_rejected": "", "llm_error": "!", "too_small": "·"}.get(c["status"], "?")
print(f" {mark} {c['status']:<18} size={len(c['members'])} "
f"drift={c.get('drift_cosine')}{' [written]' if c.get('applied') else ''}")
if verbose and c.get("proposed"):
print(f"{_short(c['proposed'])}")
processed = sum(counts.values())
print(f"\n── summary ({mode}) — {processed} clusters"
f"{' (stopped early)' if stopped else ''} ──")
for status, c in counts.most_common():
print(f" {status:<18} {c}")
print(f"\naudit CSV: {audit_path}")
if not apply:
print("dry-run — nothing written. Re-run with --apply to commit.")
return 0
def main() -> int:
p = argparse.ArgumentParser(description="LLM synthesis of decision_lessons (#158 / INV-LRN8)")
p.add_argument("--apply", action="store_true", help="commit to the DB (default: dry-run)")
p.add_argument("--dry-run", action="store_true", help="explicit dry-run (default)")
p.add_argument("--practice-area", default="", help="limit to one practice_area")
p.add_argument("--category", default="", help="limit to one category (style/structure/lexicon/tabular)")
p.add_argument("--no-throttle", action="store_true", help="skip usage-ceiling checks")
p.add_argument("--verbose", action="store_true", help="print merged text per cluster")
args = p.parse_args()
return asyncio.run(_run(
apply=args.apply, practice_area=args.practice_area, category=args.category,
throttle=not args.no_throttle, verbose=args.verbose,
))
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -19,40 +19,15 @@ import argparse
import asyncio
import logging
from legal_mcp.services import db, embeddings
from legal_mcp.services.chunker import _split_into_sections
from legal_mcp.services import db
from legal_mcp.services import style_exemplars as sx
logging.basicConfig(level=logging.INFO, format="%(message)s")
log = logging.getLogger("backfill_exemplars")
# chunker section_type → style_exemplars.section
_SECTION_MAP = {
"facts": "background",
"appellant_claims": "claims",
"respondent_claims": "claims",
"legal_analysis": "discussion",
"conclusion": "summary",
"ruling": "summary",
"intro": "other",
"other": "other",
}
MIN_WORDS = 25 # skip tiny fragments
MAX_WORDS = 450 # skip over-long blobs (likely un-split)
MAX_PER_SECTION = 15
def _paragraphs(section_text: str) -> list[str]:
"""Split a section into paragraph units (blank-line separated; fall back to lines)."""
raw = [p.strip() for p in section_text.split("\n\n")]
if len(raw) <= 1:
raw = [p.strip() for p in section_text.split("\n")]
out = []
for p in raw:
wc = len(p.split())
if MIN_WORDS <= wc <= MAX_WORDS:
out.append(p)
return out[:MAX_PER_SECTION]
# Section mapping + paragraph splitting now live in the shared service
# (legal_mcp.services.style_exemplars) so the backfill and the live
# final-enrollment path use ONE extraction implementation (G2).
async def _gather_sources() -> list[dict]:
@@ -94,28 +69,18 @@ async def main(apply: bool) -> None:
total_paras = 0
for src in sources:
units: list[tuple[str, str]] = [] # (section, paragraph)
for section_type, section_text in _split_into_sections(src["full_text"]):
section = _SECTION_MAP.get(section_type, "other")
for para in _paragraphs(section_text):
units.append((section, para))
if not units:
n = len(sx.units_for(src["full_text"]))
if not n:
continue
total_paras += len(units)
log.info(" %-14s %-16s%d פסקאות", src["source"], src["decision_number"], len(units))
total_paras += n
log.info(" %-14s %-16s%d פסקאות", src["source"], src["decision_number"], n)
if not apply:
continue
await db.delete_style_exemplars(src["decision_number"], src["source"])
texts = [u[1] for u in units]
vecs = await embeddings.embed_texts(texts, input_type="document")
for (section, para), vec in zip(units, vecs):
await db.insert_style_exemplar(
decision_number=src["decision_number"], source=src["source"],
practice_area=src["practice_area"], outcome=src["outcome"],
section=section, paragraph_text=para, word_count=len(para.split()),
embedding=vec,
)
await sx.extract_and_store(
decision_number=src["decision_number"], source=src["source"],
full_text=src["full_text"], practice_area=src["practice_area"],
outcome=src["outcome"],
)
if apply:
cov = await db.count_style_exemplars()

View File

@@ -0,0 +1,646 @@
#!/usr/bin/env python3
"""#212 (WS5 / Q1, INV-G8 eval-harness) — DEDICATED effort calibration for בלוק י'
(block-yod, the discussion/decision block — דיון והכרעה).
WHY A SEPARATE HARNESS (the #208 harness could NOT calibrate block-yod):
block-yod is the most important block — its effort is currently `xhigh`, set
"by feel" (block_writer.py:72), never empirically calibrated. The general #208
harness (scripts/calibrate_effort.py) EXCLUDES it from CALIBRATABLE_BLOCKS
because block_writer.write_block(case_id, "block-yod", …) raises
ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר…")
— the discussion block requires an APPROVED DIRECTION (brainstorm →
approve_direction → decisions.direction_doc{"approved": True, …}) that the
calibration cases (the draft_final_pairs finals) do not have. So block-yod is
not calibratable standalone without first SUPPLYING a direction.
WHAT THIS HARNESS ADDS over #208 — the DIRECTION-SUPPLY layer (read-only):
Before regenerating block-yod for a calibration case, it makes a usable
`direction_doc` available to write_block via a READ-ONLY DB OVERLAY (it
monkeypatches db.get_decision_by_case / db.get_decision FOR THIS PROCESS ONLY
to overlay the direction into the returned decision dict). NOTHING is written
to the DB — production case state is never mutated (no UPDATE), satisfying the
"do not pollute real case state" constraint. The overlay is reverted in a
finally block.
DIRECTION-SUPPLY STRATEGY (per-case, documented in the report):
1. STORED (preferred, most faithful): if the case's decision already carries
a usable approved direction (direction_doc.approved is truthy and it has a
selected_direction, OR the 8126-style schema with direction_id/structure),
use it verbatim. This is the production-faithful path.
2. RECONSTRUCTED (fallback, directional-only): otherwise synthesize a MINIMAL
direction_doc from the case's decision.outcome + the final's OWN discussion
section (the chair's signed reasoning is the ground truth for "what
direction was taken"). Clearly LABELLED reconstructed in the report → the
resulting numbers are directional, not faithful to a real brainstorm.
INV-LRN5: the reconstructed direction carries the case's OWN outcome +
a neutral pointer to its OWN final — no substance dragged across cases.
EMPIRICAL NOTE (measured on the current corpus, 2026-06-30): of the 8 finals,
only 8174-12-24 has a STORED approved direction — and its final has NO parsable
discussion section (0 words), so it is not scorable for block-yod anyway. The 7
finals that DO have a scorable discussion section all LACK a usable stored
direction. Hence the RECONSTRUCTED fallback is what makes this harness useful
today; --dry-run prints the per-case supply mode so the operator sees this.
WHAT IT MEASURES (identical to #208, REUSED — no parallel metric path, G2):
Regenerate block-yod at each effort via block_writer.write_block(effort_override)
(the PRODUCTION generation path → claude_session.query → `claude -p`, pinned
Opus 4.8, local-only), then score vs the final's DISCUSSION section via
services.style_distance.block_distance_to_final (change_percent /
anti_pattern_total / golden_ratio_deviation_pp / composite distance). The
recommend_effort, aggregate_cell, _score_cell and direction-free scaffolding are
imported from calibrate_effort (#208) — this file only supplies the direction
and restricts the grid to block-yod.
GENERATION PATH (do not violate — reference_claude_generation_path /
claude_session docstring): write_block → claude_session.query → `claude -p` uses
the local claude.ai session. It runs ONLY on the host where the `claude` CLI
exists (NOT the legal-ai container, NOT a symlinked worktree without CLI access).
Hence the live A/B is HOST-ONLY; --self-test proves the measurement +
direction-supply logic offline with zero model calls.
⚠️ SAMPLE-SIZE CAVEAT (honored, not hidden): very few cases have an uploaded
final WITH a parsable discussion section. The report prints n_finals PROMINENTLY
and labels the output DIRECTIONAL EVIDENCE, not a regression. With n<3 the
recommendation is advisory only; the chair/operator decides whether to adopt.
Usage (mcp-server venv; live needs POSTGRES + the `claude` CLI on the host):
PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python
$PY scripts/calibrate_block_yod.py --self-test # offline proof, no DB/CLI
$PY scripts/calibrate_block_yod.py --dry-run # plan grid + supply modes (needs DB, no model)
POSTGRES_PASSWORD=… POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5433 \
$PY scripts/calibrate_block_yod.py # live A/B over finals (host-only)
… --efforts low,medium,high,xhigh # override the effort grid
… --case 1130-08-25 # a single case
… --repeats 2 # avg N gens/cell (noise)
… --allow-reconstructed false # STORED-direction cases only (faithful)
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
import sys
from datetime import datetime
from pathlib import Path
from statistics import mean
logger = logging.getLogger(__name__)
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "mcp-server" / "src"))
sys.path.insert(0, str(REPO_ROOT / "scripts"))
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')}"
)
# Reuse the #208 machinery wholesale (G2 — no parallel harness/metric path).
# These are pure/offline-safe imports; calibrate_effort does no work at import.
from calibrate_effort import ( # noqa: E402
IL_TZ,
OUT_DIR,
VALID_EFFORTS,
_current_default,
_finals_for_calibration,
_score_cell,
recommend_effort,
)
BLOCK_ID = "block-yod"
SECTION = "discussion" # block-yod's golden-ratio section (style_distance._BLOCK_TO_SECTION)
DEFAULT_EFFORTS = ["low", "medium", "high", "xhigh"]
# ── direction supply (the block-yod-specific piece) ──────────────────────────
def _stored_direction_is_usable(direction_doc: dict | None) -> bool:
"""True if the case's stored direction_doc can drive write_block(block-yod).
write_block gates on `direction_doc.get("approved")` being truthy. Two stored
schemas exist in the corpus:
• brainstorm schema (build_direction_doc): {"approved": True, "selected_direction": …}
• chair-approved schema (8126-style): {"direction_id", "direction_name",
"structure", "template", …} — has NO "approved" key, so write_block would
reject it as-is. We treat it as usable IFF it carries a concrete direction
(direction_id/direction_name), and _normalize_stored_direction() stamps
approved=True onto the OVERLAY copy (never the DB).
Empty / brainstorm-only ({"brainstorm": …, "approved": False}) → not usable.
"""
dd = direction_doc or {}
if dd.get("approved") and (dd.get("selected_direction") or dd.get("reasoning")):
return True
if dd.get("direction_id") or dd.get("direction_name"):
return True
return False
def _normalize_stored_direction(direction_doc: dict) -> dict:
"""Return an overlay copy of a usable stored direction with approved=True.
Pure dict transform on a COPY — the real decisions.direction_doc is untouched.
The 8126-style schema lacks `approved`; stamping it here lets write_block's gate
pass without us mutating the DB.
"""
dd = dict(direction_doc)
dd["approved"] = True
return dd
def reconstruct_minimal_direction(outcome: str, final_discussion: str) -> dict:
"""Build a MINIMAL, clearly-reconstructed direction_doc (fallback path).
Faithful enough to make write_block(block-yod) runnable, honest about being a
reconstruction: the chair's SIGNED final discussion is the ground truth for
"what direction was taken", and the case's OWN outcome fixes the structure.
INV-LRN5: case's own outcome + a neutral pointer to its own final only — no
cross-case substance. Pure (no DB/LLM) → unit-tested in --self-test.
"""
from legal_mcp.services.lessons import OUTCOME_LABELS_HE, canonical_outcome
oc = canonical_outcome(outcome or "rejection")
snippet = " ".join((final_discussion or "").split()[:120])
return {
"approved": True,
"reconstructed": True, # marker so the report can label it
"outcome": oc,
"outcome_hebrew": OUTCOME_LABELS_HE.get(oc, oc),
"reasoning": (
"כיוון משוחזר לצורך כיול-effort בלבד (לא סיעור-מוחות אמיתי): "
"התוצאה והכיוון נגזרים מההחלטה החתומה של היו\"ר בתיק זה. "
"תמצית פתיח-הדיון של הסופי: " + snippet
),
"selected_direction": {
"name": f"כיוון משוחזר — {OUTCOME_LABELS_HE.get(oc, oc)}",
"reasoning": [
"שחזר את שלד-הנימוק מהדיון החתום של היו\"ר בתיק זה.",
],
"precedents": [],
},
"additional_notes": (
"⚠️ כיוון משוחזר — תוצאת-הכיול היא עדות-כיוון, לא נאמנה למסמך-כיוון אמיתי."
),
}
def plan_direction_supply(
decision: dict | None,
outcome: str,
final_discussion: str,
allow_reconstructed: bool,
) -> tuple[dict | None, str]:
"""Decide how to supply block-yod's direction for one case.
Returns (overlay_direction_doc | None, mode) where mode ∈
{"stored", "reconstructed", "skip"}. Pure (no I/O) → unit-tested.
"""
stored = (decision or {}).get("direction_doc")
if _stored_direction_is_usable(stored):
return _normalize_stored_direction(stored), "stored"
if allow_reconstructed and (final_discussion or "").strip():
return reconstruct_minimal_direction(outcome, final_discussion), "reconstructed"
return None, "skip"
class _DirectionOverlay:
"""Read-only overlay: make db.get_decision_by_case / db.get_decision return the
case's decision WITH `direction_doc` replaced by the supplied overlay — for the
target case_id only, for the duration of one cell. Reverted in __exit__.
write_block re-fetches the decision internally (block_writer.py:383), so passing
a direction dict in isn't enough — the DB read must yield it. We patch the read,
not the write: ZERO DB mutation (no UPDATE ever issued).
"""
def __init__(self, case_id, direction_doc: dict):
self._case_id = str(case_id)
self._dir = direction_doc
self._orig_by_case = None
self._orig_by_id = None
def __enter__(self):
from legal_mcp.services import db
self._orig_by_case = db.get_decision_by_case
self._orig_by_id = db.get_decision
async def patched_by_case(case_id, _orig=self._orig_by_case):
dec = await _orig(case_id)
if dec and str(case_id) == self._case_id:
dec = dict(dec)
dec["direction_doc"] = self._dir
return dec
async def patched_by_id(decision_id, _orig=self._orig_by_id):
dec = await _orig(decision_id)
if dec and dec.get("case_id") == self._case_id:
dec = dict(dec)
dec["direction_doc"] = self._dir
return dec
db.get_decision_by_case = patched_by_case
db.get_decision = patched_by_id
return self
def __exit__(self, *exc):
from legal_mcp.services import db
if self._orig_by_case is not None:
db.get_decision_by_case = self._orig_by_case
if self._orig_by_id is not None:
db.get_decision = self._orig_by_id
return False
async def _score_block_yod_cell(case_id, effort, final_section, final_total_words,
outcome, repeats, direction_doc):
"""Score one (case, block-yod, effort) cell with the direction overlaid.
Wraps the #208 _score_cell (REUSED) inside the read-only direction overlay so
write_block's approved-direction gate passes without DB mutation.
"""
with _DirectionOverlay(case_id, direction_doc):
return await _score_cell(
case_id, BLOCK_ID, effort, final_section, final_total_words, outcome, repeats,
)
# ── self-test (no DB, no model) ──────────────────────────────────────────────
def _self_test() -> int:
ok = True
def chk(name, cond):
nonlocal ok
ok = ok and cond
print(f" {name:50} {'ok' if cond else 'FAIL'}")
# measurement reuse: block_distance_to_final on the discussion section.
from legal_mcp.services.style_distance import block_distance_to_final, split_final_by_section
final_disc = "אנו סבורים כי דין הערר להידחות. כידוע, הלכה פסוקה היא. " * 30
d_same = block_distance_to_final(BLOCK_ID, final_disc, final_disc, "rejection",
section_target_total_words=len(final_disc.split()))
chk("identical regen ⇒ change_percent==0", d_same["change_percent"] == 0.0)
chk("identical regen ⇒ section==discussion", d_same["section"] == SECTION)
clean = "אין בידנו לקבל את הטענה. יחד עם זאת, מצאנו טעם. " * 20
dirty = "## כותרת\n- נקודה\n- נקודה\n* עוד\n### תת\n" * 10
d_clean = block_distance_to_final(BLOCK_ID, clean, final_disc, "rejection")
d_dirty = block_distance_to_final(BLOCK_ID, dirty, final_disc, "rejection")
chk("dirty regen ⇒ more anti-patterns", d_dirty["anti_pattern_total"] > d_clean["anti_pattern_total"])
chk("dirty regen ⇒ larger distance", d_dirty["distance"] > d_clean["distance"])
# ── direction-supply logic ──
chk("empty direction ⇒ not usable", not _stored_direction_is_usable({}))
chk("brainstorm-only ⇒ not usable",
not _stored_direction_is_usable({"brainstorm": {"directions": [1]}, "approved": False}))
chk("approved+selected ⇒ usable",
_stored_direction_is_usable({"approved": True, "selected_direction": {"name": "x"}}))
chk("8126-style (direction_id) ⇒ usable",
_stored_direction_is_usable({"direction_id": "d1", "structure": "..."}))
src = {"direction_id": "d1", "structure": "..."}
norm = _normalize_stored_direction(src)
chk("normalize stamps approved=True", norm["approved"] is True)
chk("normalize does not mutate source", "approved" not in src)
rec = reconstruct_minimal_direction("rejection", final_disc)
chk("reconstruct ⇒ approved", rec["approved"] is True)
chk("reconstruct ⇒ marked reconstructed", rec["reconstructed"] is True)
chk("reconstruct ⇒ canonical outcome", rec["outcome"] == "rejection")
chk("reconstruct ⇒ has selected_direction", bool(rec.get("selected_direction")))
chk("reconstruct ⇒ no cross-case precedents (INV-LRN5)",
rec["selected_direction"]["precedents"] == [])
# _build_direction_context must accept BOTH supply shapes (write_block uses it).
from legal_mcp.services.block_writer import _build_direction_context
ctx_recon = _build_direction_context({"direction_doc": rec})
chk("recon direction renders (not 'לא אושר')", "כיוון לא אושר" not in ctx_recon)
ctx_stored = _build_direction_context({"direction_doc": norm})
chk("stored(8126) direction renders", "כיוון לא אושר" not in ctx_stored)
# plan_direction_supply: stored wins; else reconstructed; else skip.
dd_stored = {"direction_doc": {"approved": True, "selected_direction": {"name": "x"}}}
_, m1 = plan_direction_supply(dd_stored, "rejection", final_disc, allow_reconstructed=True)
chk("plan: stored-direction case ⇒ stored", m1 == "stored")
_, m2 = plan_direction_supply({"direction_doc": {}}, "rejection", final_disc, True)
chk("plan: no stored + discussion ⇒ reconstructed", m2 == "reconstructed")
_, m3 = plan_direction_supply({"direction_doc": {}}, "rejection", final_disc, False)
chk("plan: no stored + reconstructed disabled ⇒ skip", m3 == "skip")
_, m4 = plan_direction_supply({"direction_doc": {}}, "rejection", "", True)
chk("plan: no stored + no discussion ⇒ skip", m4 == "skip")
# overlay monkeypatch installs + reverts cleanly (no live DB needed).
import legal_mcp.services.db as real_db
saved_bc, saved_bi = real_db.get_decision_by_case, real_db.get_decision
async def fake_by_case(cid):
return {"id": "d", "case_id": "C1", "direction_doc": {"approved": False}}
async def fake_by_id(did):
return {"id": "d", "case_id": "C1", "direction_doc": {"approved": False}}
real_db.get_decision_by_case = fake_by_case
real_db.get_decision = fake_by_id
try:
ov_dir = {"approved": True, "reconstructed": True}
async def _probe():
with _DirectionOverlay("C1", ov_dir):
d1 = await real_db.get_decision_by_case("C1")
d2 = await real_db.get_decision_by_case("OTHER")
d3 = await real_db.get_decision("d")
return d1, d2, d3
# --self-test is dispatched at the __main__ entry, OUTSIDE asyncio.run,
# so a plain asyncio.run here is safe (no enclosing event loop).
d1, d2, d3 = asyncio.run(_probe())
chk("overlay: target case gets overlaid direction", d1["direction_doc"]["approved"] is True)
chk("overlay: non-target case untouched", d2["direction_doc"]["approved"] is False)
chk("overlay: get_decision path overlaid", d3["direction_doc"]["reconstructed"] is True)
chk("overlay: reverted after context", real_db.get_decision_by_case is fake_by_case)
finally:
real_db.get_decision_by_case = saved_bc
real_db.get_decision = saved_bi
# reused #208 primitives still behave.
r = recommend_effort([
{"effort": "high", "distance": 0.22, "anti_pattern_total": 2, "change_percent": 25, "n": 1},
{"effort": "xhigh", "distance": 0.22, "anti_pattern_total": 1, "change_percent": 22, "n": 1},
])
chk("recommend tie → fewer anti-patterns", r["effort"] == "xhigh")
chk("current default for block-yod == xhigh", _current_default(BLOCK_ID) == "xhigh")
secs = split_final_by_section(
"רקע עובדתי\nהמקרקעין. " * 8 + "\n\nדיון והכרעה\nאנו סבורים. " * 8)
chk("split returns golden sections only",
set(secs).issubset({"background", "claims", "discussion", "summary"}))
print("ALL PASS" if ok else "*** FAILURES ***")
return 0 if ok else 1
# ── live A/B (host-only — needs DB + `claude` CLI) ───────────────────────────
async def _gather_cases(case_filter, allow_reconstructed):
"""Build per-case metadata + direction-supply plan over the finals."""
from uuid import UUID
from legal_mcp.services import db
from legal_mcp.services.lessons import canonical_outcome
from legal_mcp.services.style_distance import split_final_by_section
finals = await _finals_for_calibration(case_filter)
cases = []
for f in finals:
case = await db.get_case_by_number(f["case_number"]) if f.get("case_number") else None
if not case:
continue
decision = await db.get_decision_by_case(UUID(case["id"]))
outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
sections = split_final_by_section(f.get("final_text", ""))
final_discussion = sections.get(SECTION, "")
final_total_words = len((f.get("final_text", "") or "").split())
overlay, mode = plan_direction_supply(
decision, outcome, final_discussion, allow_reconstructed)
cases.append({
"case_number": f["case_number"],
"case_id": case["id"],
"outcome": outcome,
"final_discussion": final_discussion,
"final_discussion_words": len(final_discussion.split()),
"final_total_words": final_total_words,
"direction": overlay,
"supply_mode": mode,
# scorable IFF it has a discussion section AND a supplied direction.
"scorable": bool(final_discussion.strip() and overlay is not None),
})
return cases
async def _run(args, ts):
from uuid import UUID
cases = await _gather_cases(args.case, args.allow_reconstructed)
scorable = [c for c in cases if c["scorable"]]
grid_summary = {
"block": BLOCK_ID,
"section": SECTION,
"n_finals": len(cases),
"n_scorable": len(scorable),
"current_default": _current_default(BLOCK_ID),
"efforts": args.efforts,
"repeats": args.repeats,
"total_generations": len(scorable) * len(args.efforts) * args.repeats,
"allow_reconstructed": args.allow_reconstructed,
"cases": [
{"case_number": c["case_number"], "supply_mode": c["supply_mode"],
"scorable": c["scorable"], "outcome": c["outcome"],
"discussion_words": c["final_discussion_words"]}
for c in cases
],
}
if args.dry_run:
return {"dry_run": True, "grid": grid_summary, "by_effort": []}
per_effort_runs = {e: [] for e in args.efforts}
per_case = []
for c in scorable:
case_cells = []
for effort in args.efforts:
# Per-cell guard (INV-G8): any single (case, effort) failure — a
# transient rate-limit, a too-large prompt, an unexpected raise — is
# logged and skipped, never allowed to abort the whole run.
try:
cell = await _score_block_yod_cell(
UUID(c["case_id"]), effort, c["final_discussion"],
c["final_total_words"], c["outcome"], args.repeats, c["direction"],
)
except Exception as exc: # noqa: BLE001 — harness must survive any cell failure
logger.warning(
"block-yod cell skipped: case=%s effort=%s mode=%s%s",
c["case_number"], effort, c["supply_mode"], exc,
)
continue
per_effort_runs[effort].append(cell)
case_cells.append({k: cell[k] for k in
("effort", "distance", "anti_pattern_total",
"change_percent", "golden_ratio_deviation_pp", "n")})
per_case.append({"case_number": c["case_number"],
"supply_mode": c["supply_mode"], "cells": case_cells})
# Incremental persistence (INV-G8): flush after EACH case so a crash later
# never loses cases already generated. _write_report tolerates partial rows.
try:
_write_report({"dry_run": False, "grid": grid_summary,
"by_effort": _effort_rows(per_effort_runs, args.efforts),
"per_case": per_case,
"recommended": (recommend_effort(_effort_rows(per_effort_runs, args.efforts)) or {}).get("effort")},
ts)
except Exception as exc: # noqa: BLE001 — a write hiccup must not abort the run
logger.warning("incremental report write failed after case=%s%s",
c["case_number"], exc)
effort_rows = _effort_rows(per_effort_runs, args.efforts)
rec = recommend_effort(effort_rows)
return {"dry_run": False, "grid": grid_summary, "by_effort": effort_rows,
"per_case": per_case, "recommended": rec["effort"] if rec else None}
def _effort_rows(per_effort_runs, efforts):
"""Mean each metric across cases for each effort → one comparable row/effort."""
rows = []
for effort in efforts:
cells = per_effort_runs[effort]
if not cells:
continue
ratios = [c["golden_ratio_deviation_pp"] for c in cells
if c.get("golden_ratio_deviation_pp") is not None]
rows.append({
"effort": effort,
"distance": round(mean(c["distance"] for c in cells), 4),
"anti_pattern_total": round(mean(c["anti_pattern_total"] for c in cells), 2),
"change_percent": round(mean(c["change_percent"] for c in cells), 2),
"golden_ratio_deviation_pp": round(mean(ratios), 2) if ratios else None,
"n": len(cells),
})
return rows
# ── report ───────────────────────────────────────────────────────────────────
def _ts():
return datetime.now(IL_TZ).strftime("%Y%m%dT%H%M%S-IL")
def _write_report(result, ts):
OUT_DIR.mkdir(parents=True, exist_ok=True)
jp = OUT_DIR / f"block-yod-calibration-{ts}.json"
mp = OUT_DIR / f"block-yod-calibration-{ts}.md"
jp.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
g = result["grid"]
il_now = datetime.now(IL_TZ).strftime("%Y-%m-%d %H:%M")
n_recon = sum(1 for c in g["cases"] if c["supply_mode"] == "reconstructed")
n_stored = sum(1 for c in g["cases"] if c["supply_mode"] == "stored")
n_skip = sum(1 for c in g["cases"] if c["supply_mode"] == "skip")
lines = [
f"# #212 — כיול effort לבלוק י' (דיון והכרעה) מול הסופיים — {ts}\n",
f"> נוצר: {il_now} (שעון ישראל · Asia/Jerusalem)\n",
f"> ⚠️ **גודל-מדגם: {g['n_scorable']} תיקים ניתנים-לכיול** "
f"(מתוך {g['n_finals']} סופיים). זוהי **עדות-כיוון, לא רגרסיה**. "
"ההמלצה אדוויזורית; ההכרעה בידי היו\"ר/המפעיל.\n",
f"> אספקת-כיוון: {n_stored} מאוחסן · {n_recon} משוחזר · {n_skip} מדולג. "
"כיוון **משוחזר** = נגזר מתוצאת-ההחלטה + פתיח-הדיון של הסופי באותו תיק "
"(לא סיעור-מוחות אמיתי) → התוצאה נאמנה-לכיוון פחות מתיק עם כיוון-מאושר שמור.\n",
f"- בלוק: {g['block']} (section={g['section']}) · ברירת-מחדל נוכחית: "
f"**{g['current_default'] or ''}** (נקבע 'by feel', block_writer.py:72)",
f"- efforts: {', '.join(g['efforts'])} · repeats/cell: {g['repeats']}",
f"- סך ייצורי-מודל: {g['total_generations']}",
"",
"## אספקת-כיוון per-תיק\n",
"| case | supply_mode | discussion_words | outcome | scorable |",
"|---|---|---|---|---|",
]
for c in g["cases"]:
lines.append(
f"| {c['case_number']} | {c['supply_mode']} | {c['discussion_words']} | "
f"{c['outcome']} | {'' if c['scorable'] else ''} |")
lines.append("")
if result.get("dry_run"):
lines += ["## DRY-RUN — תכנון הגריד בלבד (ללא ייצור)\n",
f"- תיקים ניתנים-לכיול: **{g['n_scorable']}** · "
f"ייצורים מתוכננים: **{g['total_generations']}**",
""]
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
return jp, mp
rec = result.get("recommended") or ""
mark = "" if rec == g["current_default"] else " ⬅︎ שינוי מומלץ"
lines += [
f"## המלצה: effort = **{rec}**{mark} (current = {g['current_default'] or ''})\n",
"## טבלת effort (distance נמוך = קרוב יותר לדיון של דפנה)\n",
"| effort | distance | anti_total | change% | ratioΔpp | n |",
"|---|---|---|---|---|---|",
]
for r in result.get("by_effort", []):
star = "" if r["effort"] == result.get("recommended") else ""
ratio = r["golden_ratio_deviation_pp"]
lines.append(
f"| {r['effort']}{star} | {r['distance']:.4f} | {r['anti_pattern_total']} | "
f"{r['change_percent']} | {ratio if ratio is not None else ''} | {r['n']} |")
lines.append("")
lines += ["## פירוט per-תיק\n", "| case | mode | effort | distance | anti | change% |",
"|---|---|---|---|---|---|"]
for pc in result.get("per_case", []):
for cell in pc["cells"]:
lines.append(
f"| {pc['case_number']} | {pc['supply_mode']} | {cell['effort']} | "
f"{cell['distance']:.4f} | {cell['anti_pattern_total']} | {cell['change_percent']} |")
lines.append("")
lines.append("> change% מערבב סגנון עם שלמות-תוכן (07-learning §0.7); "
"anti_total הוא הסיגנל הנקי-יותר לסגנון. תיקים עם supply_mode=reconstructed "
"תורמים עדות-כיוון בלבד.\n")
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
return jp, mp
async def main() -> int:
ap = argparse.ArgumentParser(description="#212 block-yod (discussion) effort calibration harness")
ap.add_argument("--self-test", action="store_true",
help="offline measurement + direction-supply proof (no DB/CLI)")
ap.add_argument("--dry-run", action="store_true",
help="plan the grid + per-case direction-supply mode, no model calls (needs DB)")
ap.add_argument("--efforts", default=",".join(DEFAULT_EFFORTS),
help=f"comma effort grid (default {','.join(DEFAULT_EFFORTS)})")
ap.add_argument("--case", default=None, help="restrict to a single case_number")
ap.add_argument("--repeats", type=int, default=1, help="generations per cell (avg out gen noise)")
ap.add_argument("--allow-reconstructed", default="true",
help="true|false — allow the reconstructed-direction fallback (default true). "
"false = STORED-direction cases only (most faithful, may yield 0 cells).")
args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
args.efforts = [e.strip() for e in args.efforts.split(",") if e.strip()]
bad = [e for e in args.efforts if e not in VALID_EFFORTS]
if bad:
print(f"invalid effort(s): {bad}. valid: {sorted(VALID_EFFORTS)}", file=sys.stderr)
return 2
args.allow_reconstructed = str(args.allow_reconstructed).strip().lower() in ("1", "true", "yes", "y")
ts = _ts()
result = await _run(args, ts)
jp, mp = _write_report(result, ts)
g = result["grid"]
print(f"BLOCK-YOD CALIBRATION: {g['n_scorable']}/{g['n_finals']} scorable finals — "
"DIRECTIONAL EVIDENCE, not a regression")
n_recon = sum(1 for c in g["cases"] if c["supply_mode"] == "reconstructed")
n_stored = sum(1 for c in g["cases"] if c["supply_mode"] == "stored")
print(f" direction supply: {n_stored} stored · {n_recon} reconstructed · "
f"{sum(1 for c in g['cases'] if c['supply_mode']=='skip')} skipped")
if g["n_scorable"] == 0:
print(" no scorable finals (need a parsable discussion section + a suppliable direction).")
elif result.get("dry_run"):
print(f" dry-run: {g['total_generations']} generations planned "
f"({len(g['efforts'])} efforts × {g['n_scorable']} cases × {g['repeats']} repeats)")
else:
print(f" current default={g['current_default']} → recommended={result.get('recommended')}")
print(f" report: {mp}")
return 0
if __name__ == "__main__":
# --self-test is OFFLINE and synchronous (it spins its own short-lived loop for
# the overlay probe), so dispatch it BEFORE asyncio.run(main()) — otherwise the
# probe's asyncio.run would nest inside main's running loop.
if "--self-test" in sys.argv:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
sys.exit(_self_test())
sys.exit(asyncio.run(main()))

677
scripts/calibrate_effort.py Normal file
View File

@@ -0,0 +1,677 @@
#!/usr/bin/env python3
"""#208 (WS5 / Q1, INV-G8 eval-harness) — model×effort calibration vs the finals.
Empirically picks the per-block reasoning `effort` whose regenerated block lands
CLOSEST to עו"ד דפנה תמיר's signed final, over the EXISTING `draft_final_pairs`
ledger. This is the calibration INV-G8 / 07-learning §0.7 ask for: stop choosing
the per-block effort defaults (#204: ה=medium, ו=medium, ז=high, ח=medium, ט=high)
"by feel".
WHAT IT MEASURES (per (case, block, effort) cell — the A/B grid):
Regenerate `block` for `case` via block_writer.write_block(effort_override=…)
(the PRODUCTION generation path → claude_session.query → `claude -p`, pinned
Opus 4.8, local-only), then score the regenerated block against the matching
SECTION of the final via services.style_distance.block_distance_to_final:
• change_percent — word-diff regen↔final-section (compute_diff_stats)
• anti_pattern_total — lessons.ANTI_PATTERNS hits in the regen (cleanest
style signal — see §0.7 warning below)
• golden_ratio_deviation_pp — structural-weight gap vs the final
• distance — normalized composite (lower = closer to Dafna)
No parallel metric path: it reuses style_distance + learning_loop (G2).
RECOMMENDATION (#213 — STYLE-CLEAN, anti-primary): for each block, the effort is
ranked by `anti_pattern_total` FIRST (the clean Dafna-style-rule-violation count),
then `golden_ratio_deviation_pp` (structural-style), and `distance` only as a faint
final tiebreak. `change_percent` is REPORTED-NOT-RANKED: per 07-learning §0.7 it
mixes style with content-completeness (a case's missing facts the model can't know)
→ noise for STYLE. The pick carries a `confidence` flag ("clear"/"weak"): a pick
whose anti-pattern lead over the runner-up is within noise is flagged "weak" so an
over-claimed pick (the old block-zayin "low") is never shown as confident. Reported
next to the #204 current default so a regression/improvement is visible.
⚠️ SAMPLE-SIZE CAVEAT (honored, not hidden): very few cases have an uploaded final
(draft_final_pairs.final_text non-empty). The report prints n_finals PROMINENTLY and
labels the output DIRECTIONAL EVIDENCE, not a regression. With n<3 per block the
recommendation is advisory only; the chair/operator decides whether to adopt.
⚠️ change_percent mixes style with content completeness (07-learning §0.7): the chair
sometimes doubles length for missing substance. anti_pattern_total is the cleaner
style signal — the report surfaces both, and the composite down-weights neither
silently.
GENERATION PATH (do not violate — reference_claude_generation_path / claude_session
docstring): write_block → claude_session.query → `claude -p` uses the local claude.ai
session. It runs ONLY on the host where the `claude` CLI exists (NOT the legal-ai
container, NOT a symlinked worktree without CLI access). Hence the live A/B is
host-only; --self-test proves the measurement logic offline with zero model calls.
Usage (mcp-server venv; live needs POSTGRES + the `claude` CLI on the host):
PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python
$PY scripts/calibrate_effort.py --self-test # offline proof, no DB/CLI
POSTGRES_PASSWORD=… POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5433 \
$PY scripts/calibrate_effort.py # live A/B over finals
… --efforts low,medium,high,xhigh # override the effort grid
… --blocks block-he,block-vav # restrict to some blocks
… --case 8137-11-24 # a single case
… --repeats 2 # avg N gens/cell (noise)
… --dry-run # plan the grid, no model calls
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
import sys
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
from statistics import mean
logger = logging.getLogger(__name__)
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')}"
)
OUT_DIR = REPO_ROOT / "data" / "eval"
# Only the AI blocks that map to a golden-ratio section can be scored against the
# final's matching section (split_final_by_section). Template blocks (א-ד, יב) are
# deterministic template-fill — no effort knob. block-yod (discussion) is EXCLUDED
# from the defaults: it requires an approved direction (brainstorm → approve_direction)
# that calibration cases lack, so block_writer.write_block raises and it is NOT
# calibratable standalone — and it's out of WS5's interim-draft scope. It stays in
# VALID_BLOCKS so a user can still force it (--blocks block-yod) at their own risk;
# the per-cell guard in _run() keeps such a failure non-fatal.
CALIBRATABLE_BLOCKS = ["block-he", "block-vav", "block-zayin", "block-chet", "block-tet", "block-yod-alef"]
VALID_BLOCKS = CALIBRATABLE_BLOCKS + ["block-yod"]
DEFAULT_EFFORTS = ["low", "medium", "high", "xhigh"]
VALID_EFFORTS = {"low", "medium", "high", "xhigh", "max"}
# ── pure helpers (offline-testable) ──────────────────────────────────────────
# Style-clean ranking epsilon (#213). Confidence in a pick = how far the best
# effort's anti_pattern_total beats the 2nd-best. We call a pick "clear" only when
# that margin clears a floor that is itself the LARGER of:
# • an absolute floor (≥0.5 avg anti-patterns — a half-violation per case), and
# • a fraction of the spread across efforts (best can't claim a lead that is mere
# noise on a block whose efforts barely differ).
# Below that → "weak" (within noise; nominal pick still reported, but flagged so a
# within-noise pick is never shown as confident). Documented in the docstring.
CONFIDENCE_ABS_FLOOR = 0.5 # avg anti-patterns (a half-violation per case)
CONFIDENCE_SPREAD_FRACTION = 0.20 # of the across-effort anti spread
def _anti_confidence(rows: list[dict]) -> tuple[str, float, float]:
"""Confidence that the anti-primary pick is real, not within-noise.
Returns (confidence, margin, epsilon) where confidence ∈ {"clear","weak"}.
margin = (2nd-best anti) (best anti); epsilon = the bar margin must clear.
A single effort (no rival) is trivially "clear". Pure → unit-tested.
"""
if len(rows) < 2:
return "clear", float("inf"), 0.0
antis = sorted(r["anti_pattern_total"] for r in rows)
best, second = antis[0], antis[1]
margin = second - best
spread = antis[-1] - antis[0]
epsilon = max(CONFIDENCE_ABS_FLOOR, CONFIDENCE_SPREAD_FRACTION * spread)
return ("clear" if margin >= epsilon else "weak"), round(margin, 4), round(epsilon, 4)
def recommend_effort(cells: list[dict]) -> dict | None:
"""Pick the STYLE-CLEANEST effort for ONE block from its scored cells (#213).
cells: [{"effort","distance","anti_pattern_total","change_percent",
"golden_ratio_deviation_pp","n"}].
Ranking key (style-clean, G8 / 07-learning §0.7) — change_percent is EXCLUDED
from the ranking (it mixes style with content-completeness; reported only):
1. PRIMARY anti_pattern_total (↑ fewer Dafna-style violations = better)
2. SECONDARY golden_ratio_deviation_pp (↑ structural-style; None → +inf worst)
3. TIEBREAK distance (↑ faint final tiebreak only)
Returns the winning row dict (so callers reading ["effort"] keep working) with
two added keys: ``confidence`` ∈ {"clear","weak"} and ``confidence_margin`` —
so a within-noise pick is never presented as confident. Pure → unit-tested.
"""
if not cells:
return None
ranked = sorted(
cells,
key=lambda c: (
c["anti_pattern_total"],
c["golden_ratio_deviation_pp"] if c.get("golden_ratio_deviation_pp") is not None else float("inf"),
c["distance"],
),
)
best = dict(ranked[0])
conf, margin, _eps = _anti_confidence(cells)
best["confidence"] = conf
best["confidence_margin"] = margin
return best
def aggregate_cell(per_run: list[dict]) -> dict:
"""Mean each metric across repeated generations of the same (case, block, effort)."""
if not per_run:
return {"distance": 1.0, "anti_pattern_total": 0.0, "change_percent": 100.0,
"golden_ratio_deviation_pp": None, "n": 0}
ratios = [r["golden_ratio_deviation_pp"] for r in per_run if r.get("golden_ratio_deviation_pp") is not None]
return {
"distance": round(mean(r["distance"] for r in per_run), 4),
"anti_pattern_total": round(mean(r["anti_pattern_total"] for r in per_run), 2),
"change_percent": round(mean(r["change_percent"] for r in per_run), 2),
"golden_ratio_deviation_pp": round(mean(ratios), 2) if ratios else None,
"n": len(per_run),
}
def _current_default(block_id: str) -> str | None:
from legal_mcp.services.block_writer import BLOCK_CONFIG, DEFAULT_EFFORT
cfg = BLOCK_CONFIG.get(block_id, {})
if cfg.get("model") != "ai":
return None
return cfg.get("effort", DEFAULT_EFFORT)
# ── re-rank a saved report (offline — NO LLM, NO DB) ─────────────────────────
def rerank_saved(saved: dict) -> dict:
"""Re-apply the CURRENT (#213) recommend_effort to a saved calibration report.
Reads the per-(block,effort) rows that the run already persisted
(distance/anti/change%/ratioΔ/n) and recomputes recommended + confidence — no
regeneration, no model, no DB. Lets a past run be re-ranked under the new
style-clean key. Returns {"block_id": {old, new, confidence, confidence_margin,
current_default, efforts}}. Pure → unit-tested in --self-test.
"""
out: dict[str, dict] = {}
for block_id, bd in (saved.get("by_block") or {}).items():
rows = bd.get("efforts") or []
rec = recommend_effort(rows)
out[block_id] = {
"section": bd.get("section"),
"current_default": bd.get("current_default"),
"old_recommended": bd.get("recommended"),
"new_recommended": rec["effort"] if rec else None,
"confidence": rec["confidence"] if rec else None,
"confidence_margin": rec.get("confidence_margin") if rec else None,
"efforts": rows,
}
return out
def _render_rerank_md(saved: dict, reranked: dict, src: Path) -> str:
g = saved.get("grid", {})
finals = g.get("finals", [])
lines = [
f"# #213 — re-rank של {src.name} (style-clean, anti-primary · OFFLINE)\n",
f"> מקור: `{src.name}` · {g.get('n_finals','?')} סופיים "
f"({', '.join(finals) or ''}) · ללא LLM/DB — דירוג-מחדש בלבד.\n",
"## old pick → new pick (+confidence)\n",
"| block | section | current | old pick | **new pick** | confidence | n |",
"|---|---|---|---|---|---|---|",
]
for b, rd in reranked.items():
old = rd.get("old_recommended") or ""
new = rd.get("new_recommended") or ""
mark = "" if new == old else " ⬅︎"
conf = rd.get("confidence")
conf_cell = {"clear": "clear", "weak": "⚠️ weak (within-noise)"}.get(conf, "")
n_b = rd["efforts"][0]["n"] if rd.get("efforts") else 0
lines.append(
f"| {b} | {rd.get('section') or ''} | {rd.get('current_default') or ''} | "
f"{old} | **{new}**{mark} | {conf_cell} | {n_b} |")
lines.append("")
for b, rd in reranked.items():
if not rd.get("efforts"):
continue
lines += [f"### {b} ({rd.get('section') or ''})\n",
"| effort | distance | anti_total | change% (reported) | ratioΔpp | n |",
"|---|---|---|---|---|---|"]
for r in rd["efforts"]:
star = "" if r["effort"] == rd.get("new_recommended") else ""
ratio = r.get("golden_ratio_deviation_pp")
lines.append(
f"| {r['effort']}{star} | {r['distance']:.4f} | {r['anti_pattern_total']} | "
f"{r['change_percent']} | {ratio if ratio is not None else ''} | {r['n']} |")
lines.append("")
lines.append("> דירוג **style-clean** (#213): anti_total ראשי → ratioΔ → distance (tiebreak); "
"**change% מדווח-לא-מדורג** (07-learning §0.7); "
"confidence=⚠weak ⇒ הובלת-anti < epsilon = max(0.5, 0.20·spread).\n")
return "\n".join(lines) + "\n"
def _do_rerank(path: Path) -> int:
"""--rerank entry: load a saved report, re-rank offline, print + write *-reranked.md."""
if not path.exists():
print(f"rerank: file not found: {path}", file=sys.stderr)
return 2
saved = json.loads(path.read_text(encoding="utf-8"))
reranked = rerank_saved(saved)
md = _render_rerank_md(saved, reranked, path)
print(md)
out = path.with_name(path.stem + "-reranked.md")
out.write_text(md, encoding="utf-8")
print(f" reranked report written: {out}", file=sys.stderr)
return 0
# ── self-test (no DB, no model) ──────────────────────────────────────────────
def _self_test() -> int:
ok = True
def chk(name, cond):
nonlocal ok
ok = ok and cond
print(f" {name:42} {'ok' if cond else 'FAIL'}")
# block_distance_to_final: regen identical to final-section ⇒ change ~0,
# distance dominated by anti-patterns (0 here) ⇒ ~0.
from legal_mcp.services.style_distance import (
block_distance_to_final, split_final_by_section,
)
final_section = "לפנינו ערר על החלטת הוועדה המקומית. " * 40
d_same = block_distance_to_final("block-he", final_section, final_section, "rejection",
section_target_total_words=len(final_section.split()))
chk("identical regen ⇒ change_percent==0", d_same["change_percent"] == 0.0)
chk("identical regen ⇒ anti==0", d_same["anti_pattern_total"] == 0)
chk("identical regen ⇒ distance small", d_same["distance"] < 0.05)
# A regen full of anti-patterns (markdown headers / bullet lists) scores worse
# than clean continuous narrative, holding the final fixed.
clean = "אנו סבורים כי דין הערר להידחות. כידוע, הלכה פסוקה היא. " * 20
dirty = "## כותרת\n- נקודה ראשונה\n- נקודה שנייה\n* עוד נקודה\n### תת\n" * 10
d_clean = block_distance_to_final("block-yod", clean, final_section, "rejection",
section_target_total_words=len(final_section.split()))
d_dirty = block_distance_to_final("block-yod", dirty, final_section, "rejection",
section_target_total_words=len(final_section.split()))
chk("dirty regen has more anti-patterns", d_dirty["anti_pattern_total"] > d_clean["anti_pattern_total"])
chk("dirty regen ⇒ larger distance", d_dirty["distance"] > d_clean["distance"])
# ratio deviation is None when no total provided (never fabricated)
d_noratio = block_distance_to_final("block-he", clean, final_section, "rejection")
chk("no total ⇒ ratio deviation None", d_noratio["golden_ratio_deviation_pp"] is None)
# split_final_by_section returns mapped golden sections only.
sample_final = (
"רקע עובדתי\nהמקרקעין נשוא הערר. " * 10 + "\n\n"
"תמצית טענות הצדדים\nהעוררים טוענים כי. " * 10 + "\n\n"
"דיון והכרעה\nאנו סבורים כי. " * 10
)
secs = split_final_by_section(sample_final)
chk("split maps to golden sections", set(secs).issubset(
{"background", "claims", "discussion", "summary"}))
chk("split found ≥1 section", len(secs) >= 1)
# recommend_effort (#213, style-clean): anti_pattern_total is PRIMARY — the
# effort with the FEWEST style violations wins, even if its distance is higher.
rec = recommend_effort([
{"effort": "low", "distance": 0.20, "anti_pattern_total": 5, "change_percent": 22,
"golden_ratio_deviation_pp": 3.0, "n": 1},
{"effort": "high", "distance": 0.40, "anti_pattern_total": 2, "change_percent": 40,
"golden_ratio_deviation_pp": 5.0, "n": 1},
{"effort": "xhigh", "distance": 0.50, "anti_pattern_total": 1, "change_percent": 50,
"golden_ratio_deviation_pp": 9.0, "n": 1},
])
chk("recommend: anti-primary (not distance)", rec["effort"] == "xhigh")
chk("recommend: change_percent NOT ranked", rec["distance"] == 0.50) # worst distance still won
chk("recommend empty ⇒ None", recommend_effort([]) is None)
# SECONDARY = golden_ratio_deviation_pp when anti ties; distance only as last tiebreak.
rec_tie = recommend_effort([
{"effort": "low", "distance": 0.10, "anti_pattern_total": 3, "change_percent": 10,
"golden_ratio_deviation_pp": 8.0, "n": 1},
{"effort": "high", "distance": 0.90, "anti_pattern_total": 3, "change_percent": 90,
"golden_ratio_deviation_pp": 2.0, "n": 1},
])
chk("recommend: ratio breaks anti-tie", rec_tie["effort"] == "high")
rec_ratio_tie = recommend_effort([
{"effort": "low", "distance": 0.30, "anti_pattern_total": 3, "change_percent": 10,
"golden_ratio_deviation_pp": 5.0, "n": 1},
{"effort": "high", "distance": 0.10, "anti_pattern_total": 3, "change_percent": 90,
"golden_ratio_deviation_pp": 5.0, "n": 1},
])
chk("recommend: distance breaks final tie", rec_ratio_tie["effort"] == "high")
# None ratio is treated as worst (never beats a real deviation on the secondary key).
rec_none = recommend_effort([
{"effort": "low", "distance": 0.10, "anti_pattern_total": 2, "change_percent": 10,
"golden_ratio_deviation_pp": None, "n": 1},
{"effort": "high", "distance": 0.90, "anti_pattern_total": 2, "change_percent": 90,
"golden_ratio_deviation_pp": 4.0, "n": 1},
])
chk("recommend: None ratio is worst", rec_none["effort"] == "high")
# confidence flag (#213): a clear anti lead ⇒ "clear"; within-noise ⇒ "weak".
rec_clear = recommend_effort([
{"effort": "low", "distance": 0.5, "anti_pattern_total": 1.0, "change_percent": 50,
"golden_ratio_deviation_pp": 5.0, "n": 5},
{"effort": "high", "distance": 0.5, "anti_pattern_total": 4.0, "change_percent": 50,
"golden_ratio_deviation_pp": 5.0, "n": 5},
])
chk("confidence: big anti lead ⇒ clear", rec_clear["confidence"] == "clear")
rec_weak = recommend_effort([
{"effort": "low", "distance": 0.4, "anti_pattern_total": 3.6, "change_percent": 93,
"golden_ratio_deviation_pp": 6.7, "n": 5},
{"effort": "xhigh", "distance": 0.5, "anti_pattern_total": 3.6, "change_percent": 102,
"golden_ratio_deviation_pp": 6.6, "n": 5},
{"effort": "high", "distance": 0.53, "anti_pattern_total": 3.8, "change_percent": 104,
"golden_ratio_deviation_pp": 5.7, "n": 5},
])
chk("confidence: anti tie ⇒ weak", rec_weak["confidence"] == "weak")
rec_single = recommend_effort([
{"effort": "low", "distance": 0.4, "anti_pattern_total": 2.0, "change_percent": 50,
"golden_ratio_deviation_pp": 5.0, "n": 5}])
chk("confidence: single effort ⇒ clear", rec_single["confidence"] == "clear")
# aggregate_cell: means + ratio drops Nones, keeps n.
agg = aggregate_cell([
{"distance": 0.2, "anti_pattern_total": 2, "change_percent": 20, "golden_ratio_deviation_pp": 3.0},
{"distance": 0.4, "anti_pattern_total": 4, "change_percent": 30, "golden_ratio_deviation_pp": None},
])
chk("aggregate distance mean", agg["distance"] == 0.3)
chk("aggregate n counted", agg["n"] == 2)
chk("aggregate ratio skips None", agg["golden_ratio_deviation_pp"] == 3.0)
# rerank_saved: re-applies the new key to a saved report's stored rows, no I/O.
# Mimics the real 8-final block-vav rows: low has fewest anti by a clear margin.
saved = {"grid": {"n_finals": 7, "finals": ["x"]}, "by_block": {
"block-vav": {"section": "background", "current_default": "medium",
"recommended": "low", "efforts": [
{"effort": "low", "distance": 0.4454, "anti_pattern_total": 1.57, "change_percent": 87.39, "golden_ratio_deviation_pp": 6.33, "n": 7},
{"effort": "medium", "distance": 0.4749, "anti_pattern_total": 2.29, "change_percent": 87.76, "golden_ratio_deviation_pp": 6.37, "n": 7},
{"effort": "high", "distance": 0.5381, "anti_pattern_total": 4.14, "change_percent": 90.69, "golden_ratio_deviation_pp": 5.99, "n": 7},
{"effort": "xhigh", "distance": 0.5413, "anti_pattern_total": 4.57, "change_percent": 89.53, "golden_ratio_deviation_pp": 6.41, "n": 7},
]},
}}
rr = rerank_saved(saved)
chk("rerank: block-vav stays low", rr["block-vav"]["new_recommended"] == "low")
chk("rerank: block-vav clear (anti lead)", rr["block-vav"]["confidence"] == "clear")
chk("rerank: keeps old pick for diff", rr["block-vav"]["old_recommended"] == "low")
chk("rerank: renders markdown", "new pick" in _render_rerank_md(saved, rr, Path("x.json")))
print("ALL PASS" if ok else "*** FAILURES ***")
return 0 if ok else 1
# ── live A/B (host-only — needs DB + `claude` CLI) ───────────────────────────
async def _finals_for_calibration(case_filter: str | None) -> list[dict]:
"""draft_final_pairs whose final_text is populated (the held-out comparison set)."""
from legal_mcp.services import db
pairs = await db.list_draft_final_pairs(limit=500)
out: list[dict] = []
for p in pairs:
if case_filter and p.get("case_number") != case_filter:
continue
full = await db.get_draft_final_pair(p["id"])
if full and (full.get("final_text") or "").strip():
out.append(full)
return out
async def _score_cell(case_id, block_id: str, effort: str, final_section: str,
final_total_words: int, outcome: str, repeats: int) -> dict:
"""Generate `block_id` at `effort` `repeats` times; score each vs the final section."""
from legal_mcp.services import block_writer
from legal_mcp.services.style_distance import block_distance_to_final
runs: list[dict] = []
for _ in range(repeats):
res = await block_writer.write_block(case_id, block_id, effort_override=effort)
scored = block_distance_to_final(
block_id, res.get("content", ""), final_section, outcome,
section_target_total_words=final_total_words,
)
runs.append(scored)
agg = aggregate_cell(runs)
agg["effort"] = effort
agg["runs"] = runs
return agg
async def _run(args, ts: str) -> dict:
from uuid import UUID
from legal_mcp.services import db
from legal_mcp.services.lessons import canonical_outcome
from legal_mcp.services.style_distance import split_final_by_section, _BLOCK_TO_SECTION
efforts = args.efforts
blocks = args.blocks
finals = await _finals_for_calibration(args.case)
cases_meta = []
for f in finals:
case = await db.get_case_by_number(f["case_number"]) if f.get("case_number") else None
if not case:
continue
decision = await db.get_decision_by_case(UUID(case["id"]))
outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
sections = split_final_by_section(f.get("final_text", ""))
final_total_words = len((f.get("final_text", "") or "").split())
cases_meta.append({
"case_number": f["case_number"], "case_id": case["id"],
"outcome": outcome, "sections": sections, "final_total_words": final_total_words,
})
# grid plan: (block → cases that have its section)
plan: dict[str, list[dict]] = {}
for block_id in blocks:
section = _BLOCK_TO_SECTION.get(block_id)
plan[block_id] = [c for c in cases_meta if section and c["sections"].get(section)]
total_cells = sum(len(plan[b]) for b in blocks) * len(efforts) * args.repeats
grid_summary = {
"n_finals": len(cases_meta),
"finals": [c["case_number"] for c in cases_meta],
"blocks": blocks, "efforts": efforts, "repeats": args.repeats,
"total_generations": total_cells,
"per_block_n": {b: len(plan[b]) for b in blocks},
}
if args.dry_run:
return {"dry_run": True, "grid": grid_summary, "by_block": {}}
by_block: dict[str, dict] = {}
for block_id in blocks:
section = _BLOCK_TO_SECTION.get(block_id)
per_effort_runs: dict[str, list[dict]] = {e: [] for e in efforts}
per_case: list[dict] = []
for c in plan[block_id]:
final_section = c["sections"][section]
case_cells = []
for effort in efforts:
# Per-cell guard: ANY failure of a single (case, block, effort) cell
# — e.g. block-yod raising "ללא כיוון מאושר", or a transient rate-limit
# mid-run — is logged and skipped, NOT allowed to kill the whole run
# (INV-G8 eval-harness robustness). Partial results still persist below.
try:
cell = await _score_cell(
UUID(c["case_id"]), block_id, effort, final_section,
c["final_total_words"], c["outcome"], args.repeats,
)
except Exception as exc: # noqa: BLE001 — harness must survive any cell failure
logger.warning(
"calibration cell skipped: case=%s block=%s effort=%s%s",
c["case_number"], block_id, effort, exc,
)
continue
per_effort_runs[effort].append(cell)
case_cells.append({k: cell[k] for k in
("effort", "distance", "anti_pattern_total",
"change_percent", "golden_ratio_deviation_pp", "n")})
per_case.append({"case_number": c["case_number"], "cells": case_cells})
# mean across cases for each effort → one comparable row per effort
effort_rows = []
for effort in efforts:
rows = per_effort_runs[effort]
if not rows:
continue
ratios = [r["golden_ratio_deviation_pp"] for r in rows if r.get("golden_ratio_deviation_pp") is not None]
effort_rows.append({
"effort": effort,
"distance": round(mean(r["distance"] for r in rows), 4),
"anti_pattern_total": round(mean(r["anti_pattern_total"] for r in rows), 2),
"change_percent": round(mean(r["change_percent"] for r in rows), 2),
"golden_ratio_deviation_pp": round(mean(ratios), 2) if ratios else None,
"n": len(rows),
})
rec = recommend_effort(effort_rows)
by_block[block_id] = {
"section": section,
"current_default": _current_default(block_id),
"recommended": rec["effort"] if rec else None,
"confidence": rec["confidence"] if rec else None,
"confidence_margin": rec.get("confidence_margin") if rec else None,
"efforts": effort_rows,
"per_case": per_case,
}
# Incremental persistence (INV-G8): flush the report after every completed
# block so a crash later in the grid never loses blocks already generated.
# Blocks not yet done are simply absent from by_block; _write_report tolerates
# partial results. main() does the final flush once the loop finishes.
try:
_write_report({"dry_run": False, "grid": grid_summary, "by_block": by_block}, ts)
except Exception as exc: # noqa: BLE001 — a write hiccup must not abort the run
logger.warning("incremental report write failed after block=%s%s", block_id, exc)
return {"dry_run": False, "grid": grid_summary, "by_block": by_block}
IL_TZ = ZoneInfo("Asia/Jerusalem")
def _ts() -> str:
"""Filename-safe report stamp in Israel time (INV-UI9: human-facing display).
This script runs on the host, which is UTC, so a process-clock stamp would
mislead the chair. We pin Asia/Jerusalem and suffix ``IL`` (instead of a bare
``Z``) so the displayed/filename time is unambiguously Israel-local.
"""
return datetime.now(IL_TZ).strftime("%Y%m%dT%H%M%S-IL")
def _write_report(result: dict, ts: str) -> tuple[Path, Path]:
OUT_DIR.mkdir(parents=True, exist_ok=True)
jp = OUT_DIR / f"effort-calibration-{ts}.json"
mp = OUT_DIR / f"effort-calibration-{ts}.md"
jp.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
g = result["grid"]
n = g["n_finals"]
il_now = datetime.now(IL_TZ).strftime("%Y-%m-%d %H:%M")
lines = [
f"# #208 — כיול model×effort מול הסופיים — {ts}\n",
f"> נוצר: {il_now} (שעון ישראל · Asia/Jerusalem)\n",
f"> ⚠️ **גודל-מדגם: {n} סופיים** ({', '.join(g['finals']) or ''}). "
"זוהי **עדות-כיוון, לא רגרסיה** — מעט תיקים בעלי סופי-עלוי. "
"ההמלצה אדוויזורית; ההכרעה בידי היו\"ר/המפעיל.\n",
f"- בלוקים: {', '.join(g['blocks'])}",
f"- efforts: {', '.join(g['efforts'])} · repeats/cell: {g['repeats']}",
f"- סך ייצורי-מודל: {g['total_generations']}",
"",
]
if result.get("dry_run"):
lines += ["## DRY-RUN — תכנון הגריד בלבד (ללא ייצור)\n",
"| block | #cases | current default |", "|---|---|---|"]
for b in g["blocks"]:
lines.append(f"| {b} | {g['per_block_n'].get(b,0)} | {_current_default(b) or ''} |")
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
return jp, mp
lines += ["## המלצה per-בלוק (style-clean: anti_pattern_total ראשי — #213)\n",
"| block | section | current | **recommended** | confidence | n |",
"|---|---|---|---|---|---|"]
for b, bd in result["by_block"].items():
rec = bd.get("recommended") or ""
mark = "" if rec == bd.get("current_default") else " ⬅︎"
conf = bd.get("confidence")
conf_cell = {"clear": "clear", "weak": "⚠️ weak (within-noise)"}.get(conf, "")
n_b = bd["efforts"][0]["n"] if bd.get("efforts") else 0
lines.append(
f"| {b} | {bd.get('section','')} | {bd.get('current_default') or ''} | "
f"**{rec}**{mark} | {conf_cell} | {n_b} |")
lines.append("")
for b, bd in result["by_block"].items():
lines += [f"### {b} ({bd.get('section','')})\n",
"| effort | distance | anti_total | change% | ratioΔpp | n |",
"|---|---|---|---|---|---|"]
for r in bd.get("efforts", []):
star = "" if r["effort"] == bd.get("recommended") else ""
ratio = r["golden_ratio_deviation_pp"]
lines.append(
f"| {r['effort']}{star} | {r['distance']:.4f} | {r['anti_pattern_total']} | "
f"{r['change_percent']} | {ratio if ratio is not None else ''} | {r['n']} |")
lines.append("")
lines.append("> דירוג-ההמלצה **style-clean** (#213): anti_total ראשי → ratioΔ → distance (tiebreak). "
"**change% מדווח-לא-מדורג** — מערבב סגנון עם שלמות-תוכן (07-learning §0.7), "
"anti_total הוא הסיגנל הנקי-לסגנון. confidence=⚠weak ⇒ הבחירה בתוך-הרעש "
"(ההובלה ב-anti קטנה מ-epsilon = max(0.5, 0.20·spread)).\n")
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
return jp, mp
async def main() -> int:
ap = argparse.ArgumentParser(description="#208 model/effort calibration harness")
ap.add_argument("--self-test", action="store_true", help="offline measurement-logic proof (no DB/CLI)")
ap.add_argument("--rerank", metavar="REPORT.json", default=None,
help="re-rank a saved calibration JSON under the current style-clean key (no LLM/DB)")
ap.add_argument("--dry-run", action="store_true", help="plan the A/B grid over existing finals, no model calls")
ap.add_argument("--efforts", default=",".join(DEFAULT_EFFORTS),
help=f"comma effort grid (default {','.join(DEFAULT_EFFORTS)})")
ap.add_argument("--blocks", default=",".join(CALIBRATABLE_BLOCKS),
help="comma block ids to calibrate")
ap.add_argument("--case", default=None, help="restrict to a single case_number")
ap.add_argument("--repeats", type=int, default=1, help="generations per cell (avg out gen noise)")
args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
if args.self_test:
return _self_test()
if args.rerank:
return _do_rerank(Path(args.rerank))
args.efforts = [e.strip() for e in args.efforts.split(",") if e.strip()]
bad = [e for e in args.efforts if e not in VALID_EFFORTS]
if bad:
print(f"invalid effort(s): {bad}. valid: {sorted(VALID_EFFORTS)}", file=sys.stderr)
return 2
args.blocks = [b.strip() for b in args.blocks.split(",") if b.strip()]
bad_b = [b for b in args.blocks if b not in VALID_BLOCKS]
if bad_b:
print(f"non-calibratable block(s): {bad_b}. valid: {VALID_BLOCKS}", file=sys.stderr)
return 2
ts = _ts()
result = await _run(args, ts)
jp, mp = _write_report(result, ts)
g = result["grid"]
print(f"CALIBRATION: {g['n_finals']} finals — DIRECTIONAL EVIDENCE, not a regression")
if g["n_finals"] == 0:
print(" no finals with final_text in draft_final_pairs — upload a signed final first.")
elif result.get("dry_run"):
print(f" dry-run: {g['total_generations']} generations planned across {len(g['blocks'])} blocks")
else:
for b, bd in result["by_block"].items():
print(f" {b:16} current={bd.get('current_default') or '':6} "
f"→ recommended={bd.get('recommended') or '':6} "
f"[{bd.get('confidence') or ''}]")
print(f" report: {mp}")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

View File

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

View File

@@ -91,15 +91,22 @@ async def main(args: argparse.Namespace) -> int:
# The 3 steps as durable nodes (X16 / INV-DUR1) — shared runtime with
# final_halacha (scripts/_pipeline_runtime.py). A crash/OOM in the long style
# panel [3] resumes from [3] instead of re-paying the Opus distillation [1].
# panel [3] resumes from [3] instead of re-paying the Opus distillation [2].
#
# Order (#159): enroll FIRST. enroll creates the style_corpus row (fast, no LLM)
# that decision_lessons attach to. Running it before the up-to-30-min distillation
# means the corpus exists within seconds — so the curator agent's §A (which fires
# in parallel on a final_learning_* wake) can record source='curator' findings
# without racing the 30-min step. enroll/ingest are mutually independent; panel
# needs both, so it stays last.
async def step_ingest(results: dict) -> dict:
# [1] distillation (Opus) — skip if already analyzed (idempotent; --force to redo).
# [2] distillation (Opus) — skip if already analyzed (idempotent; --force to redo).
status = await _latest_pair_status(case["id"])
if status == "analyzed" and not args.force:
print("[1/3] ingest_final_version — דולג (הזוג כבר analyzed; --force לחידוש)")
print("[2/3] ingest_final_version — דולג (הזוג כבר analyzed; --force לחידוש)")
return {"ingest": "skipped:analyzed"}
print("[1/3] ingest_final_version — דיסטילציית טיוטה↔סופי…", flush=True)
print("[2/3] ingest_final_version — דיסטילציית טיוטה↔סופי…", flush=True)
raw = await ingest_final_version(case_number, file_path=final_path)
try:
env = json.loads(raw)
@@ -120,8 +127,9 @@ async def main(args: argparse.Namespace) -> int:
return {"ingest": "done"}
async def step_enroll(results: dict) -> dict:
# [2] enroll into style_corpus (idempotent) — lessons need a corpus_id.
print("[2/3] רישום לקורפוס-הסגנון (idempotent)…", flush=True)
# [1] enroll into style_corpus (idempotent) — lessons need a corpus_id, and
# it must exist early (before the long distillation) so curator §A can attach.
print("[1/3] רישום לקורפוס-הסגנון (idempotent)…", flush=True)
if await _has_style_corpus(case_number):
print(" ✓ כבר רשום בקורפוס-הסגנון")
return {"enroll": "exists"}
@@ -150,8 +158,8 @@ async def main(args: argparse.Namespace) -> int:
return {"panel_rc": rc or 0}
steps = [
_pipeline_runtime.Step("ingest_final_version", step_ingest),
_pipeline_runtime.Step("enroll_style_corpus", step_enroll),
_pipeline_runtime.Step("ingest_final_version", step_ingest),
_pipeline_runtime.Step("style_panel", step_panel),
]
checkpoint_db = config.DATA_DIR / "checkpoints" / "learning.sqlite"

50
scripts/host_sync.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# host_sync.sh — keep the HOST working tree (~/legal-ai) in sync with origin/main.
#
# Why this exists: the writer (block_writer), the learning panels
# (style_lesson_panel), and the legal_mcp MCP tools run from the host tree — the
# Paperclip agents `claude -p` with cwd=/home/chaim/legal-ai, and the long-running
# pm2 services import legal_mcp from mcp-server/src. A merge to main + Coolify
# deploy only refreshes the CONTAINER (FastAPI endpoints); host-side code stays on
# whatever was last `git pull`ed. On 2026-06-28 the host was 16 commits behind, so a
# full day of learning-loop fixes sat dormant until a manual pull. This closes that
# gap (TaskMaster #160).
#
# Safety: fast-forward ONLY (never force, never lose local work). The host tree
# carries a perpetually-dirty .taskmaster/tasks/tasks.json (uncommitted by policy) —
# a clean ff leaves it untouched; if upstream ever touches the same file the ff
# fails and we log + bail rather than clobber it. Agent runs spawn fresh, so they
# pick up new code on the next wakeup with no restart; only the long-running pm2
# services that IMPORT app code are restarted, and only when their own files change.
set -uo pipefail
REPO=/home/chaim/legal-ai
LOG="$REPO/data/logs/host_sync.log"
mkdir -p "$REPO/data/logs"
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" >> "$LOG"; }
cd "$REPO" || { log "ERROR cd $REPO failed"; exit 1; }
# Only sync when on main (don't disturb a manual branch checkout).
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
[ "$branch" = "main" ] || { log "skip: on branch '$branch', not main"; exit 0; }
git fetch origin main --quiet 2>>"$LOG" || { log "ERROR git fetch failed"; exit 1; }
local_head=$(git rev-parse HEAD)
remote_head=$(git rev-parse origin/main)
[ "$local_head" = "$remote_head" ] && exit 0 # up to date — quiet no-op
changed=$(git diff --name-only "$local_head" "$remote_head")
if ! git pull --ff-only origin main --quiet 2>>"$LOG"; then
log "ERROR ff-only pull failed ($local_head$remote_head) — manual intervention needed (likely local tasks.json vs upstream); NOT forcing"
exit 1
fi
log "synced $local_head$(git rev-parse --short HEAD) ($(echo "$changed" | wc -l) files)"
# Restart only the long-running services whose OWN code changed (precise, minimal
# churn). The agent path (writer/panels via fresh `claude -p`) needs no restart.
restart() { pm2 restart "$1" --update-env >/dev/null 2>>"$LOG" && log "restarted $1"; }
echo "$changed" | grep -q '^mcp-server/src/legal_mcp/chat_service/' && restart legal-chat-service
echo "$changed" | grep -qE '^mcp-server/src/legal_mcp/court_fetch_service/' && restart legal-court-fetch-service
echo "$changed" | grep -q '^scripts/reap_orphan_procs.py' && restart legal-reaper
exit 0

View File

@@ -0,0 +1,102 @@
"""Batch ingest of appeals-committee decisions staged in data/precedents/incoming/.
Sequential (NOT concurrent — avoids the 2026-05-31 load-spike incident) ingest of
each .doc/.docx via the canonical internal pipeline, followed by metadata extraction
per case (the internal path does NOT auto-queue metadata — INV-ING3). Halacha is
auto-queued by ingest; drain it separately via MCP precedent_process_pending.
case_number canonical follows the filename/Nevo convention validated against the
corpus + missing_precedents list:
- מרכז/חיפה/ת"א committees number with month: NNNN/MM/YY → NNNN-MM-YY
- ירושלים/צפון committees number without month: NNNN/YY → NNNN-YY
decision_date / summary / subject_tags / appeal_subtype are left empty on purpose —
the metadata extractor fills them from the full text (more reliable than parsing here).
Run: mcp-server/.venv/bin/python scripts/ingest_incoming_batch.py
Config (POSTGRES_URL, VOYAGE_API_KEY, ANTHROPIC_API_KEY) auto-loads from ~/.env.
"""
import asyncio
import os
import sys
import traceback
from pathlib import Path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
from legal_mcp.services import internal_decisions as svc
from legal_mcp.services import precedent_metadata_extractor as meta
INC = "/home/chaim/legal-ai/data/precedents/incoming"
# file, case_number(canonical-with-slashes), chair, district, court, practice_area
DECISIONS = [
("105-07.doc", "105/07", "דרור לביא-אפרת", "צפון", "rishuy_uvniya"),
("ARAR-17-105-44.doc", "105/17", "רונית אלפר", "מרכז", "betterment_levy"),
("ARAR-18-1029.doc", "1029/18", "אליעד וינשל", "ירושלים", "rishuy_uvniya"),
("ARAR-20-1018-44.doc", "1018/20", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
("ARAR-20-1023-55.doc", "1023/20", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
("ARAR-21-1080-55.doc", "1080/21", "נילי בן משה ידגר", "צפון", "rishuy_uvniya"),
("ARAR-21-11-1051.doc", "1051/11/21", "רונית אלפר", "מרכז", "rishuy_uvniya"),
("ARAR-22-01-1015.doc", "1015/01/22", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
("ARAR-22-06-1029.doc", "1029/06/22", "מאיה אשכנזי", "מרכז", "rishuy_uvniya"),
("ARAR-22-08-1044.doc", "1044/08/22", "מאיה אשכנזי", "מרכז", "rishuy_uvniya"),
("ARAR-22-10-1050.doc", "1050/10/22", "מאיה אשכנזי", "מרכז", "rishuy_uvniya"),
("ARAR-22-1079.doc", "1079/22", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
("ARAR-23-04-1010.doc", "1010/04/23", "מאיה אשכנזי", "מרכז", "rishuy_uvniya"),
("ARAR-23-08-1074-9.doc", "1074/08/23", "מיכל הלברשטם דגני", "חיפה", "rishuy_uvniya"),
("ARAR-23-1034.docx", "1006/23", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
("ARAR-23-1073.doc", "1073/23", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
("ARAR-23-1085.doc", "1085/23", "נילי בן משה ידגר", "צפון", "rishuy_uvniya"),
("ARAR-24-01-1009-5.docx","1009/01/24", "מיכל דגני הלברשטם", "תל אביב", "rishuy_uvniya"),
("ARAR-24-05-1044.doc", "1044/05/24", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
("ARAR-25-01-10072.docx", "1007/01/25", "יפעת בן אריה שטיינברג", "תל אביב", "rishuy_uvniya"),
]
async def main():
results = []
for fname, case_number, chair, district, parea in DECISIONS:
fp = Path(INC) / fname
rec = {"file": fname, "case_number": case_number}
if not fp.exists():
rec["error"] = "file-missing"
print(f"{fname}: file missing", flush=True)
results.append(rec)
continue
try:
out = await svc.ingest_internal_decision(
file_path=fp,
case_number=case_number,
chair_name=chair,
district=district,
court=f"ועדת הערר לתכנון ובנייה — מחוז {district}",
practice_area=parea,
proceeding_type="ערר",
is_binding=False,
)
cid = out.get("case_law_id")
rec["case_law_id"] = cid
rec["chunks"] = out.get("chunks")
print(f"✓ ingest {case_number}: id={cid} chunks={out.get('chunks')}", flush=True)
# metadata (internal path does not auto-queue it)
m = await meta.extract_and_apply(cid)
rec["meta_status"] = m.get("status")
sug = m.get("suggested") or {}
rec["suggested_case_number"] = sug.get("case_number_clean")
rec["citation_formatted"] = sug.get("citation_formatted")
rec["meta_date"] = sug.get("decision_date_iso")
print(f" meta {case_number}: {m.get('status')} | clean={sug.get('case_number_clean')} | {sug.get('citation_formatted')}", flush=True)
except Exception as e:
rec["error"] = f"{type(e).__name__}: {e}"
print(f"{fname} ({case_number}): {e}", flush=True)
traceback.print_exc()
results.append(rec)
print("\n===SUMMARY===", flush=True)
for r in results:
print(r, flush=True)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,392 @@
#!/usr/bin/env python3
"""OCR Benchmark: Current system (PyMuPDF + Google Vision) vs Mistral OCR 4.0
Usage:
python scripts/ocr_benchmark_mistral.py [--docs N] [--output PATH]
Downloads PDFs from MinIO, calls Mistral OCR API, compares against
already-extracted text stored in the DB, and writes a Markdown report.
"""
from __future__ import annotations
import argparse
import asyncio
import base64
import json
import re
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import TypedDict
import httpx
# ── Config ───────────────────────────────────────────────────────────────────
MISTRAL_API_KEY = "UsZjLCX30ev6pox0KgvXyuFP3ktsPYpN"
MISTRAL_OCR_MODEL = "mistral-ocr-latest"
MISTRAL_OCR_URL = "https://api.mistral.ai/v1/ocr"
MINIO_ALIAS = "legalminio"
MINIO_BUCKET = "legal-documents"
# Documents to benchmark — selected for diversity:
# - main appeal (40 pp, 4.4 MB, large digital doc)
# - permit (9 pp, 1.7 MB, likely partially scanned)
# - protocol (5 pp, 178 KB, administrative typed)
# - response (12 pp, ~390 KB, digital legal brief)
DOCS_TO_BENCHMARK = [
{
"title": "כתב ערר",
"minio_key": "cases/1027-04-26/documents/originals/כתב ערר - מושב נחם נ׳ ועדה מקומית בית שמש.pdf",
"doc_type": "appeal",
"pages": 40,
},
{
"title": "נספח 1 — היתר הבנייה",
"minio_key": "cases/1027-04-26/documents/originals/נספח 1 - היתר הבנייה (פורסם 17.03.26).pdf",
"doc_type": "permit",
"pages": 9,
},
{
"title": "נספח 14 — פרוטוקול ועדה מחוזית",
"minio_key": "cases/1027-04-26/documents/originals/נספח 14 - פרוטוקול דיון ועדה מחוזית - 06.06.23.pdf",
"doc_type": "protocol",
"pages": 5,
},
{
"title": "תגובת המשיבה 3",
"minio_key": "cases/1027-04-26/documents/originals/תגובת המשיבה 3 לערר ולבקשה להתליית היתר בנייה.pdf",
"doc_type": "response",
"pages": 12,
},
]
# ── DB access (read-only: fetch already-extracted text) ──────────────────────
def _fetch_extracted_texts(case_number: str) -> dict[str, str]:
"""Pull extracted_text from DB for all docs in the case."""
import psycopg2 # type: ignore
conn = psycopg2.connect(
host="localhost", port=5433, dbname="legal_ai",
user="legal_ai", password="od0ASJZFYibOlWK59krLvvETmgqwlXe8",
)
cur = conn.cursor()
cur.execute(
"""
SELECT d.title, d.extracted_text, d.file_path, d.page_count
FROM documents d
JOIN cases c ON c.id = d.case_id
WHERE c.case_number = %s AND d.extracted_text IS NOT NULL
""",
(case_number,),
)
rows = cur.fetchall()
cur.close()
conn.close()
return {row[0]: {"text": row[1], "file_path": row[2], "pages": row[3]} for row in rows}
# ── MinIO download ────────────────────────────────────────────────────────────
def download_from_minio(minio_key: str, dest: Path) -> None:
"""Download a file from MinIO using mcli."""
src = f"{MINIO_ALIAS}/{MINIO_BUCKET}/{minio_key}"
result = subprocess.run(
["mcli", "cp", src, str(dest)],
capture_output=True, text=True,
)
if result.returncode != 0:
raise RuntimeError(f"mcli cp failed: {result.stderr}")
# ── Text quality metrics ─────────────────────────────────────────────────────
_HEBREW_RE = re.compile(r'[֐-׿]')
_WORD_RE = re.compile(r'\S+')
def compute_metrics(text: str) -> dict:
"""Compute quality metrics for extracted text."""
if not text:
return {"chars": 0, "words": 0, "avg_word_len": 0,
"single_char_pct": 0, "words_per_line": 0,
"hebrew_pct": 0, "quality_ok": False}
words = _WORD_RE.findall(text)
n_words = len(words)
if n_words == 0:
return {"chars": len(text), "words": 0, "avg_word_len": 0,
"single_char_pct": 0, "words_per_line": 0,
"hebrew_pct": 0, "quality_ok": False}
avg_len = sum(len(w) for w in words) / n_words
single_char_pct = sum(1 for w in words if len(w) == 1) / n_words
lines = [ln for ln in text.split("\n") if ln.strip()]
words_per_line = n_words / len(lines) if lines else 0
letters = re.findall(r'[a-zA-Z֐-׿]', text)
hebrew_pct = (
sum(1 for c in letters if _HEBREW_RE.match(c)) / len(letters)
if letters else 0
)
quality_ok = (
n_words >= 10
and avg_len >= 2.5
and single_char_pct <= 0.4
and words_per_line >= 3.0
and hebrew_pct >= 0.5
)
return {
"chars": len(text),
"words": n_words,
"avg_word_len": round(avg_len, 2),
"single_char_pct": round(single_char_pct * 100, 1),
"words_per_line": round(words_per_line, 1),
"hebrew_pct": round(hebrew_pct * 100, 1),
"quality_ok": quality_ok,
}
def count_known_abbrev_errors(text: str) -> int:
"""Count common Hebrew abbreviation OCR errors (pre-fix indicators)."""
patterns = ['עוהייד', 'עוייד', 'הנייל', 'ביהמייש', 'עייי', 'בייכ', 'תמייא']
return sum(text.count(p) for p in patterns)
def count_correct_abbrevs(text: str) -> int:
"""Count correctly rendered Hebrew abbreviations."""
correct = ['עו"ד', 'הנ"ל', 'ביהמ"ש', 'ע"י', 'ב"כ', 'תמ"א', 'ס"ק']
return sum(text.count(p) for p in correct)
# ── Mistral OCR ───────────────────────────────────────────────────────────────
def call_mistral_ocr(pdf_path: Path) -> tuple[str, float]:
"""Call Mistral OCR API on a PDF. Returns (extracted_text, elapsed_seconds)."""
with open(pdf_path, "rb") as f:
pdf_b64 = base64.b64encode(f.read()).decode()
payload = {
"model": MISTRAL_OCR_MODEL,
"document": {
"type": "document_url",
"document_url": f"data:application/pdf;base64,{pdf_b64}",
},
"include_image_base64": False,
}
t0 = time.time()
with httpx.Client(timeout=300.0) as client:
resp = client.post(
MISTRAL_OCR_URL,
headers={
"Authorization": f"Bearer {MISTRAL_API_KEY}",
"Content-Type": "application/json",
},
json=payload,
)
elapsed = time.time() - t0
if resp.status_code != 200:
raise RuntimeError(f"Mistral OCR error {resp.status_code}: {resp.text[:500]}")
data = resp.json()
# Response: {"pages": [{"index": 0, "markdown": "..."}, ...]}
pages = data.get("pages", [])
text = "\n\n".join(p.get("markdown", "") for p in pages)
return text, elapsed
# ── Report ────────────────────────────────────────────────────────────────────
def render_metric_table(current: dict, mistral: dict) -> str:
rows = [
("תווים", f"{current['chars']:,}", f"{mistral['chars']:,}"),
("מילים", f"{current['words']:,}", f"{mistral['words']:,}"),
("אורך מילה ממוצע", str(current['avg_word_len']), str(mistral['avg_word_len'])),
("% מילים חד-תוויות", f"{current['single_char_pct']}%", f"{mistral['single_char_pct']}%"),
("מילים לשורה", str(current['words_per_line']), str(mistral['words_per_line'])),
("% תווים עבריים", f"{current['hebrew_pct']}%", f"{mistral['hebrew_pct']}%"),
("איכות כוללת", "" if current['quality_ok'] else "", "" if mistral['quality_ok'] else ""),
]
lines = ["| מדד | OCR נוכחי | Mistral OCR |", "|-----|-----------|-------------|"]
for label, cur_val, mis_val in rows:
lines.append(f"| {label} | {cur_val} | {mis_val} |")
return "\n".join(lines)
def build_report(results: list[dict], output_path: Path) -> None:
lines = [
"# השוואת OCR: מערכת נוכחית מול Mistral OCR",
f"\n**תיק:** 1027-04-26 — בל\"מ מושב נחם מפעל בטון בית שמש ",
f"**תאריך:** {time.strftime('%Y-%m-%d %H:%M')} ",
f"**מודל Mistral:** `{MISTRAL_OCR_MODEL}` ",
f"**מערכת נוכחית:** PyMuPDF (born-digital) + Google Cloud Vision (scanned) ",
"\n---\n",
"## סיכום מנהלים\n",
]
# Summary table
sum_lines = ["| מסמך | עמודים | נוכחי תווים | Mistral תווים | Mistral זמן | עדיפות |",
"|------|--------|-------------|---------------|-------------|--------|"]
for r in results:
if "error" in r:
sum_lines.append(f"| {r['title']} | {r['pages']} | — | שגיאה | — | — |")
continue
cur_chars = r["current_metrics"]["chars"]
mis_chars = r["mistral_metrics"]["chars"]
winner = "🔵 נוכחי" if cur_chars > mis_chars * 1.05 else (
"🟢 Mistral" if mis_chars > cur_chars * 1.05 else "⚖️ שקול")
sum_lines.append(
f"| {r['title']} | {r['pages']} | {cur_chars:,} | {mis_chars:,} | "
f"{r['mistral_elapsed']:.1f}s | {winner} |"
)
lines.extend(sum_lines)
lines.append("\n---\n")
# Per-document detail
for r in results:
lines.append(f"## {r['title']} ({r['pages']} עמודים)\n")
if "error" in r:
lines.append(f"**שגיאה ב-Mistral OCR:** `{r['error']}`\n")
continue
lines.append(render_metric_table(r["current_metrics"], r["mistral_metrics"]))
lines.append("")
cur_abbr_err = count_known_abbrev_errors(r["current_text"])
mis_abbr_err = count_known_abbrev_errors(r["mistral_text"])
cur_abbr_ok = count_correct_abbrevs(r["current_text"])
mis_abbr_ok = count_correct_abbrevs(r["mistral_text"])
lines.append(f"\n**קיצורים עבריים:**")
lines.append(f"- נוכחי: {cur_abbr_ok} נכונים, {cur_abbr_err} שגויים")
lines.append(f"- Mistral: {mis_abbr_ok} נכונים, {mis_abbr_err} שגויים")
lines.append(f"\n**זמן Mistral:** {r['mistral_elapsed']:.1f} שניות\n")
# Side-by-side first 600 chars
lines.append("### דוגמת טקסט — 600 תווים ראשונים\n")
lines.append("**מערכת נוכחית:**")
lines.append("```")
lines.append((r["current_text"] or "")[:600].replace("```", "'''"))
lines.append("```\n")
lines.append("**Mistral OCR:**")
lines.append("```")
lines.append((r["mistral_text"] or "")[:600].replace("```", "'''"))
lines.append("```\n")
lines.append("---\n")
output_path.write_text("\n".join(lines), encoding="utf-8")
print(f"\n✅ דוח נשמר: {output_path}")
# ── Main ──────────────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(description="OCR benchmark: current vs Mistral")
parser.add_argument("--docs", type=int, default=4,
help="כמה מסמכים לבדוק (ברירת מחדל: 4)")
parser.add_argument("--output", type=str,
default="/home/chaim/legal-ai/data/audit/ocr-benchmark-mistral.md",
help="נתיב לדוח הפלט")
args = parser.parse_args()
docs = DOCS_TO_BENCHMARK[: args.docs]
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
print(f"🔍 שולף טקסטים קיימים מה-DB עבור תיק 1027-04-26...")
db_texts = _fetch_extracted_texts("1027-04-26")
print(f" נמצאו {len(db_texts)} מסמכים עם טקסט מחולץ")
results = []
with tempfile.TemporaryDirectory() as tmp_dir:
for doc in docs:
print(f"\n📄 מעבד: {doc['title']} ({doc['pages']} עמודים)")
# --- Current OCR text from DB ---
current_text = ""
for title, info in db_texts.items():
if doc["title"].split("")[0].strip() in title or doc["minio_key"].split("/")[-1] in (info.get("file_path") or ""):
current_text = info["text"] or ""
break
if not current_text:
# Try by file_path suffix match
key_name = doc["minio_key"].split("/")[-1]
for title, info in db_texts.items():
if key_name in (info.get("file_path") or ""):
current_text = info["text"] or ""
break
if not current_text:
print(f" ⚠️ לא נמצא טקסט ב-DB, מחפש לפי שם מסמך...")
# fallback: match by doc_type + rough title
for title, info in db_texts.items():
if doc["doc_type"] in title.lower() or doc["title"][:8] in title:
current_text = info["text"] or ""
break
print(f" נוכחי: {len(current_text):,} תווים")
# --- Download PDF from MinIO ---
pdf_name = doc["minio_key"].split("/")[-1]
pdf_path = Path(tmp_dir) / pdf_name
print(f" מוריד מ-MinIO...")
try:
download_from_minio(doc["minio_key"], pdf_path)
print(f" הורד: {pdf_path.stat().st_size:,} bytes")
except Exception as e:
print(f" ❌ שגיאה בהורדה: {e}")
results.append({"title": doc["title"], "pages": doc["pages"], "error": str(e)})
continue
# --- Mistral OCR ---
print(f" קורא Mistral OCR API...")
try:
mistral_text, elapsed = call_mistral_ocr(pdf_path)
print(f" Mistral: {len(mistral_text):,} תווים ({elapsed:.1f}s)")
except Exception as e:
print(f" ❌ שגיאת Mistral API: {e}")
results.append({
"title": doc["title"], "pages": doc["pages"],
"current_text": current_text,
"current_metrics": compute_metrics(current_text),
"mistral_text": "", "mistral_metrics": compute_metrics(""),
"mistral_elapsed": 0, "error": str(e),
})
continue
results.append({
"title": doc["title"],
"pages": doc["pages"],
"current_text": current_text,
"current_metrics": compute_metrics(current_text),
"mistral_text": mistral_text,
"mistral_metrics": compute_metrics(mistral_text),
"mistral_elapsed": elapsed,
})
print("\n📊 בונה דוח...")
build_report(results, output_path)
# Also save raw texts for manual inspection
raw_dir = output_path.parent / "ocr-benchmark-raw"
raw_dir.mkdir(exist_ok=True)
for r in results:
safe = r["title"].replace("/", "-").replace(" ", "_")[:40]
if "current_text" in r:
(raw_dir / f"{safe}_current.txt").write_text(r["current_text"], encoding="utf-8")
if "mistral_text" in r:
(raw_dir / f"{safe}_mistral.txt").write_text(r["mistral_text"], encoding="utf-8")
print(f"💾 טקסטים גולמיים נשמרו: {raw_dir}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Propose a corpus-refreshed update to Dafna's voice-fingerprint (TaskMaster #161).
The voice-fingerprint (docs/daphna-voice-fingerprint.md) is the PRIMARY abstract
style channel the writer consumes (07-learning §0.2, channel A). It was hand-authored
and never regenerated from the growing corpus — so the "voice" the writer adapts has
been frozen while the corpus grew. This script reads the corpus (style_corpus stats +
block-level style_exemplars sample + measured section ratios) and asks Opus (local)
to PROPOSE refinements/additions to the fingerprint — STYLE / METHOD / VOICE / LEXICON
ONLY, never case substance (INV-LRN5).
CHAIR-GATED (INV-LRN1, HARD gate — the prose profile is voice-knowledge, not a
low-risk style rule, so it does NOT auto-flow): the proposal is written to
data/curator-proposals/ and NEVER overwrites the live fingerprint. The chair reviews
it (in /training, with the other curator proposals) and hand-commits the parts she
accepts to docs/daphna-voice-fingerprint.md — the same manual gate the fingerprint
already uses. This is the prose half of the voice-profile refresh; the structural
half (corpus-measured ratios → writer) shipped in PR #345.
Runs on the HOST (claude_session needs the local claude CLI). Usage:
DOTENV_PATH=/home/chaim/.env DATA_DIR=/home/chaim/legal-ai/data \\
/home/chaim/legal-ai/mcp-server/.venv/bin/python scripts/regenerate_voice_fingerprint.py
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from legal_mcp import config
from legal_mcp.services import claude_session, db
from legal_mcp.services.style_distance import measure_corpus_ratios
logging.basicConfig(level=logging.INFO, format="%(message)s")
log = logging.getLogger("voice_fingerprint_refresh")
FINGERPRINT = config.DATA_DIR.parent / "docs" / "daphna-voice-fingerprint.md"
PROPOSALS = config.DATA_DIR / "curator-proposals"
_PER_SECTION = 5 # exemplars per section in the evidence pack
_MAX_PARA_CHARS = 700 # cap each exemplar so the prompt stays bounded
_SYSTEM = """אתה מזקק את טביעת-אצבע-הקול של עו"ד דפנה תמיר — פרופיל-סגנון מופשט (איך היא כותבת: קול, שיטה, מבנה, לקסיקון, אנטי-דפוסים), לא תוכן.
חוקים מחייבים:
- **סגנון/שיטה בלבד (INV-LRN5):** אסור לכלול מהות משפטית ספציפית — הלכה, עובדה, תקדים, או ניסוח מתיק קונקרטי. רק הכללות-סגנון.
- **לחדד ולהרחיב, לא לזרוק:** שמר את התובנות האצורות בפרופיל הנוכחי; הצע תוספות/חידודים מעוגנים בראיות-הקורפוס שלהלן.
- **לעגן:** כל קביעה חדשה נשענת על דפוס שחוזר בראיות. אם אין עיגון — אל תמציא ואל תוסיף.
- שמר על מבנה-המסמך והסעיפים הקיימים.
הפלט: גרסת-fingerprint מוצעת מלאה (Markdown), מוכנה לסקירת-יו"ר — בלי הקדמות או הסברים מסביב."""
async def _gather_evidence() -> tuple[str, str]:
pool = await db.get_pool()
async with pool.acquire() as conn:
n_corpus = await conn.fetchval("SELECT count(*) FROM style_corpus WHERE coalesce(full_text,'') <> ''")
n_ex = await conn.fetchval("SELECT count(*) FROM style_exemplars")
rows = await conn.fetch(
"""SELECT section, decision_number, outcome, paragraph_text FROM (
SELECT section, decision_number, outcome, paragraph_text,
row_number() OVER (PARTITION BY section
ORDER BY decision_number DESC, word_count DESC) AS rn
FROM style_exemplars
) t WHERE rn <= $1 ORDER BY section, rn""",
_PER_SECTION,
)
ratios = await measure_corpus_ratios()
parts = [f"קורפוס: {n_corpus} החלטות, {n_ex} דוגמאות-בלוק.", "", "יחסי-מבנה מדודים מהקורפוס (אחוז-מהסך, לפי תוצאה):"]
for outcome, entry in (ratios or {}).items():
secs = ", ".join(f"{s} {round(p)}%" for s, p in (entry.get("sections") or {}).items())
parts.append(f" - {outcome} (n={entry.get('n', 0)}): {secs}")
parts += ["", "דוגמאות-סגנון מייצגות (פסקאות אמיתיות של דפנה — מקור ללמידת-קול, לא להעתקת-מהות):"]
for r in rows:
para = (r["paragraph_text"] or "").strip()[:_MAX_PARA_CHARS]
parts.append(f"\n[{r['section']} · {r['decision_number']} · {r['outcome'] or ''}]\n{para}")
return "\n".join(parts), f"{n_corpus} החלטות / {n_ex} דוגמאות"
async def main() -> int:
if not FINGERPRINT.exists():
log.error("fingerprint not found: %s", FINGERPRINT)
return 1
current = FINGERPRINT.read_text(encoding="utf-8")
evidence, summary = await _gather_evidence()
user = (
f"## הפרופיל הנוכחי (לחדד ולהרחיב, לא להחליף):\n{current}\n\n"
f"## ראיות-קורפוס:\n{evidence}\n\n"
"## המשימה:\nהצע גרסת-fingerprint מעודכנת המשלבת חידודים/תוספות-סגנון מעוגנים בראיות לעיל, "
"תוך שימור כל התובנות האצורות. סגנון/שיטה בלבד — בלי מהות."
)
log.info("synthesizing voice-fingerprint proposal from %s (Opus, local)…", summary)
proposed = await claude_session.query(
user, timeout=claude_session.LONG_TIMEOUT, system=_SYSTEM,
model="claude-opus-4-8", tools="",
)
if not proposed or not proposed.strip():
log.error("empty proposal from model — aborting (nothing written)")
return 1
PROPOSALS.mkdir(parents=True, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
out = PROPOSALS / f"voice-fingerprint-{ts}.md"
header = (
f"<!-- הצעת-חידוש לטביעת-אצבע-הקול · {ts} · מקור: {summary} -->\n"
"<!-- שער-יו\"ר (INV-LRN1): הצעה בלבד. לסקור ולקבע ידנית ל-docs/daphna-voice-fingerprint.md. -->\n\n"
)
out.write_text(header + proposed, encoding="utf-8")
log.info("✓ proposal written (chair-gated, NOT applied): %s", out)
log.info(" review in /training → commit accepted parts to %s", FINGERPRINT)
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View File

@@ -19,7 +19,13 @@ migrates atomically per case — is everything that embeds the number as *text*:
4. MinIO keys cases/{old}/… 3 buckets; cp→new then rm old.
legal-immutable (WORM/object-lock) → copy-only, old object stays locked.
5. Gitea repo cases/{old} API PATCH name + local .git remote rewrite
6. Paperclip project name replace(old→new) so case↔project lookup holds
6. Paperclip case↔issue linkage replace(old→new) in THREE places, because the
legal-ai → Paperclip lookup (get_case_issues) keys on the case number as text:
• projects.name so case↔project lookup holds
• plugin_state.legal-case-number the authoritative issue linkage value_json
• issues.title the '[ערר {cn}] …' tag the title-path lookup uses
Without (b)+(c) the issues keep the OLD number and get_case_issues returns [],
so post-final actions (run-learning / run-halacha) silently skip ("no_issue").
Bare occurrences of the old number that are NOT inside a '/cases/{old}/' path
(e.g. prose in notes, a citation) are *reported for review*, never auto-edited.
@@ -273,10 +279,25 @@ async def inspect_paperclip(old: str) -> dict:
try:
c = await asyncpg.connect(PAPERCLIP_DSN, timeout=10)
except Exception as e:
return {"reachable": False, "error": str(e)[:120], "projects": []}
return {"reachable": False, "error": str(e)[:120],
"projects": [], "linkage_rows": 0, "issue_titles": 0}
try:
rows = await c.fetch("SELECT id, name FROM projects WHERE name LIKE $1", f"%{old}%")
return {"reachable": True, "projects": [(str(r["id"]), r["name"]) for r in rows]}
# The two surfaces get_case_issues actually keys on: the legal-case-number
# plugin_state linkage and the '[ערר {cn}]' tag in issue titles.
linkage = await c.fetchval(
"SELECT count(*) FROM plugin_state "
"WHERE state_key = 'legal-case-number' AND value_json = to_jsonb($1::text)",
old,
)
titles = await c.fetchval(
"SELECT count(*) FROM issues WHERE title LIKE $1", f"%{old}%")
return {
"reachable": True,
"projects": [(str(r["id"]), r["name"]) for r in rows],
"linkage_rows": linkage or 0,
"issue_titles": titles or 0,
}
finally:
await c.close()
@@ -369,16 +390,31 @@ async def apply_case(conn, rec: dict, *, skip_minio: bool, skip_gitea: bool,
except urllib.error.HTTPError as e:
log(f" ✗ Gitea rename failed: HTTP {e.code} {e.read()[:160]!r}")
# 6. Paperclip project name
if not skip_paperclip and rec["paperclip"].get("reachable") and rec["paperclip"]["projects"]:
# 6. Paperclip case↔issue linkage — project name + legal-case-number value +
# issue titles. (b)+(c) are what get_case_issues keys on; without them the
# issues keep the old number and run-learning/run-halacha skip with "no_issue".
if not skip_paperclip and rec["paperclip"].get("reachable"):
import asyncpg
c = await asyncpg.connect(PAPERCLIP_DSN, timeout=10)
try:
if rec["paperclip"]["projects"]:
res = await c.execute(
"UPDATE projects SET name = replace(name, $1, $2), updated_at = now() "
"WHERE name LIKE $3",
old, new, f"%{old}%",
)
log(f" ✓ Paperclip projects: {res}")
res = await c.execute(
"UPDATE projects SET name = replace(name, $1, $2), updated_at = now() WHERE name LIKE $3",
"UPDATE plugin_state SET value_json = to_jsonb($2::text) "
"WHERE state_key = 'legal-case-number' AND value_json = to_jsonb($1::text)",
old, new,
)
log(f" ✓ Paperclip plugin_state (legal-case-number): {res}")
res = await c.execute(
"UPDATE issues SET title = replace(title, $1, $2) WHERE title LIKE $3",
old, new, f"%{old}%",
)
log(f" ✓ Paperclip projects: {res}")
log(f" ✓ Paperclip issue titles: {res}")
finally:
await c.close()
@@ -419,6 +455,8 @@ def print_inspection(rec: dict) -> None:
log(f" pclip: {name}")
if not pc["projects"]:
log(" pclip: (no matching project)")
log(f" pclip: linkage rows={pc.get('linkage_rows', 0)} "
f"issue titles={pc.get('issue_titles', 0)} (→ rewritten to {rec['new']})")
else:
log(f" pclip: unreachable ({pc.get('error','')})")
log(" DB path columns to rewrite:")

View File

@@ -110,6 +110,19 @@ def _category(change: dict) -> str:
return "style"
# Graduated gate (INV-LRN1, chair decision 2026-06-28): a STYLE lesson the panel
# kept by 2/2 consensus flows straight to the writer (review_status='approved'),
# reversibly — the chair can veto it in /training. SUBSTANCE (halacha/precedent/
# fact) never reaches here (it's filtered to `substance` and skipped, and routes
# through the strict 3-judge halacha gate), so every category this panel emits is
# style and auto-approves. The constant keeps the gate explicit and future-proof.
_STYLE_CATEGORIES = frozenset({"style", "structure", "lexicon", "tabular"})
def _review_status_for(category: str) -> str:
return "approved" if category in _STYLE_CATEGORIES else "proposed"
# ── two judges, one signature: (system, user) -> dict|None ──
async def judge_deepseek(client: httpx.AsyncClient, system: str, user: str) -> dict | None:
@@ -323,18 +336,24 @@ async def main(args: argparse.Namespace) -> int:
_lesson_text(r["_change"])])
written = 0
approved = 0
for r in fresh:
cat = _category(r["_change"])
rs = _review_status_for(cat)
await db.add_decision_lesson(
UUID(corpus_id),
lesson_text=_lesson_text(r["_change"]),
category=_category(r["_change"]),
category=cat,
source="panel:deepseek+gemini",
created_by="panel",
review_status=rs,
)
written += 1
approved += (rs == "approved")
chair = cc["split"] + cc["incomplete"]
print(f"\nAPPLIED (reversible): wrote {written} decision_lesson proposals "
print(f"\nAPPLIED (reversible): wrote {written} decision_lessons "
f"({approved} auto-approved style → writer; graduated gate) "
f"(source=panel:deepseek+gemini) · {skipped_dup} כפילויות דולגו · "
f"{chair} escalated to chair · {len(substance)} substance skipped")
print(f"backup → {backup}")

View File

@@ -0,0 +1,170 @@
# Advanced Features — פיצ'רים מתקדמים
## הערות שוליים (Footnotes)
**שימוש מרכזי:** הפניות לחקיקה ופסיקה.
```javascript
const { FootnoteReferenceRun } = require('docx');
const doc = new Document({
footnotes: {
1: { children: [new Paragraph({
bidirectional: true, alignment: AlignmentType.START,
children: [new TextRun({
text: "חוק החוזים (חלק כללי), התשל״ג-1973, סעיף 12.",
font: "David", size: 20, rightToLeft: true
})]
})] },
},
// ...sections
});
// הפניה בגוף הטקסט:
new Paragraph({
bidirectional: true, alignment: AlignmentType.BOTH,
children: [
new TextRun({ text: "חובת תום הלב", font: "David", size: 24, rightToLeft: true }),
new FootnoteReferenceRun(1),
new TextRun({ text: " חלה על כל שלבי המשא ומתן.", font: "David", size: 24, rightToLeft: true }),
]
})
```
### תיקון RTL בהערות שוליים (post-unpack)
docx-js לא מגדיר RTL מלא. אחרי unpack, תקן ב-`word/footnotes.xml`:
```xml
<w:footnote w:id="1">
<w:p>
<w:pPr>
<w:pStyle w:val="FootnoteText"/>
<w:bidi/>
<w:jc w:val="start"/>
</w:pPr>
<w:r>
<w:rPr>
<w:rStyle w:val="FootnoteReference"/>
<w:rtl/>
</w:rPr>
<w:footnoteRef/>
</w:r>
</w:p>
</w:footnote>
```
---
## תוכן עניינים (TOC)
**⚠️ TOC ידני בלבד** — `TableOfContents` של docx-js מאבד הגדרות RTL בעדכון Word.
```javascript
const { Tab, TabStopType, LeaderType, LineRuleType } = require('docx');
const tocEntry = (text, pageNum, opts = {}) => new Paragraph({
bidirectional: true,
spacing: { after: 60, line: 276, lineRule: LineRuleType.AUTO },
...(opts.indent ? { indent: { right: opts.indent } } : {}),
tabStops: [{ type: TabStopType.RIGHT, position: 9026, leader: LeaderType.DOT }],
children: [
new TextRun({ text, font: "David", size: 24, rightToLeft: true, bold: opts.bold || false }),
new TextRun({ children: [new Tab()], font: "David", rightToLeft: true }),
new TextRun({ text: String(pageNum), font: "David", size: 24, rightToLeft: true }),
]
});
// שימוש:
tocEntry("פרק א׳ — הגדרות כלליות", 2, { bold: true }),
tocEntry("1. הגדרות יסוד", 2, { indent: 400 }),
```
---
## מספר סקשנים (Multiple Sections)
**שימוש:** כותרות שונות לנספחים, שוליים שונים.
```javascript
const doc = new Document({
sections: [
{
properties: {
page: { size: { width: 11906, height: 16838 }, margin: { top: 1417, right: 1417, bottom: 1417, left: 1417 } },
bidi: true,
},
headers: {
default: new Header({ children: [new Paragraph({
bidirectional: true, alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "הסכם שירותים", font: "David", size: 20, bold: true, rightToLeft: true })]
})] })
},
children: [ /* גוף ההסכם */ ]
},
{
properties: {
page: { size: { width: 11906, height: 16838 }, margin: { top: 1417, right: 1417, bottom: 1417, left: 1417 } },
bidi: true,
},
headers: {
default: new Header({ children: [new Paragraph({
bidirectional: true, alignment: AlignmentType.START,
children: [new TextRun({ text: "נספח א׳ — לוח תעריפים", font: "David", size: 20, bold: true, rightToLeft: true })]
})] })
},
children: [ /* הנספח */ ]
}
]
});
```
---
## לוגו/תמונה בכותרת (Letterhead)
```javascript
const { ImageRun } = require('docx');
const logoBuffer = fs.readFileSync('/path/to/logo.png');
headers: {
default: new Header({
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [new ImageRun({ data: logoBuffer, transformation: { width: 200, height: 60 }, type: "png" })],
}),
new Paragraph({
bidirectional: true, alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "משרד עורכי דין ישראלי ושות׳", font: "David", size: 20, bold: true, rightToLeft: true })],
}),
],
}),
}
```
**הערה:** תמונה חייבת להיות קובץ אמיתי — לבקש מהמשתמש אם אין.
---
## היפרלינקים
```javascript
const { ExternalHyperlink, UnderlineType } = require('docx');
new Paragraph({
bidirectional: true,
children: [
new TextRun({ text: "ראה: ", font: "David", size: 24, rightToLeft: true }),
new ExternalHyperlink({
link: "https://www.nevo.co.il/law_html/law01/073_002.htm",
children: [new TextRun({
text: "חוק החוזים באתר נבו",
font: "David", size: 24, rightToLeft: true,
color: "0563C1",
underline: { type: UnderlineType.SINGLE },
})],
}),
]
})
```
**⚠️ אל תשתמש ב-`style: "Hyperlink"`** — מפריע ל-RTL. הגדר `color` + `underline` ידנית.

View File

@@ -0,0 +1,219 @@
# Document Templates — תבניות מסמכים משפטיים
## תבנית 1: כתב טענות (בקשה, תביעה, הגנה, ערעור)
```javascript
const { Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell,
AlignmentType, LevelFormat, BorderStyle, WidthType } = require('docx');
const PAGE_WIDTH = 11906;
const MARGINS = { top: 1134, right: 1134, bottom: 1134, left: 1134 };
const CONTENT_WIDTH = PAGE_WIDTH - MARGINS.left - MARGINS.right;
const noBorder = { style: BorderStyle.NONE, size: 0, color: "FFFFFF" };
const noBorders = { top: noBorder, bottom: noBorder, left: noBorder, right: noBorder };
// Header בית משפט — טבלה עם שם בית המשפט (ימין) ומספר תיק (שמאל)
function courtHeader(courtName, caseNumber) {
return new Table({
width: { size: CONTENT_WIDTH, type: WidthType.DXA },
columnWidths: [CONTENT_WIDTH / 2, CONTENT_WIDTH / 2],
visuallyRightToLeft: true,
rows: [
new TableRow({
children: [
new TableCell({
width: { size: CONTENT_WIDTH / 2, type: WidthType.DXA },
borders: noBorders,
children: [new Paragraph({
bidirectional: true, alignment: AlignmentType.START,
children: [new TextRun({ text: courtName, bold: true, font: "David", size: 26, rightToLeft: true })]
})]
}),
new TableCell({
width: { size: CONTENT_WIDTH / 2, type: WidthType.DXA },
borders: noBorders,
children: [new Paragraph({
bidirectional: true, alignment: AlignmentType.END,
children: [new TextRun({ text: caseNumber, bold: true, font: "David", size: 26, rightToLeft: true })]
})]
})
]
})
]
});
}
function mainTitle(text) {
return new Paragraph({
bidirectional: true, alignment: AlignmentType.CENTER,
spacing: { before: 300, after: 300 },
children: [new TextRun({ text, bold: true, font: "David", size: 28, rightToLeft: true, underline: {} })]
});
}
function subHeading(text) {
return new Paragraph({
bidirectional: true, alignment: AlignmentType.START,
spacing: { before: 240, after: 120 },
children: [new TextRun({ text, bold: true, font: "David", size: 24, rightToLeft: true, underline: {} })]
});
}
const doc = new Document({
numbering: {
config: [{
reference: "legal-clauses",
levels: [{
level: 0, format: LevelFormat.DECIMAL, text: "%1.",
alignment: AlignmentType.START, suffix: "tab",
style: { paragraph: { indent: { left: 360, hanging: 360 } } }
}]
}]
},
sections: [{
properties: {
page: { size: { width: PAGE_WIDTH, height: 16838 }, margin: MARGINS },
bidi: true
},
children: [
courtHeader("בית המשפט המחוזי בתל אביב", "ת\"א 12345-01-26"),
mainTitle("כתב תביעה"),
// ... פרטי צדדים, סעיפים, חתימה
]
}]
});
```
---
## תבנית 2: מכתב התראה
```javascript
function letterHeader(firmName, address, phone, email) {
return [
new Paragraph({
bidirectional: true, alignment: AlignmentType.START,
children: [new TextRun({ text: firmName, bold: true, font: "David", size: 28, rightToLeft: true })]
}),
new Paragraph({
bidirectional: true, alignment: AlignmentType.START,
children: [new TextRun({ text: address, font: "David", size: 22, rightToLeft: true })]
}),
new Paragraph({
bidirectional: true, alignment: AlignmentType.START,
spacing: { after: 300 },
children: [new TextRun({ text: `טל': ${phone} | ${email}`, font: "David", size: 22, rightToLeft: true })]
}),
];
}
function subjectLine(text) {
return new Paragraph({
bidirectional: true, alignment: AlignmentType.CENTER,
spacing: { before: 200, after: 200 },
children: [
new TextRun({ text: "הנדון: ", bold: true, font: "David", size: 24, rightToLeft: true }),
new TextRun({ text, bold: true, font: "David", size: 24, rightToLeft: true, underline: {} })
]
});
}
// שימוש:
sections: [{
properties: { page: { size: { width: 11906, height: 16838 }, margin: { top: 1417, right: 1417, bottom: 1417, left: 1417 } }, bidi: true },
children: [
...letterHeader("משרד עו\"ד כהן ושות'", "רח' הרצל 1, תל אביב", "03-1234567", "office@cohen-law.co.il"),
new Paragraph({
bidirectional: true, alignment: AlignmentType.START,
children: [new TextRun({ text: "תאריך: 10.2.2026", font: "David", size: 24, rightToLeft: true })]
}),
new Paragraph({
bidirectional: true, alignment: AlignmentType.START,
spacing: { before: 200 },
children: [new TextRun({ text: "לכבוד: [שם הנמען]", font: "David", size: 24, rightToLeft: true })]
}),
subjectLine("התראה בטרם נקיטת הליכים משפטיים"),
]
}]
```
---
## תבנית 3: הסכם/חוזה
```javascript
const CONTENT_WIDTH = 9638; // A4 עם שוליים 2.5 ס
const noBorders = { /* ראה תבנית 1 */ };
function contractTitle(text) {
return new Paragraph({
bidirectional: true, alignment: AlignmentType.CENTER,
spacing: { after: 300 },
children: [new TextRun({ text, bold: true, font: "David", size: 32, rightToLeft: true })]
});
}
function partyClause(label, name, id, address, alias) {
return new Paragraph({
bidirectional: true, alignment: AlignmentType.BOTH,
spacing: { after: 120 },
children: [
new TextRun({ text: `${label}: `, bold: true, font: "David", size: 24, rightToLeft: true }),
new TextRun({ text: `${name}, ח.פ./ת.ז. ${id}, מ${address} (להלן: "`, font: "David", size: 24, rightToLeft: true }),
new TextRun({ text: alias, bold: true, font: "David", size: 24, rightToLeft: true }),
new TextRun({ text: '")', font: "David", size: 24, rightToLeft: true }),
]
});
}
function signatureTable(contentWidth) {
return new Table({
width: { size: contentWidth, type: WidthType.DXA },
columnWidths: [contentWidth / 2, contentWidth / 2],
visuallyRightToLeft: true,
rows: [new TableRow({
children: [
new TableCell({
borders: noBorders,
children: [
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "_________________", font: "David", size: 24, rightToLeft: true })] }),
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "צד א'", font: "David", size: 24, rightToLeft: true })] })
]
}),
new TableCell({
borders: noBorders,
children: [
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "_________________", font: "David", size: 24, rightToLeft: true })] }),
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "צד ב'", font: "David", size: 24, rightToLeft: true })] })
]
})
]
})]
});
}
sections: [{
properties: { page: { size: { width: 11906, height: 16838 }, margin: { top: 1417, right: 1417, bottom: 1417, left: 1417 } }, bidi: true },
children: [
contractTitle("הסכם שירותים"),
new Paragraph({
bidirectional: true, alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "נערך ונחתם בתל אביב ביום __________", font: "David", size: 24, rightToLeft: true })]
}),
partyClause("מצד אחד", "[שם]", "[מספר]", "[כתובת]", "המזמין"),
partyClause("מצד שני", "[שם]", "[מספר]", "[כתובת]", "הספק"),
// הואילים + סעיפים...
new Paragraph({
bidirectional: true, alignment: AlignmentType.CENTER,
spacing: { before: 400, after: 300 },
children: [new TextRun({ text: "ולראיה באו הצדדים על החתום:", bold: true, font: "David", size: 24, rightToLeft: true })]
}),
signatureTable(CONTENT_WIDTH)
]
}]
```

View File

@@ -0,0 +1,57 @@
# Tracked Changes — עקוב אחר שינויים
## שם מחבר בעברית
```xml
<w:del w:id="10" w:author="עו&quot;ד כהן" w:date="2026-02-06T09:00:00Z">
```
## שינוי ערך (סכום, תאריך, תקופה)
פצל את הטקסט ועטוף רק את הערך שמשתנה:
```xml
<w:r><w:rPr>...RTL PROPS...</w:rPr>
<w:t xml:space="preserve">שכר הטרחה יעמוד על סך של </w:t></w:r>
<w:del w:id="10" w:author="עו&quot;ד כהן" w:date="...">
<w:r><w:rPr>...RTL PROPS...</w:rPr><w:delText>750</w:delText></w:r>
</w:del>
<w:ins w:id="11" w:author="עו&quot;ד כהן" w:date="...">
<w:r><w:rPr>...RTL PROPS...</w:rPr><w:t>850</w:t></w:r>
</w:ins>
<w:r><w:rPr>...RTL PROPS...</w:rPr>
<w:t xml:space="preserve"> ש״ח לשעת עבודה</w:t></w:r>
```
## מחיקת סעיף שלם
```xml
<w:p>
<w:pPr>
<w:bidi/>
<w:jc w:val="both"/>
<w:rPr>
<w:del w:id="20" w:author="עו&quot;ד כהן" w:date="..."/>
</w:rPr>
</w:pPr>
<w:del w:id="21" w:author="עו&quot;ד כהן" w:date="...">
<w:r><w:rPr>...RTL PROPS...</w:rPr>
<w:delText>הסעיף שנמחק</w:delText></w:r>
</w:del>
</w:p>
```
## RTL PROPS — בלוק rPr מלא לכל run
```xml
<w:rPr>
<w:rFonts w:ascii="David" w:cs="David" w:eastAsia="David" w:hAnsi="David"/>
<w:sz w:val="24"/>
<w:szCs w:val="24"/>
<w:rtl/>
</w:rPr>
```
## קבלה/דחייה של שינויים
| פעולה | לפני | אחרי |
|-------|------|------|
| קבלת הוספה | `<w:ins ...><w:r>...<w:t>טקסט</w:t></w:r></w:ins>` | `<w:r>...<w:t>טקסט</w:t></w:r>` |
| דחיית הוספה | `<w:ins ...><w:r>...</w:r></w:ins>` | *(מחק הכל)* |
| קבלת מחיקה | `<w:del ...><w:r>...<w:delText>טקסט</w:delText></w:r></w:del>` | *(מחק הכל)* |
| דחיית מחיקה | `<w:del ...><w:r>...<w:delText>טקסט</w:delText></w:r></w:del>` | `<w:r>...<w:t>טקסט</w:t></w:r>` |

View File

@@ -9,6 +9,7 @@ import {
type ApprovalCategory,
type ApprovalSeverity,
} from "@/lib/api/chair";
import { formatDateLong } from "@/lib/format-date";
/**
* מרכז אישורים — דפנה (INV-G10).
@@ -35,18 +36,7 @@ const SEVERITY_RAIL: Record<ApprovalSeverity, string> = {
ok: "border-s-success",
};
function formatDate(iso?: string | null): string {
if (!iso) return "";
try {
return new Date(iso).toLocaleDateString("he-IL", {
day: "numeric",
month: "long",
year: "numeric",
});
} catch {
return "";
}
}
const formatDate = (iso?: string | null) => formatDateLong(iso);
function ApprovalCard({ cat }: { cat: ApprovalCategory }) {
const cleared = cat.count === 0;

View File

@@ -28,19 +28,7 @@ import {
import { useCases, useRestoreCase, type Case } from "@/lib/api/cases";
import { subtypeOf } from "@/components/cases/appeal-type-bars";
import { APPEAL_SUBTYPE_LABELS, type AppealSubtype } from "@/lib/practice-area";
function formatDate(iso?: string | null) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("he-IL", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return iso;
}
}
import { formatDate, getIsraelYear } from "@/lib/format-date";
// type chip styling per mockup 05 (.t-lic / .t-bet / .t-comp)
const TYPE_CHIP: Record<string, string> = {
@@ -177,8 +165,8 @@ export default function ArchivePage() {
const set = new Set<string>();
for (const c of rows) {
if (!c.archived_at) continue;
const y = new Date(c.archived_at).getFullYear();
if (!Number.isNaN(y)) set.add(String(y));
const y = getIsraelYear(c.archived_at);
if (y !== null) set.add(String(y));
}
return [...set].sort((a, b) => Number(b) - Number(a));
}, [rows]);
@@ -209,7 +197,7 @@ export default function ArchivePage() {
if (yearFilter !== "all") {
all = all.filter((r) => {
const iso = r.original.archived_at;
return iso != null && String(new Date(iso).getFullYear()) === yearFilter;
return iso != null && String(getIsraelYear(iso)) === yearFilter;
});
}
return all;

View File

@@ -1,449 +0,0 @@
"use client";
import { use, useRef, useState } from "react";
import Link from "next/link";
import { FileText } from "lucide-react";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { SubsectionCard } from "@/components/compose/subsection-card";
import { PrecedentsSection } from "@/components/compose/precedents-section";
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
import { Markdown } from "@/components/ui/markdown";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCase, type CaseStatus } from "@/lib/api/cases";
import { useResearchAnalysis } from "@/lib/api/research";
import { useCasePrecedents } from "@/lib/api/precedents";
import { APPEAL_SUBTYPES } from "@/lib/practice-area";
import { DOC_TYPE_LABELS, type DocType } from "@/lib/doc-types";
// ── Case-status → Hebrew label + tone (mockup 03 status chip) ────────────────
const STATUS_CHIP: Record<string, { label: string; cls: string }> = {
new: { label: "חדש", cls: "bg-rule-soft text-ink-muted border-rule" },
processing: { label: "בעיבוד", cls: "bg-info-bg text-info border-info/30" },
documents_ready: { label: "מסמכים מוכנים", cls: "bg-info-bg text-info border-info/30" },
outcome_set: { label: "תוצאה נקבעה", cls: "bg-info-bg text-info border-info/30" },
direction_approved: { label: "כיוון אושר", cls: "bg-info-bg text-info border-info/30" },
qa_review: { label: "בדיקת-איכות", cls: "bg-gold-wash text-gold-deep border-gold/40" },
drafted: { label: "טיוטה", cls: "bg-gold-wash text-gold-deep border-gold/40" },
exported: { label: "יוצא", cls: "bg-success-bg text-success border-success/40" },
reviewed: { label: "נסקר", cls: "bg-success-bg text-success border-success/40" },
final: { label: "סופי", cls: "bg-success-bg text-success border-success/40" },
};
function StatusChip({ status }: { status?: CaseStatus }) {
const c = (status && STATUS_CHIP[status]) || {
label: "בעריכה",
cls: "bg-info-bg text-info border-info/30",
};
return (
<span
className={`rounded-full text-[0.78rem] font-semibold px-3 py-0.5 border ${c.cls}`}
>
{c.label}
</span>
);
}
function subtypeLabel(subtype?: string | null): string | null {
if (!subtype) return null;
return APPEAL_SUBTYPES.find((s) => s.value === subtype)?.label ?? null;
}
function ProseSection({ title, content }: { title: string; content?: string }) {
if (!content?.trim()) return null;
return (
<section className="space-y-2">
<h3 className="text-[0.78rem] uppercase tracking-[0.08em] text-gold-deep font-semibold">
{title}
</h3>
<Markdown content={content.trim()} />
</section>
);
}
// ── "השלמה והעברה" rail card — DOCX export, upload, download (all real) ──────
function FinishRail({
caseNumber,
hasAnalysis,
onUploaded,
}: {
caseNumber: string;
hasAnalysis: boolean;
onUploaded: () => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [uploadMsg, setUploadMsg] = useState<{ ok: boolean; text: string } | null>(null);
async function handleUpload(file: File) {
setUploading(true);
setUploadMsg(null);
try {
const form = new FormData();
form.append("file", file);
const res = await fetch(`/api/cases/${caseNumber}/research/analysis/upload`, {
method: "PUT",
body: form,
});
const data = await res.json();
if (!res.ok) {
setUploadMsg({ ok: false, text: data.detail || "שגיאה בהעלאה" });
return;
}
setUploadMsg({
ok: true,
text: `הקובץ הועלה — ${data.sections.threshold_claims} טענות סף, ${data.sections.issues} סוגיות`,
});
onUploaded();
} catch {
setUploadMsg({ ok: false, text: "שגיאת רשת" });
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
}
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-4 py-4">
<h3 className="text-navy text-[0.9rem] font-semibold mb-3">השלמה והעברה</h3>
<input
ref={fileRef}
type="file"
accept=".md"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleUpload(f);
}}
/>
<div className="space-y-2">
{hasAnalysis && (
<Button
variant="outline"
className="w-full justify-center"
onClick={() => {
const a = document.createElement("a");
a.href = `/api/cases/${caseNumber}/research/analysis/export-docx`;
a.click();
}}
>
ייצוא DOCX
</Button>
)}
<Button
className="w-full justify-center bg-gold text-white hover:bg-gold-deep"
disabled={uploading}
onClick={() => fileRef.current?.click()}
>
{uploading ? "מעלה…" : "העלאת ניתוח מעודכן"}
</Button>
{hasAnalysis && (
<Button
variant="outline"
className="w-full justify-center"
onClick={() => {
const a = document.createElement("a");
a.href = `/api/cases/${caseNumber}/research/analysis/download`;
a.download = `analysis-${caseNumber}.md`;
a.click();
}}
>
הורד ניתוח (MD)
</Button>
)}
</div>
{uploadMsg && (
<p className={`text-xs mt-2 ${uploadMsg.ok ? "text-success" : "text-danger"}`}>
{uploadMsg.text}
</p>
)}
{/* mockup 03: stage indicators — informational pointers, not actions */}
<div className="mt-3 space-y-0">
<div className="text-[0.78rem] text-ink-muted pt-2 border-t border-rule-soft">
<b className="text-navy">הרץ למידת-קול</b> ממתין להעלאת הסופי
</div>
<div className="text-[0.78rem] text-ink-muted pt-2 mt-2 border-t border-rule-soft">
<b className="text-navy">הרץ אימות-הלכות</b> ממתין להעלאת הסופי
</div>
</div>
<Button asChild variant="ghost" className="w-full justify-center mt-3 text-ink-muted">
<Link href={`/cases/${caseNumber}`}>חזרה לתיק</Link>
</Button>
</CardContent>
</Card>
);
}
export default function ComposePage({
params,
}: {
params: Promise<{ caseNumber: string }>;
}) {
const { caseNumber } = use(params);
const caseQuery = useCase(caseNumber);
const analysis = useResearchAnalysis(caseNumber);
const precedentsQuery = useCasePrecedents(caseNumber);
/* Partition the flat list into scopes so each child renders its own slice
* without re-fetching. Done once at the page level. */
const allPrecedents = precedentsQuery.data ?? [];
const caseLevelPrecedents = allPrecedents.filter((p) => p.section_id === null);
const precedentsBySection = new Map<string, typeof allPrecedents>();
for (const p of allPrecedents) {
if (p.section_id) {
const existing = precedentsBySection.get(p.section_id) ?? [];
existing.push(p);
precedentsBySection.set(p.section_id, existing);
}
}
const practiceArea = caseQuery.data?.practice_area ?? null;
const subtype = subtypeLabel(caseQuery.data?.appeal_subtype);
const parties = (() => {
const c = caseQuery.data;
if (!c) return null;
const app = c.appellants?.length ? c.appellants.join(", ") : null;
const resp = c.respondents?.length ? c.respondents.join(", ") : null;
const out: string[] = [];
if (app) out.push(`עוררים: ${app}`);
if (resp) out.push(`משיבה: ${resp}`);
return out.length ? out.join(" · ") : c.title || null;
})();
const documents = caseQuery.data?.documents ?? [];
const isNotFound =
analysis.error instanceof Error &&
/404|לא נמצא|טרם בוצע/.test(analysis.error.message);
return (
<AppShell>
{/* ── Case header band (mockup 03) — parchment strip, full-bleed to the
AppShell <main> edges (which pads px-10 py-10) ── */}
<div className="-mx-10 -mt-10 mb-6 border-b border-rule bg-parchment px-10 py-5">
<nav className="text-[0.78rem] text-ink-muted flex items-center gap-2 mb-2">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden>·</span>
<Link href={`/cases/${caseNumber}`} className="hover:text-gold-deep">
ערר {caseNumber}
</Link>
<span aria-hidden>·</span>
<span className="text-navy">עורך החלטה</span>
</nav>
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-navy text-2xl font-bold mb-0">ערר {caseNumber}</h1>
<StatusChip status={caseQuery.data?.status} />
{subtype && (
<span className="rounded-full text-[0.78rem] font-semibold px-3 py-0.5 border border-rule bg-gold-wash text-gold-deep">
{subtype}
</span>
)}
{/* INV-G10: source-of-truth pill — the blocks are the canonical text */}
<span className="ms-auto rounded-lg text-[0.8rem] font-semibold px-3.5 py-1.5 border border-gold bg-gold-wash text-gold-deep">
מקור-אמת: בלוקים
</span>
</div>
{parties && <p className="text-ink-soft text-sm mt-2">{parties}</p>}
</div>
{caseQuery.isPending ? (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-3">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-96" />
<Skeleton className="h-4 w-80" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
) : (
/* ── Two-column workspace: tabbed main editor + 320px side rail ──── */
<div className="grid gap-6 lg:grid-cols-[1fr_320px] items-start">
{/* MAIN — block editor (default) + chair positions, as tabs (mockup 03) */}
<div className="min-w-0">
<Tabs defaultValue="blocks" dir="rtl">
<TabsList className="bg-rule-soft/60">
<TabsTrigger value="blocks">עורך הבלוקים</TabsTrigger>
<TabsTrigger value="positions">עמדות וטענות</TabsTrigger>
</TabsList>
{/* Tab 1 — the 12-block decision editor (reused DecisionBlocksPanel) */}
<TabsContent value="blocks" className="mt-5">
<DecisionBlocksPanel caseNumber={caseNumber} />
</TabsContent>
{/* Tab 2 — chair positions on the analyst's threshold-claims + issues */}
<TabsContent value="positions" className="mt-5">
{analysis.isPending ? (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-3">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-96" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
) : isNotFound ? (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-12 text-center space-y-3">
<div className="text-gold text-3xl" aria-hidden></div>
<h2 className="text-navy text-lg mb-0">
טרם בוצע ניתוח משפטי לתיק זה
</h2>
<p className="text-ink-muted text-sm max-w-md mx-auto">
לאחר שקובץ <code>analysis-and-research.md</code> ייווצר, תוכלי
לערוך כאן את עמדת הוועדה לכל טענת סף וסוגיה.
</p>
</CardContent>
</Card>
) : analysis.error ? (
<Card className="bg-danger-bg border-danger/40">
<CardContent className="px-6 py-5 text-center">
<p className="text-danger">{analysis.error.message}</p>
</CardContent>
</Card>
) : analysis.data ? (
<div className="space-y-6">
{/* Threshold claims */}
{analysis.data.threshold_claims &&
analysis.data.threshold_claims.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-navy text-lg font-semibold mb-0">טענות סף</h2>
<span className="text-[0.72rem] rounded-full bg-gold-wash text-gold-deep px-2 py-0.5 border border-gold/40 tabular-nums">
{analysis.data.threshold_claims.length}
</span>
</div>
<div className="space-y-2.5">
{analysis.data.threshold_claims.map((tc) => (
<SubsectionCard
key={tc.id}
caseNumber={caseNumber}
item={tc}
precedents={precedentsBySection.get(tc.id) ?? []}
practiceArea={practiceArea}
/>
))}
</div>
</div>
)}
{/* Issues */}
{analysis.data.issues && analysis.data.issues.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-navy text-lg font-semibold mb-0">סוגיות להכרעה</h2>
<span className="text-[0.72rem] rounded-full bg-gold-wash text-gold-deep px-2 py-0.5 border border-gold/40 tabular-nums">
{analysis.data.issues.length}
</span>
</div>
<div className="space-y-2.5">
{analysis.data.issues.map((iss) => (
<SubsectionCard
key={iss.id}
caseNumber={caseNumber}
item={iss}
precedents={precedentsBySection.get(iss.id) ?? []}
practiceArea={practiceArea}
/>
))}
</div>
</div>
)}
{!analysis.data.threshold_claims?.length &&
!analysis.data.issues?.length && (
<Card className="bg-surface border-rule">
<CardContent className="px-6 py-10 text-center text-ink-muted">
לא נמצאו טענות סף או סוגיות בניתוח זה.
</CardContent>
</Card>
)}
{/* Background prose — supporting context after the decision points */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-5">
<h2 className="text-navy text-lg font-semibold mb-0">רקע לניתוח</h2>
<ProseSection title="צד מיוצג" content={analysis.data.represented_party} />
<ProseSection title="רקע דיוני" content={analysis.data.procedural_background} />
<ProseSection title="עובדות מוסכמות" content={analysis.data.agreed_facts} />
<ProseSection title="עובדות במחלוקת" content={analysis.data.disputed_facts} />
</CardContent>
</Card>
{analysis.data.conclusions?.trim() && (
<Card className="bg-gold-wash border-gold/40 shadow-sm">
<CardContent className="px-6 py-5 space-y-3">
<h2 className="text-gold-deep text-lg font-semibold mb-0">מסקנות</h2>
<Markdown content={analysis.data.conclusions.trim()} />
</CardContent>
</Card>
)}
</div>
) : null}
</TabsContent>
</Tabs>
</div>
{/* SIDE RAIL — documents · attached precedents · finish-and-transfer */}
<aside className="space-y-4 lg:sticky lg:top-4">
{/* מסמכי התיק */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-4 py-4">
<h3 className="text-navy text-[0.9rem] font-semibold mb-2">מסמכי התיק</h3>
{documents.length === 0 ? (
<p className="text-[0.78rem] text-ink-muted">אין מסמכים מצורפים</p>
) : (
<ul>
{documents.map((d) => (
<li
key={d.id}
className="flex items-center gap-2 text-[0.82rem] text-ink-soft py-1.5 border-b border-rule-soft last:border-0"
>
<FileText className="w-3.5 h-3.5 text-ink-muted shrink-0" aria-hidden />
<span className="truncate flex-1" title={d.title}>
{d.title || "מסמך"}
</span>
<span className="rounded bg-rule-soft text-ink-muted text-[0.68rem] px-1.5 py-0.5 shrink-0 whitespace-nowrap">
{DOC_TYPE_LABELS[d.doc_type as DocType] ?? d.doc_type}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
{/* פסיקה מצורפת (case-level) */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-4 py-4">
<h3 className="text-navy text-[0.9rem] font-semibold mb-1">פסיקה מצורפת</h3>
<p className="text-[0.72rem] text-ink-muted mb-3">
ציטוטים התומכים בעמדה באופן רוחבי ישולבו בפתיחת בלוק י (דיון).
</p>
<PrecedentsSection
caseNumber={caseNumber}
sectionId={null}
precedents={caseLevelPrecedents}
practiceArea={practiceArea}
emptyHelperText="עדיין לא צורפה פסיקה כללית לתיק"
/>
</CardContent>
</Card>
{/* השלמה והעברה */}
<FinishRail
caseNumber={caseNumber}
hasAnalysis={!!analysis.data}
onUploaded={() => analysis.refetch()}
/>
</aside>
</div>
)}
</AppShell>
);
}

View File

@@ -1,33 +1,33 @@
"use client";
import { use } from "react";
import { use, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Accordion, AccordionContent, AccordionItem, AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { CaseHeader } from "@/components/cases/case-header";
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
import { WorkflowTimeline } from "@/components/cases/workflow-timeline";
import { StatusGuide } from "@/components/cases/status-guide";
import { StatusChanger } from "@/components/cases/status-changer";
import { DocumentsPanel } from "@/components/cases/documents-panel";
import { DraftsPanel } from "@/components/cases/drafts-panel";
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
import { PositionsPanel } from "@/components/cases/positions-panel";
import { HearingChangesPanel } from "@/components/cases/hearing-changes-panel";
import { CitationVerificationPanel } from "@/components/compose/citation-verification-panel";
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
import { AgentActivityPreview } from "@/components/cases/agent-activity-preview";
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
import { UploadSheet } from "@/components/documents/upload-sheet";
import { expectedOutcomes } from "@/lib/schemas/case";
import { useCase, useStartWorkflow } from "@/lib/api/cases";
import { toast } from "sonner";
import { Play, Loader2 } from "lucide-react";
const EXPECTED_OUTCOME_LABELS: Record<string, string> = Object.fromEntries(
expectedOutcomes.map((o) => [o.value, o.label]),
);
import {
Play, Loader2, LayoutGrid, Scale, BadgeCheck, FileText, MessageSquare, Users,
type LucideIcon,
} from "lucide-react";
/*
* Next 16 breaking change: route params are now a Promise.
@@ -39,14 +39,14 @@ export default function CaseDetailPage({
params: Promise<{ caseNumber: string }>;
}) {
const { caseNumber } = use(params);
const { data, isPending, error } = useCase(caseNumber);
const [tab, setTab] = useState("overview");
const { data, isPending, error, refetch } = useCase(caseNumber);
const startWorkflow = useStartWorkflow(caseNumber);
const canStartWorkflow = data?.status === "new" || data?.status === "documents_ready";
const expectedOutcomeLabel = data?.expected_outcome
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
: null;
if (error) {
// Only take over the whole page when there is NO data to show. A transient
// 5xx on the 5s background refetch must not blow away an already-loaded page.
if (error && !data) {
return (
<AppShell>
<section className="space-y-6">
@@ -54,9 +54,14 @@ export default function CaseDetailPage({
<CardContent className="px-6 py-6 text-center space-y-3">
<p className="text-danger font-semibold">שגיאה בטעינת התיק</p>
<p className="text-sm text-ink-muted">{error.message}</p>
<Button asChild variant="outline">
<Link href="/">חזרה לרשימת התיקים</Link>
</Button>
<div className="flex items-center justify-center gap-2">
<Button variant="outline" onClick={() => refetch()}>
נסה שוב
</Button>
<Button asChild variant="ghost">
<Link href="/">חזרה לרשימת התיקים</Link>
</Button>
</div>
</CardContent>
</Card>
</section>
@@ -64,23 +69,32 @@ export default function CaseDetailPage({
);
}
// Workflow order (X17): intake → arguments+positions → verify citations →
// write decision → drafts/final → agents (monitoring).
const tabDefs: [string, string, LucideIcon][] = [
["overview", "סקירה", LayoutGrid],
["arguments", "טיעונים ועמדות", Scale],
["verify", "אימות פסיקה", BadgeCheck],
["decision", "ההחלטה", FileText],
["drafts", "טיוטות והערות", MessageSquare],
["agents", "סוכנים", Users],
];
// V2 "segmented" tab strip (X17): each tab a pill, active = raised white card.
// Spread full-width via justify-between (chair request) — wide gaps with few
// tabs, tightening as more are added; gap-2 is the no-overlap floor.
const tabsList = (
<TabsList
variant="line"
className="gap-6 h-auto p-0 rounded-none -mb-px"
className="!h-auto w-full gap-2 p-0 justify-between"
>
{[
["overview", "סקירה"],
["arguments", "טיעונים"],
["decision", "ההחלטה"],
["drafts", "טיוטות והערות"],
["agents", "סוכנים"],
].map(([value, label]) => (
{tabDefs.map(([value, label, Icon]) => (
<TabsTrigger
key={value}
value={value}
className="flex-none rounded-none px-0 pb-3.5 pt-0 text-[0.92rem] font-medium text-ink-muted data-active:text-navy data-active:font-semibold data-active:after:bg-gold data-active:after:bottom-0"
className="flex-none gap-1.5 rounded-lg border border-transparent px-3.5 py-1.5 text-[0.9rem] font-medium text-ink-soft after:hidden hover:bg-parchment/60 data-active:bg-surface data-active:text-navy data-active:font-semibold data-active:border-rule data-active:shadow-sm [&_svg]:text-ink-light data-active:[&_svg]:text-gold-deep"
>
<Icon className="w-4 h-4" />
{label}
</TabsTrigger>
))}
@@ -116,7 +130,7 @@ export default function CaseDetailPage({
return (
<AppShell>
<Tabs defaultValue="overview" dir="rtl">
<Tabs value={tab} onValueChange={setTab} dir="rtl">
{/* parchment band — header (title/chips/parties/actions) + tab strip */}
{isPending ? (
<div className="-mx-10 -mt-10 mb-2 bg-parchment border-b border-rule px-10 pt-6 pb-4 space-y-3">
@@ -125,85 +139,125 @@ export default function CaseDetailPage({
<Skeleton className="h-6 w-96" />
</div>
) : (
<CaseHeader data={data} actions={bandActions} tabs={tabsList} />
<CaseHeader caseNumber={caseNumber} data={data} actions={bandActions} tabs={tabsList} />
)}
{/* two-column wrap — main tab content (1fr) + rail (340px) */}
<div className="grid gap-6 lg:grid-cols-[1fr_340px] items-start mt-6">
<div className="min-w-0">
<TabsContent value="overview" className="mt-0 space-y-5">
{/* full-width tab content — status now lives in the always-visible band
StatusRail (banner A, X17), so every tab keeps the status context. */}
<div className="min-w-0 mt-6">
<TabsContent value="overview" className="mt-0">
{/* documents (wider) + agent-activity preview (mockup 18j) */}
<div className="grid gap-6 lg:grid-cols-[1.4fr_1fr] items-start">
<DocumentsPanel data={data} />
<AgentActivityPreview caseNumber={caseNumber} />
</div>
</TabsContent>
{/* gold CTA — open the decision editor */}
<Button
asChild
className="w-full bg-gold text-white hover:bg-gold-deep border-transparent py-6 text-base font-semibold"
{/* טיעונים ועמדות — chair positions (editing) over the analyst's
issues, with the aggregated by-party arguments collapsible below
(merged from the deleted /compose editor, mockup 18f). */}
<TabsContent value="arguments" className="mt-0 space-y-4">
{/* עמדות וטענות — collapsed by default (chair preference #226),
mirroring the two accordions below. */}
<Accordion type="single" collapsible>
<AccordionItem
value="positions"
className="overflow-hidden rounded-xl border border-rule bg-surface shadow-sm"
>
<Link href={`/cases/${caseNumber}/compose`}>
פתח עורך החלטה
</Link>
</Button>
</TabsContent>
<TabsContent value="arguments" className="mt-0">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<AccordionTrigger className="px-6 py-4 hover:no-underline">
<span className="flex flex-1 flex-col items-start">
<span className="text-navy text-[0.95rem] font-bold">
עמדות וטענות
</span>
<span className="text-ink-muted text-[0.78rem]">
סוגיות-המחלוקת ועמדות הצדדים · עורך עמדת-היו״ר מזין את בלוק י׳
</span>
</span>
</AccordionTrigger>
<AccordionContent className="px-6 pb-5 pt-0">
<PositionsPanel caseNumber={caseNumber} />
</AccordionContent>
</AccordionItem>
</Accordion>
<Accordion type="single" collapsible>
<AccordionItem
value="byparty"
className="overflow-hidden rounded-xl border border-rule bg-surface shadow-sm"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline">
<span className="flex flex-1 flex-col items-start">
<span className="text-navy text-[0.95rem] font-bold">
טיעונים מאוגדים לפי צד
</span>
<span className="text-ink-muted text-[0.78rem]">
תצוגה מסונתזת של הטענות הגולמיות, מקובצת לפי צד וקדימות
</span>
</span>
</AccordionTrigger>
<AccordionContent className="px-6 pb-5 pt-0">
<LegalArgumentsPanel caseNumber={caseNumber} />
</CardContent>
</Card>
</TabsContent>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* מה קרה בדיון — comparative analysis of the hearing protocol vs.
the written pleadings (#226). Sits below the aggregated arguments
because it speaks to those same arguments in their oral gloss.
Collapsed by default, mirroring the "by-party" accordion above. */}
<Accordion type="single" collapsible>
<AccordionItem
value="hearing"
className="overflow-hidden rounded-xl border border-rule bg-surface shadow-sm"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline">
<span className="flex flex-1 flex-col items-start">
<span className="text-navy text-[0.95rem] font-bold">
מה קרה בדיון
</span>
<span className="text-ink-muted text-[0.78rem]">
השוואת הטענות בכתב מול הדיון בעל-פה מה התחזק ומה נטען לראשונה
</span>
</span>
</AccordionTrigger>
<AccordionContent className="px-6 pb-5 pt-0">
<HearingChangesPanel caseNumber={caseNumber} />
</AccordionContent>
</AccordionItem>
</Accordion>
</TabsContent>
<TabsContent value="decision" className="mt-0">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<DecisionBlocksPanel caseNumber={caseNumber} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="drafts" className="mt-0">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="agents" className="mt-0">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<AgentActivityFeed caseNumber={caseNumber} />
</CardContent>
</Card>
</TabsContent>
</div>
{/* rail — status timeline + status controls (mockup .rail) */}
<div className="space-y-5">
<Card className="bg-surface border-rule shadow-sm overflow-hidden p-0 h-fit">
<div className="px-5 py-3.5 border-b border-rule-soft bg-parchment text-[0.92rem] font-semibold text-navy">
סטטוס התיק
</div>
<CardContent className="px-5 py-4 space-y-4">
<AgentStatusWidget caseNumber={caseNumber} />
<WorkflowTimeline status={data?.status} />
{expectedOutcomeLabel && (
<div className="border-t border-rule-soft pt-3">
<dl className="flex justify-between items-center text-sm">
<dt className="text-ink-muted">תוצאה צפויה</dt>
<dd className="rounded-full bg-warn-bg text-warn text-[0.75rem] font-semibold px-3 py-0.5">
{expectedOutcomeLabel}
</dd>
</dl>
</div>
)}
<StatusChanger caseNumber={caseNumber} currentStatus={data?.status} />
<StatusGuide />
{/* אימות פסיקה — per-argument supporting-precedent verify gate (#154),
relocated from the deleted /compose editor to a top-level tab. */}
<TabsContent value="verify" className="mt-0">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<CitationVerificationPanel caseNumber={caseNumber} />
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="decision" className="mt-0">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<DecisionBlocksPanel caseNumber={caseNumber} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="drafts" className="mt-0">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="agents" className="mt-0">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<AgentActivityFeed caseNumber={caseNumber} />
</CardContent>
</Card>
</TabsContent>
</div>
</Tabs>
</AppShell>

View File

@@ -16,6 +16,7 @@ import {
type ChairFeedback,
type FeedbackCategory,
} from "@/lib/api/feedback";
import { formatDateLong } from "@/lib/format-date";
/**
* מרכז הערות יו"ר — הדף המרכזי לטיפול בכל הערות דפנה שנרשמו על טיוטות
@@ -33,18 +34,7 @@ const CAT_CHIP: Record<FeedbackCategory, string> = {
other: "bg-rule-soft text-ink-soft",
};
function formatDate(iso?: string | null): string {
if (!iso) return "";
try {
return new Date(iso).toLocaleDateString("he-IL", {
day: "numeric",
month: "long",
year: "numeric",
});
} catch {
return "";
}
}
const formatDate = (iso?: string | null) => formatDateLong(iso);
function FeedbackCard({ fb }: { fb: ChairFeedback }) {
const resolve = useResolveFeedback();

View File

@@ -22,12 +22,12 @@ import { MissingPrecedentsTable } from "@/components/missing-precedents/missing-
type StatusFilter = MissingPrecedentStatus | "all";
// Only the two states the chair acts on: open gaps to fill, and closed gaps for
// reference. "הועלה" (transient) and "לא-רלוונטי" were dropped from the filter,
// and "הכל" with them (chair's request, design 09).
const STATUS_CHIPS: { value: StatusFilter; label: string }[] = [
{ value: "open", label: "פתוח" },
{ value: "uploaded", label: "הועלה" },
{ value: "closed", label: "נסגר" },
{ value: "irrelevant", label: "לא-רלוונטי" },
{ value: "all", label: "הכל" },
];
export default function MissingPrecedentsPage() {
@@ -145,15 +145,13 @@ export default function MissingPrecedentsPage() {
<div className="rounded-lg border border-rule bg-parchment px-5 py-3.5 text-[0.82rem] text-ink-muted leading-7">
<b className="text-ink-soft">מחזור-חיים:</b>{" "}
<LifecycleChip tone="open">פתוח</LifecycleChip> {" "}
<LifecycleChip tone="up">הועלה</LifecycleChip> {" "}
<LifecycleChip tone="closed">נסגר</LifecycleChip>. פריט נפתח אוטומטית
בעת חילוץ ציטוט שאין לו תקדים בקורפוס; בהעלאת פסק-הדין הוא מקושר לרשומת
הפסיקה דרך{" "}
<code className="rounded border border-rule bg-surface px-1.5 py-0.5 text-[0.75rem] text-gold-deep" dir="ltr">
linked_case_law_id
</code>{" "}
ונסגר. פריט שאינו רלוונטי מסומן{" "}
<LifecycleChip tone="na">לא-רלוונטי</LifecycleChip> מבלי שתידרש העלאה.
ונסגר.
</div>
</section>
</AppShell>

View File

@@ -12,7 +12,6 @@ import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Dialog,
DialogContent,
@@ -37,6 +36,7 @@ import {
type SubscriptionUsage,
type UsageWindow,
} from "@/lib/api/operations";
import { formatTime, israelParts } from "@/lib/format-date";
function mb(bytes: number): string {
return `${Math.round((bytes || 0) / 1024 / 1024)}MB`;
@@ -47,8 +47,7 @@ function mb(bytes: number): string {
// and resumes on its own when a window resets.
function usageResetLabel(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return `איפוס ${d.toLocaleTimeString("he-IL", { hour: "2-digit", minute: "2-digit" })}`;
return `איפוס ${formatTime(iso)}`;
}
function UsageMeter({ label, w }: { label: string; w?: UsageWindow | null }) {
@@ -196,8 +195,9 @@ function nextSaturday18(): Date {
}
const HE_DAYS = ["א׳", "ב׳", "ג׳", "ד׳", "ה׳", "ו׳", "ש׳"];
function fmtDeadline(iso: string): string {
const d = new Date(iso);
return `${HE_DAYS[d.getDay()]} ${pad2(d.getDate())}/${pad2(d.getMonth() + 1)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
const p = israelParts(iso);
if (!p) return "—";
return `${HE_DAYS[p.weekday]} ${pad2(p.day)}/${pad2(p.month)} ${pad2(p.hour)}:${pad2(p.minute)}`;
}
function BurstControl({ s }: { s: OpsService }) {
@@ -673,11 +673,11 @@ function RunLogDialog({ run, onClose }: { run: AgentRun | null; onClose: () => v
) : error ? (
<p className="text-sm text-destructive">שגיאה בטעינת הלוג: {String(error)}</p>
) : (
<ScrollArea className="h-[60vh] rounded-md border border-rule-soft bg-rule-soft/20 p-3">
<pre dir="ltr" className="text-[0.72rem] leading-relaxed whitespace-pre-wrap break-words text-navy text-start">
<div className="h-[60vh] w-full overflow-y-auto overflow-x-hidden rounded-md border border-rule-soft bg-rule-soft/20 p-3">
<pre dir="ltr" className="w-full min-w-0 max-w-full overflow-x-hidden text-[0.72rem] leading-relaxed whitespace-pre-wrap break-all text-navy text-start">
{text || "אין פלט עדיין."}
</pre>
</ScrollArea>
</div>
)}
</DialogContent>
</Dialog>

View File

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

View File

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

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