feat: external precedent library with auto halacha extraction
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s

Adds a third corpus of legal authority distinct from style_corpus
(Daphna's prior decisions for voice) and case_precedents (chair-attached
quotes per case). The new corpus holds chair-uploaded court rulings and
other appeals committee decisions, with binding rules (הלכות) extracted
automatically and queued for chair approval.

Pipeline (web/app.py + services/precedent_library.py):
file → extract → chunk → Voyage embed → halacha_extractor → store +
publish progress over the existing Redis SSE channel.

Schema V7 (services/db.py): extends case_law with source_kind +
extraction status fields under a CHECK constraint pinning practice_area
to the three appeals committee domains (rishuy_uvniya, betterment_levy,
compensation_197). New precedent_chunks (vector(1024)) and halachot
tables (vector(1024) over rule_statement, IVFFlat indexes, gin on
practice_areas/subject_tags). Halachot start as pending_review; only
approved/published rows are visible to search_precedent_library.

Agents: legal-writer, legal-researcher, legal-analyst, legal-ceo,
legal-qa get search_precedent_library. legal-writer prompt explains
the three-corpus distinction and CREAC use; legal-qa now verifies that
every cited halacha resolves to an approved row in the corpus.

UI: /precedents page with four tabs — library / semantic search /
pending review (J/K nav, A/R/E shortcuts, badge count) / stats.
Reuses the existing upload-sheet progress + SSE pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 08:38:18 +00:00
parent a6edb75bbf
commit 7ee90dce31
23 changed files with 3853 additions and 67 deletions

View File

@@ -18,6 +18,7 @@ tools:
- mcp__legal-ai__get_claims - mcp__legal-ai__get_claims
- mcp__legal-ai__search_case_documents - mcp__legal-ai__search_case_documents
- mcp__legal-ai__search_decisions - mcp__legal-ai__search_decisions
- mcp__legal-ai__search_precedent_library
- mcp__legal-ai__find_similar_cases - mcp__legal-ai__find_similar_cases
- mcp__legal-ai__workflow_status - mcp__legal-ai__workflow_status
- mcp__legal-ai__processing_status - mcp__legal-ai__processing_status

View File

@@ -17,6 +17,7 @@ tools:
- mcp__legal-ai__record_chair_feedback - mcp__legal-ai__record_chair_feedback
- mcp__legal-ai__list_chair_feedback - mcp__legal-ai__list_chair_feedback
- mcp__legal-ai__search_case_documents - mcp__legal-ai__search_case_documents
- mcp__legal-ai__search_precedent_library
- mcp__legal-ai__workflow_status - mcp__legal-ai__workflow_status
- mcp__legal-ai__processing_status - mcp__legal-ai__processing_status
- mcp__legal-ai__get_metrics - mcp__legal-ai__get_metrics

View File

@@ -14,6 +14,7 @@ tools:
- mcp__legal-ai__get_metrics - mcp__legal-ai__get_metrics
- mcp__legal-ai__workflow_status - mcp__legal-ai__workflow_status
- mcp__legal-ai__search_case_documents - mcp__legal-ai__search_case_documents
- mcp__legal-ai__search_precedent_library
--- ---
# בודק איכות — סוכן QA להחלטות ועדת ערר # בודק איכות — סוכן QA להחלטות ועדת ערר
@@ -115,6 +116,7 @@ tools:
#### תקדמים (מ-`daphna-precedent-network.md`) #### תקדמים (מ-`daphna-precedent-network.md`)
- לכל סוגיה משפטית — האם נבחר התקדים המועדף של דפנה? - לכל סוגיה משפטית — האם נבחר התקדים המועדף של דפנה?
- האם יש תקדים אישי שלה רלוונטי? אם כן — האם הופנה אליו (חיסכון / דחייה / הבחנה)? - האם יש תקדים אישי שלה רלוונטי? אם כן — האם הופנה אליו (חיסכון / דחייה / הבחנה)?
- **ציטוטי פסיקה חיצונית בבלוק י** — לכל ציטוט (`citation` + `supporting_quote`) שמופיע, חפש ב-`search_precedent_library` (subject_tag הרלוונטי) וודא שהציטוט קיים בקורפוס ושהלכה אושרה. ציטוט שלא תואם להלכה מאושרת = critical.
#### תבנית קבלה (מ-`daphna-acceptance-architecture.md` — אם תוצאה = קבלה) #### תבנית קבלה (מ-`daphna-acceptance-architecture.md` — אם תוצאה = קבלה)
- האם הסיבה לקבלה ברורה: פגם פנימי / החזרה / תיקונים / 8xxx מהותית / שומה? - האם הסיבה לקבלה ברורה: פגם פנימי / החזרה / תיקונים / 8xxx מהותית / שומה?

View File

@@ -19,6 +19,7 @@ tools:
- mcp__legal-ai__precedent_attach - mcp__legal-ai__precedent_attach
- mcp__legal-ai__precedent_list - mcp__legal-ai__precedent_list
- mcp__legal-ai__precedent_search_library - mcp__legal-ai__precedent_search_library
- mcp__legal-ai__search_precedent_library
- mcp__legal-ai__workflow_status - mcp__legal-ai__workflow_status
--- ---
@@ -85,7 +86,13 @@ tools:
2. **תקדמים אישיים**: `search_decisions` בקטגוריה זהה לתיק. אם דפנה כבר הכריעה בסוגיה דומה: 2. **תקדמים אישיים**: `search_decisions` בקטגוריה זהה לתיק. אם דפנה כבר הכריעה בסוגיה דומה:
- אם תוצאה דומה: תקדם לחיסכון דוקטרינרי ("כפי שקבענו ב-X") - אם תוצאה דומה: תקדם לחיסכון דוקטרינרי ("כפי שקבענו ב-X")
- אם תוצאה הפוכה: ציין כי **חובה** הבחנה (distinguishing) - אם תוצאה הפוכה: ציין כי **חובה** הבחנה (distinguishing)
3. **דווח** איזה תקדמים מהקאנון רלוונטיים, ואיזה תקדמים אישיים נמצאו 3. **קורפוס פסיקה סמכותית**: `search_precedent_library` — חיפוש סמנטי בהלכות שאושרו ע"י דפנה (פסיקת עליון/מנהלי/ועדות ערר אחרות). מחזיר rule_statement + supporting_quote + citation מוכנים לציטוט בבלוק י. אם הצדדים הפנו לפסק דין שלא בקורפוס — הוסף אותו דרך `precedent_attach` (לתיק) או דרך ממשק ההעלאה ב-`/precedents` (לקורפוס הקבוע).
4. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
**שלושת המקורות — אל תבלבל:**
- `search_decisions` = החלטות דפנה (style_corpus).
- `search_precedent_library` = פסיקה חיצונית סמכותית עם הלכות מאושרות.
- `precedent_search_library` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
### שלב 3: מיפוי תכנית ### שלב 3: מיפוי תכנית
1. קרא הוראות התכנית **במלואן** — לא רק את הסעיף הנטען 1. קרא הוראות התכנית **במלואן** — לא רק את הסעיף הנטען

View File

@@ -19,6 +19,7 @@ tools:
- mcp__legal-ai__save_block_content - mcp__legal-ai__save_block_content
- mcp__legal-ai__write_block - mcp__legal-ai__write_block
- mcp__legal-ai__search_decisions - mcp__legal-ai__search_decisions
- mcp__legal-ai__search_precedent_library
- mcp__legal-ai__search_case_documents - mcp__legal-ai__search_case_documents
- mcp__legal-ai__get_style_guide - mcp__legal-ai__get_style_guide
- mcp__legal-ai__workflow_status - mcp__legal-ai__workflow_status
@@ -341,6 +342,20 @@ curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
זה לא קישוט. דפנה בונה ג'וריספרודנציה אישית מתמשכת. ראה דוגמה ב-1194-25 פס' 61, 64, 97, 98, 99 — חמש הפניות ל-1130-25. זה לא קישוט. דפנה בונה ג'וריספרודנציה אישית מתמשכת. ראה דוגמה ב-1194-25 פס' 61, 64, 97, 98, 99 — חמש הפניות ל-1130-25.
### חיפוש פסיקה סמכותית חיצונית (חובה)
אחרי `search_decisions`, חפש גם ב-**`search_precedent_library`** — הקורפוס של פסיקת ערכאות עליונות וועדות ערר אחרות, עם הלכות שדפנה אישרה. זה המקור היחיד לציטוטי פסיקה בבלוק י לפי CREAC:
- **rule (כלל)** — נסח את הכלל המחייב מתוך `rule_statement`. אל תמציא ניסוח חדש; השתמש בניסוח שאושר.
- **explanation (הרחבה)** — צטט את `supporting_quote` במלואו, מילה במילה. כל ציטוט חייב לכלול `case_number` + `court` + מראה מקום (`page_reference` כשיש).
**הבחנה בין כלים:**
- `search_decisions` = החלטות דפנה עצמה (סגנון, אסטרטגיה, ג'וריספרודנציה אישית).
- `search_precedent_library` = פסיקה חיצונית סמכותית (מחייבת או משכנעת — בית המשפט העליון, מנהלי, ועדות ערר אחרות).
- `precedent_search_library` (שונה!) = ציטוטים שדפנה צירפה ידנית לתיקים בעבר. לא לבלבל.
חפש לפי `practice_area` (rishuy_uvniya / betterment_levy / compensation_197) ולפי `subject_tag` רלוונטי. הלכות שלא אושרו ע"י דפנה לא מוחזרות מהכלי — אם החיפוש ריק, חזור ל-`search_decisions` בלבד.
### אנטי-דפוסים — בדיקה אחרי כתיבה (חובה) ### אנטי-דפוסים — בדיקה אחרי כתיבה (חובה)
- [ ] **אין רשימות ממוספרות בתוך פסקה** (`(1)... (2)... (3)...`) — דפנה מעולם לא משתמשת - [ ] **אין רשימות ממוספרות בתוך פסקה** (`(1)... (2)... (3)...`) — דפנה מעולם לא משתמשת

View File

@@ -2,7 +2,7 @@
"master": { "master": {
"tasks": [ "tasks": [
{ {
"id": "32", "id": 32,
"title": "הקמת סביבת פיתוח ותשתית בסיסית", "title": "הקמת סביבת פיתוח ותשתית בסיסית",
"description": "הקמת סביבת הפיתוח הבסיסית עם Python, FastAPI, PostgreSQL ו-Infisical לניהול סודות", "description": "הקמת סביבת הפיתוח הבסיסית עם Python, FastAPI, PostgreSQL ו-Infisical לניהול סודות",
"details": "יצירת פרויקט Python עם FastAPI כשרת API, PostgreSQL כמסד נתונים, ו-Infisical לניהול סודות. הגדרת Docker containers לפיתוח מקומי. יצירת מבנה תיקיות: /src, /tests, /docs, /data. הגדרת requirements.txt עם כל התלויות הנדרשות: fastapi, uvicorn, sqlalchemy, psycopg2, python-multipart, python-docx, PyPDF2, anthropic, infisical-python. הגדרת משתני סביבה דרך Infisical.", "details": "יצירת פרויקט Python עם FastAPI כשרת API, PostgreSQL כמסד נתונים, ו-Infisical לניהול סודות. הגדרת Docker containers לפיתוח מקומי. יצירת מבנה תיקיות: /src, /tests, /docs, /data. הגדרת requirements.txt עם כל התלויות הנדרשות: fastapi, uvicorn, sqlalchemy, psycopg2, python-multipart, python-docx, PyPDF2, anthropic, infisical-python. הגדרת משתני סביבה דרך Infisical.",
@@ -14,7 +14,7 @@
"updatedAt": "2026-04-03T08:53:33.842Z" "updatedAt": "2026-04-03T08:53:33.842Z"
}, },
{ {
"id": "33", "id": 33,
"title": "מודול קליטה ועיבוד מסמכים", "title": "מודול קליטה ועיבוד מסמכים",
"description": "פיתוח מודול לקליטת קבצי PDF, DOCX, MD וחילוץ טקסט כולל OCR", "description": "פיתוח מודול לקליטת קבצי PDF, DOCX, MD וחילוץ טקסט כולל OCR",
"details": "יצירת מחלקה DocumentProcessor שמטפלת בקבצים מסוגים שונים. עבור PDF: שימוש ב-PyPDF2 לטקסט רגיל ו-pytesseract לOCR של קבצים סרוקים. עבור DOCX: שימוש ב-python-docx. עבור MD: קריאה ישירה. הוספת זיהוי אוטומטי של קבצים סרוקים. יצירת API endpoint POST /documents/upload שמקבל קבצים ומחזיר טקסט מחולץ. שמירת מטא-דאטה של כל מסמך במסד הנתונים.", "details": "יצירת מחלקה DocumentProcessor שמטפלת בקבצים מסוגים שונים. עבור PDF: שימוש ב-PyPDF2 לטקסט רגיל ו-pytesseract לOCR של קבצים סרוקים. עבור DOCX: שימוש ב-python-docx. עבור MD: קריאה ישירה. הוספת זיהוי אוטומטי של קבצים סרוקים. יצירת API endpoint POST /documents/upload שמקבל קבצים ומחזיר טקסט מחולץ. שמירת מטא-דאטה של כל מסמך במסד הנתונים.",
@@ -28,7 +28,7 @@
"updatedAt": "2026-04-03T09:38:55.716Z" "updatedAt": "2026-04-03T09:38:55.716Z"
}, },
{ {
"id": "34", "id": 34,
"title": "מודול סיווג מסמכים וזיהוי צדדים", "title": "מודול סיווג מסמכים וזיהוי צדדים",
"description": "פיתוח מודול לסיווג מסמכים לסוגים (ערר, תשובה, פרוטוקול וכו') וזיהוי צדדים", "description": "פיתוח מודול לסיווג מסמכים לסוגים (ערר, תשובה, פרוטוקול וכו') וזיהוי צדדים",
"details": "יצירת מחלקה DocumentClassifier שמשתמשת ב-Claude API לסיווג מסמכים. הגדרת prompt מובנה שמזהה: סוג מסמך (ערר/תשובה/תגובה/פרוטוקול/תכנית/היתר/פסק דין/החלטה), צדדים (עוררים, משיבים, ועדה, מבקשי היתר), סוג ערר לפי מספר תיק (1xxx=רישוי, 8xxx=השבחה, 9xxx=פיצויים). יצירת מבנה נתונים מובנה לשמירת המידע המסווג. הוספת ולידציה לתוצאות הסיווג.", "details": "יצירת מחלקה DocumentClassifier שמשתמשת ב-Claude API לסיווג מסמכים. הגדרת prompt מובנה שמזהה: סוג מסמך (ערר/תשובה/תגובה/פרוטוקול/תכנית/היתר/פסק דין/החלטה), צדדים (עוררים, משיבים, ועדה, מבקשי היתר), סוג ערר לפי מספר תיק (1xxx=רישוי, 8xxx=השבחה, 9xxx=פיצויים). יצירת מבנה נתונים מובנה לשמירת המידע המסווג. הוספת ולידציה לתוצאות הסיווג.",
@@ -42,7 +42,7 @@
"updatedAt": "2026-04-03T09:43:02.411Z" "updatedAt": "2026-04-03T09:43:02.411Z"
}, },
{ {
"id": "35", "id": 35,
"title": "מודול חילוץ טענות", "title": "מודול חילוץ טענות",
"description": "פיתוח מודול לחילוץ וסיכום טענות מכתבי טענות לפי צד", "description": "פיתוח מודול לחילוץ וסיכום טענות מכתבי טענות לפי צד",
"details": "יצירת מחלקה ClaimsExtractor שמחלצת טענות מכתבי ערר ותשובה. שימוש ב-Claude API עם prompt מיוחד שמזהה טענות לפי צד ומסכם אותן בצורה נאמנה למקור. יצירת מבנה נתונים שמקשר בין טענה למסמך המקור ולמיקום בו. הוספת מנגנון לזיהוי טענות חוזרות או דומות. שמירת הטענות במסד הנתונים עם קישור לתיק ולצד.", "details": "יצירת מחלקה ClaimsExtractor שמחלצת טענות מכתבי ערר ותשובה. שימוש ב-Claude API עם prompt מיוחד שמזהה טענות לפי צד ומסכם אותן בצורה נאמנה למקור. יצירת מבנה נתונים שמקשר בין טענה למסמך המקור ולמיקום בו. הוספת מנגנון לזיהוי טענות חוזרות או דומות. שמירת הטענות במסד הנתונים עם קישור לתיק ולצד.",
@@ -56,7 +56,7 @@
"updatedAt": "2026-04-03T09:45:38.799Z" "updatedAt": "2026-04-03T09:45:38.799Z"
}, },
{ {
"id": "36", "id": 36,
"title": "מודול זיהוי תכניות ופסיקה", "title": "מודול זיהוי תכניות ופסיקה",
"description": "פיתוח מודול לזיהוי תכניות חלות על המקרקעין ופסיקה מצוטטת במסמכים", "description": "פיתוח מודול לזיהוי תכניות חלות על המקרקעין ופסיקה מצוטטת במסמכים",
"details": "יצירת מחלקה LegalReferencesExtractor שמזהה: תכניות (תב\"ע, תמ\"א, תכניות מקומיות), פסיקה מצוטטת (עם מספרי תיק ושנה), חקיקה רלוונטית. שימוש ב-regex patterns לזיהוי דפוסים נפוצים ו-Claude API לאימות ועידון. יצירת מאגר מקומי של תכניות ופסיקה שכבר זוהו. הוספת מנגנון לולידציה של הפניות שזוהו.", "details": "יצירת מחלקה LegalReferencesExtractor שמזהה: תכניות (תב\"ע, תמ\"א, תכניות מקומיות), פסיקה מצוטטת (עם מספרי תיק ושנה), חקיקה רלוונטית. שימוש ב-regex patterns לזיהוי דפוסים נפוצים ו-Claude API לאימות ועידון. יצירת מאגר מקומי של תכניות ופסיקה שכבר זוהו. הוספת מנגנון לולידציה של הפניות שזוהו.",
@@ -70,7 +70,7 @@
"updatedAt": "2026-04-03T09:48:16.636Z" "updatedAt": "2026-04-03T09:48:16.636Z"
}, },
{ {
"id": "37", "id": 37,
"title": "ממשק הזנת תוצאה וסיעור מוחות", "title": "ממשק הזנת תוצאה וסיעור מוחות",
"description": "פיתוח ממשק CLI להזנת תוצאה (דחייה/קבלה/חלקית) ומנגנון סיעור מוחות", "description": "פיתוח ממשק CLI להזנת תוצאה (דחייה/קבלה/חלקית) ומנגנון סיעור מוחות",
"details": "יצירת CLI interface עם typer שמאפשר לחיים להזין: סוג תוצאה (דחייה/קבלה/קבלה חלקית), נימוק (אופציונלי). אם לא הוזן נימוק - הפעלת מודול BrainstormingEngine שמציג טענות מרכזיות ומציע 2-3 כיוונים אפשריים. יצירת שיח אינטראקטיבי בין חיים למערכת עד הגעה לכיוון מוסכם. שמירת מסמך הכיוון הסופי. הוספת מנגנון מניעה מכתיבת דיון ללא כיוון מאושר.", "details": "יצירת CLI interface עם typer שמאפשר לחיים להזין: סוג תוצאה (דחייה/קבלה/קבלה חלקית), נימוק (אופציונלי). אם לא הוזן נימוק - הפעלת מודול BrainstormingEngine שמציג טענות מרכזיות ומציע 2-3 כיוונים אפשריים. יצירת שיח אינטראקטיבי בין חיים למערכת עד הגעה לכיוון מוסכם. שמירת מסמך הכיוון הסופי. הוספת מנגנון מניעה מכתיבת דיון ללא כיוון מאושר.",
@@ -85,7 +85,7 @@
"updatedAt": "2026-04-03T09:55:06.069Z" "updatedAt": "2026-04-03T09:55:06.069Z"
}, },
{ {
"id": "38", "id": 38,
"title": "מנוע כתיבת בלוק הפתיחה (בלוק ה)", "title": "מנוע כתיבת בלוק הפתיחה (בלוק ה)",
"description": "פיתוח מנוע לכתיבת בלוק הפתיחה בסגנון דפנה", "description": "פיתוח מנוע לכתיבת בלוק הפתיחה בסגנון דפנה",
"details": "יצירת מחלקה OpeningBlockWriter שכותבת את בלוק הפתיחה. ניתוח דפוסי הפתיחה מ-7 ההחלטות הקיימות (\"לפנינו\" vs \"עניינה של החלטה זו\"). יצירת prompt מובנה שמתאים את הפתיחה לסוג הערר ולמורכבות התיק. הוספת מנגנון לבחירת נוסח הפתיחה המתאים. שמירת תבניות פתיחה במסד הנתונים.", "details": "יצירת מחלקה OpeningBlockWriter שכותבת את בלוק הפתיחה. ניתוח דפוסי הפתיחה מ-7 ההחלטות הקיימות (\"לפנינו\" vs \"עניינה של החלטה זו\"). יצירת prompt מובנה שמתאים את הפתיחה לסוג הערר ולמורכבות התיק. הוספת מנגנון לבחירת נוסח הפתיחה המתאים. שמירת תבניות פתיחה במסד הנתונים.",
@@ -99,7 +99,7 @@
"updatedAt": "2026-04-03T09:58:34.296Z" "updatedAt": "2026-04-03T09:58:34.296Z"
}, },
{ {
"id": "39", "id": 39,
"title": "מנוע כתיבת בלוק הרקע (בלוק ו)", "title": "מנוע כתיבת בלוק הרקע (בלוק ו)",
"description": "פיתוח מנוע לכתיבת בלוק הרקע בצורה ניטרלית", "description": "פיתוח מנוע לכתיבת בלוק הרקע בצורה ניטרלית",
"details": "יצירת מחלקה BackgroundBlockWriter שכותבת רקע ניטרלי. הגדרת כללי ניטרליות: אין ציטוטים מצדדים, אין מילות שיפוט, הצגת עובדות בלבד. יצירת רשימת מילים אסורות ומנגנון ולידציה. שימוש במידע מהמסמכים המסווגים לבניית הרקע. הוספת מנגנון לקביעת אורך הרקע לפי מורכבות התיק (3%-18% מההחלטה).", "details": "יצירת מחלקה BackgroundBlockWriter שכותבת רקע ניטרלי. הגדרת כללי ניטרליות: אין ציטוטים מצדדים, אין מילות שיפוט, הצגת עובדות בלבד. יצירת רשימת מילים אסורות ומנגנון ולידציה. שימוש במידע מהמסמכים המסווגים לבניית הרקע. הוספת מנגנון לקביעת אורך הרקע לפי מורכבות התיק (3%-18% מההחלטה).",
@@ -113,7 +113,7 @@
"updatedAt": "2026-04-03T09:58:34.300Z" "updatedAt": "2026-04-03T09:58:34.300Z"
}, },
{ {
"id": "40", "id": 40,
"title": "מנוע כתיבת בלוק הטענות (בלוק ז)", "title": "מנוע כתיבת בלוק הטענות (בלוק ז)",
"description": "פיתוח מנוע לכתיבת סיכום טענות הצדדים בגוף שלישי", "description": "פיתוח מנוע לכתיבת סיכום טענות הצדדים בגוף שלישי",
"details": "יצירת מחלקה ClaimsBlockWriter שמסכמת טענות בגוף שלישי. שימוש בטענות שחולצו במודול חילוץ הטענות. הבטחת נאמנות מוחלטת למקור - אין שינוי מילים או קיצור ללא ציון. יצירת מבנה לוגי של הצגת הטענות לפי צד. הוספת מנגנון לקישור כל טענה למקור המדויק במסמך.", "details": "יצירת מחלקה ClaimsBlockWriter שמסכמת טענות בגוף שלישי. שימוש בטענות שחולצו במודול חילוץ הטענות. הבטחת נאמנות מוחלטת למקור - אין שינוי מילים או קיצור ללא ציון. יצירת מבנה לוגי של הצגת הטענות לפי צד. הוספת מנגנון לקישור כל טענה למקור המדויק במסמך.",
@@ -127,7 +127,7 @@
"updatedAt": "2026-04-03T09:58:34.303Z" "updatedAt": "2026-04-03T09:58:34.303Z"
}, },
{ {
"id": "41", "id": 41,
"title": "מנוע כתיבת בלוק ההליכים (בלוק ח)", "title": "מנוע כתיבת בלוק ההליכים (בלוק ח)",
"description": "פיתוח מנוע לכתיבת בלוק ההליכים (רק כשהיו הליכים מעבר לדיון פשוט)", "description": "פיתוח מנוע לכתיבת בלוק ההליכים (רק כשהיו הליכים מעבר לדיון פשוט)",
"details": "יצירת מחלקה ProceduresBlockWriter שכותבת תיעוד כרונולוגי של הליכים. זיהוי אוטומטי מתי נדרש הבלוק (סיור, השלמות טיעון, החלטות ביניים). יצירת ציר זמן של האירועים מהמסמכים. הבטחת דיוק עובדתי ומבנה כרונולוגי. הוספת מנגנון להחלטה אוטומטית האם הבלוק נדרש.", "details": "יצירת מחלקה ProceduresBlockWriter שכותבת תיעוד כרונולוגי של הליכים. זיהוי אוטומטי מתי נדרש הבלוק (סיור, השלמות טיעון, החלטות ביניים). יצירת ציר זמן של האירועים מהמסמכים. הבטחת דיוק עובדתי ומבנה כרונולוגי. הוספת מנגנון להחלטה אוטומטית האם הבלוק נדרש.",
@@ -141,7 +141,7 @@
"updatedAt": "2026-04-03T09:58:34.305Z" "updatedAt": "2026-04-03T09:58:34.305Z"
}, },
{ {
"id": "42", "id": 42,
"title": "מנוע כתיבת בלוק התכניות (בלוק ט)", "title": "מנוע כתיבת בלוק התכניות (בלוק ט)",
"description": "פיתוח מנוע לכתיבת בלוק התכניות והמסגרת הנורמטיבית", "description": "פיתוח מנוע לכתיבת בלוק התכניות והמסגרת הנורמטיבית",
"details": "יצירת מחלקה PlansBlockWriter שמטפלת ברישום תכניות. הגדרת כללי החלטה מתי נדרש פרק נפרד (מורכבות תכנונית, שאלה משפטית כמו ס' 152). שימוש במידע התכניות שזוהו במודול זיהוי התכניות. יצירת מבנה הירכי של התכניות (ארציות, מחוזיות, מקומיות). הוספת מנגנון לקביעת עומק הפירוט הנדרש.", "details": "יצירת מחלקה PlansBlockWriter שמטפלת ברישום תכניות. הגדרת כללי החלטה מתי נדרש פרק נפרד (מורכבות תכנונית, שאלה משפטית כמו ס' 152). שימוש במידע התכניות שזוהו במודול זיהוי התכניות. יצירת מבנה הירכי של התכניות (ארציות, מחוזיות, מקומיות). הוספת מנגנון לקביעת עומק הפירוט הנדרש.",
@@ -155,7 +155,7 @@
"updatedAt": "2026-04-03T09:58:34.308Z" "updatedAt": "2026-04-03T09:58:34.308Z"
}, },
{ {
"id": "43", "id": 43,
"title": "מנוע כתיבת בלוק הדיון (בלוק י) - ליבת המערכת", "title": "מנוע כתיבת בלוק הדיון (בלוק י) - ליבת המערכת",
"description": "פיתוח מנוע הכתיבה המרכזי לבלוק הדיון בשיטת CREAC", "description": "פיתוח מנוע הכתיבה המרכזי לבלוק הדיון בשיטת CREAC",
"details": "יצירת מחלקה DiscussionBlockWriter - הליבה של המערכת. יישום שיטת CREAC: מסקנה בפתיחה, כלל משפטי, הסבר, יישום על המקרה, מסקנה. הבטחת מענה לכל טענה מבלוק ז. שימוש בכיוון שנקבע בשלב סיעור המוחות. הוספת מנגנון למניעת כפילויות והפניות לבלוקים קודמים. יצירת מבנה לוגי של הנימוקים לפי סדר חשיבות.", "details": "יצירת מחלקה DiscussionBlockWriter - הליבה של המערכת. יישום שיטת CREAC: מסקנה בפתיחה, כלל משפטי, הסבר, יישום על המקרה, מסקנה. הבטחת מענה לכל טענה מבלוק ז. שימוש בכיוון שנקבע בשלב סיעור המוחות. הוספת מנגנון למניעת כפילויות והפניות לבלוקים קודמים. יצירת מבנה לוגי של הנימוקים לפי סדר חשיבות.",
@@ -169,7 +169,7 @@
"updatedAt": "2026-04-03T09:58:34.311Z" "updatedAt": "2026-04-03T09:58:34.311Z"
}, },
{ {
"id": "44", "id": 44,
"title": "מנוע כתיבת בלוק הסיכום (בלוק יא)", "title": "מנוע כתיבת בלוק הסיכום (בלוק יא)",
"description": "פיתוח מנוע לכתיבת בלוק הסיכום עם הוראות אופרטיביות", "description": "פיתוח מנוע לכתיבת בלוק הסיכום עם הוראות אופרטיביות",
"details": "יצירת מחלקה SummaryBlockWriter שכותבת הוראות אופרטיביות. גזירת ההוראות מהדיון שנכתב בבלוק י. הבטחת התאמה מדויקת להכרעה שנקבעה. יצירת מבנה ברור של ההוראות (מה מתקבל, מה נדחה, מה התנאים). הוספת מנגנון לולידציה של עקביות בין הדיון לסיכום.", "details": "יצירת מחלקה SummaryBlockWriter שכותבת הוראות אופרטיביות. גזירת ההוראות מהדיון שנכתב בבלוק י. הבטחת התאמה מדויקת להכרעה שנקבעה. יצירת מבנה ברור של ההוראות (מה מתקבל, מה נדחה, מה התנאים). הוספת מנגנון לולידציה של עקביות בין הדיון לסיכום.",
@@ -183,7 +183,7 @@
"updatedAt": "2026-04-03T09:58:34.313Z" "updatedAt": "2026-04-03T09:58:34.313Z"
}, },
{ {
"id": "45", "id": 45,
"title": "מנוע ייצוא DOCX מעוצב", "title": "מנוע ייצוא DOCX מעוצב",
"description": "פיתוח מנוע לייצוא ההחלטה לקובץ DOCX מעוצב בעברית RTL", "description": "פיתוח מנוע לייצוא ההחלטה לקובץ DOCX מעוצב בעברית RTL",
"details": "יצירת מחלקה DocxExporter שמייצרת DOCX מעוצב. הגדרת גופן David, כיוון RTL, כותרות מעוצבות, מספור סעיפים רציף. יצירת תבנית DOCX בסיסית עם הגדרות העיצוב. הוספת מנגנון לסימון מקומות תמונה (GIS, תשריט, סיור). הבטחת תמיכה מלאה בעברית ובכיוון RTL. יצירת מבנה היררכי של כותרות וסעיפים.", "details": "יצירת מחלקה DocxExporter שמייצרת DOCX מעוצב. הגדרת גופן David, כיוון RTL, כותרות מעוצבות, מספור סעיפים רציף. יצירת תבנית DOCX בסיסית עם הגדרות העיצוב. הוספת מנגנון לסימון מקומות תמונה (GIS, תשריט, סיור). הבטחת תמיכה מלאה בעברית ובכיוון RTL. יצירת מבנה היררכי של כותרות וסעיפים.",
@@ -197,7 +197,7 @@
"updatedAt": "2026-04-03T10:12:36.842Z" "updatedAt": "2026-04-03T10:12:36.842Z"
}, },
{ {
"id": "46", "id": 46,
"title": "מנגנון בקרת איכות ווולידציה", "title": "מנגנון בקרת איכות ווולידציה",
"description": "פיתוח מנגנון בקרת איכות לוולידציה של ההחלטה לפני הפלט", "description": "פיתוח מנגנון בקרת איכות לוולידציה של ההחלטה לפני הפלט",
"details": "יצירת מחלקה QualityController שבודקת: אפס הזיות (כל הפניה מול מסמכים שסופקו), מענה לכל טענה, רקע ניטרלי (ללא מילות שיפוט), משקלות בלוקים בטווח יחסי הזהב ±10%, ציטוטים נאמנים למקור. יצירת דוח ולידציה מפורט. הוספת מנגנון למניעת פלט במקרה של כשלון ולידציה קריטי.", "details": "יצירת מחלקה QualityController שבודקת: אפס הזיות (כל הפניה מול מסמכים שסופקו), מענה לכל טענה, רקע ניטרלי (ללא מילות שיפוט), משקלות בלוקים בטווח יחסי הזהב ±10%, ציטוטים נאמנים למקור. יצירת דוח ולידציה מפורט. הוספת מנגנון למניעת פלט במקרה של כשלון ולידציה קריטי.",
@@ -211,7 +211,7 @@
"updatedAt": "2026-04-03T10:14:00.311Z" "updatedAt": "2026-04-03T10:14:00.311Z"
}, },
{ {
"id": "47", "id": 47,
"title": "מודול לולאת למידה", "title": "מודול לולאת למידה",
"description": "פיתוח מודול לקליטת גרסה סופית והשוואה לטיוטה ללמידה", "description": "פיתוח מודול לקליטת גרסה סופית והשוואה לטיוטה ללמידה",
"details": "יצירת מחלקה LearningLoop שמקבלת את הגרסה הסופית שדפנה חתמה. השוואת הטיוטה לגרסה הסופית וזיהוי הבדלים. חילוץ לקחים: ביטויים חדשים, דפוסים שהשתנו, שגיאות חוזרות. עדכון מודל הסגנון על בסיס הלקחים. יצירת דוח למידה לחיים. שמירת הלקחים במסד הנתונים לשיפור עתידי.", "details": "יצירת מחלקה LearningLoop שמקבלת את הגרסה הסופית שדפנה חתמה. השוואת הטיוטה לגרסה הסופית וזיהוי הבדלים. חילוץ לקחים: ביטויים חדשים, דפוסים שהשתנו, שגיאות חוזרות. עדכון מודל הסגנון על בסיס הלקחים. יצירת דוח למידה לחיים. שמירת הלקחים במסד הנתונים לשיפור עתידי.",
@@ -225,7 +225,7 @@
"updatedAt": "2026-04-03T10:15:14.639Z" "updatedAt": "2026-04-03T10:15:14.639Z"
}, },
{ {
"id": "48", "id": 48,
"title": "מודול מדדי הצלחה ודשבורד", "title": "מודול מדדי הצלחה ודשבורד",
"description": "פיתוח מודול למדידת KPIs ויצירת דשבורד מעקב", "description": "פיתוח מודול למדידת KPIs ויצירת דשבורד מעקב",
"details": "יצירת מחלקה MetricsTracker שמודדת: אחוז שינוי (השוואת טיוטה לגרסה סופית), זמן לטיוטה (מקצה לקצה), אפס הזיות (ספירת הפניות לא תקינות), מענה לכל טענה, משקלות בלוקים, רקע ניטרלי. יצירת דשבורד פשוט עם הצגת המדדים לאורך זמן. הוספת התראות כשמדד יורד מתחת לסף המינימום.", "details": "יצירת מחלקה MetricsTracker שמודדת: אחוז שינוי (השוואת טיוטה לגרסה סופית), זמן לטיוטה (מקצה לקצה), אפס הזיות (ספירת הפניות לא תקינות), מענה לכל טענה, משקלות בלוקים, רקע ניטרלי. יצירת דשבורד פשוט עם הצגת המדדים לאורך זמן. הוספת התראות כשמדד יורד מתחת לסף המינימום.",
@@ -239,7 +239,7 @@
"updatedAt": "2026-04-03T10:16:10.708Z" "updatedAt": "2026-04-03T10:16:10.708Z"
}, },
{ {
"id": "49", "id": 49,
"title": "מנגנון ניהול סודות ואבטחה", "title": "מנגנון ניהול סודות ואבטחה",
"description": "יישום מנגנון אבטחה מלא עם Infisical וניהול סודות", "description": "יישום מנגנון אבטחה מלא עם Infisical וניהול סודות",
"details": "הגדרת Infisical לניהול כל הסודות: Anthropic API key, מחרוזות חיבור למסד נתונים, מפתחות הצפנה. יצירת מנגנון הצפנה לחומרי התיקים במסד הנתונים. הגדרת מדיניות גישה והרשאות. יצירת מנגנון audit log לכל הפעולות. הבטחת שחומרי התיקים לא נשלחים לשירותים חיצוניים מלבד Anthropic API.", "details": "הגדרת Infisical לניהול כל הסודות: Anthropic API key, מחרוזות חיבור למסד נתונים, מפתחות הצפנה. יצירת מנגנון הצפנה לחומרי התיקים במסד הנתונים. הגדרת מדיניות גישה והרשאות. יצירת מנגנון audit log לכל הפעולות. הבטחת שחומרי התיקים לא נשלחים לשירותים חיצוניים מלבד Anthropic API.",
@@ -253,7 +253,7 @@
"updatedAt": "2026-04-03T10:17:43.954Z" "updatedAt": "2026-04-03T10:17:43.954Z"
}, },
{ {
"id": "50", "id": 50,
"title": "מנגנון גיבוי ושחזור", "title": "מנגנון גיבוי ושחזור",
"description": "יישום מנגנון גיבוי יומי אוטומטי ושחזור מסד הנתונים", "description": "יישום מנגנון גיבוי יומי אוטומטי ושחזור מסד הנתונים",
"details": "יצירת סקריפט גיבוי יומי אוטומטי למסד הנתונים PostgreSQL. הגדרת cron job לביצוע הגיבוי בשעות הלילה. יצירת מנגנון שחזור מגיבוי. שמירת הגיבויים במיקום מאובטח. הוספת מנגנון לבדיקת תקינות הגיבויים. יצירת תיעוד לתהליכי גיבוי ושחזור.", "details": "יצירת סקריפט גיבוי יומי אוטומטי למסד הנתונים PostgreSQL. הגדרת cron job לביצוע הגיבוי בשעות הלילה. יצירת מנגנון שחזור מגיבוי. שמירת הגיבויים במיקום מאובטח. הוספת מנגנון לבדיקת תקינות הגיבויים. יצירת תיעוד לתהליכי גיבוי ושחזור.",
@@ -267,7 +267,7 @@
"updatedAt": "2026-04-03T10:18:18.247Z" "updatedAt": "2026-04-03T10:18:18.247Z"
}, },
{ {
"id": "51", "id": 51,
"title": "ממשק CLI מלא ותיעוד", "title": "ממשק CLI מלא ותיעוד",
"description": "פיתוח ממשק CLI מלא עם כל הפקודות הנדרשות ותיעוד מקיף", "description": "פיתוח ממשק CLI מלא עם כל הפקודות הנדרשות ותיעוד מקיף",
"details": "יצירת CLI מקיף עם typer שכולל: העלאת מסמכים, הזנת תוצאה, סיעור מוחות, יצירת טיוטה, הזנת גרסה סופית, הצגת מדדים. הוספת help מפורט לכל פקודה. יצירת תיעוד מקיף למשתמש עם דוגמאות שימוש. הוספת מנגנון לולידציה של קלטים. יצירת מנגנון לטיפול בשגיאות ומסרי שגיאה ברורים בעברית.", "details": "יצירת CLI מקיף עם typer שכולל: העלאת מסמכים, הזנת תוצאה, סיעור מוחות, יצירת טיוטה, הזנת גרסה סופית, הצגת מדדים. הוספת help מפורט לכל פקודה. יצירת תיעוד מקיף למשתמש עם דוגמאות שימוש. הוספת מנגנון לולידציה של קלטים. יצירת מנגנון לטיפול בשגיאות ומסרי שגיאה ברורים בעברית.",
@@ -282,7 +282,7 @@
"updatedAt": "2026-04-03T10:19:20.241Z" "updatedAt": "2026-04-03T10:19:20.241Z"
}, },
{ {
"id": "52", "id": 52,
"title": "בדיקות אינטגרציה ומבחן הסמכה", "title": "בדיקות אינטגרציה ומבחן הסמכה",
"description": "יצירת חבילת בדיקות מקיפה ומבחן הסמכה על תיק אמיתי", "description": "יצירת חבילת בדיקות מקיפה ומבחן הסמכה על תיק אמיתי",
"details": "יצירת בדיקות אינטגרציה לכל התהליך מקצה לקצה. בדיקה עם תיק הכט (תיק שכבר יש לו החלטה סופית) - השוואת הטיוטה שהמערכת מייצרת להחלטה הסופית. מדידת פער ווידוא שהוא קטן מ-10%. יצירת מבחן הסמכה מובנה לפני שימוש מבצעי. הוספת בדיקות ביצועים - וידוא שהמערכת מייצרת טיוטה תוך יום עבודה.", "details": "יצירת בדיקות אינטגרציה לכל התהליך מקצה לקצה. בדיקה עם תיק הכט (תיק שכבר יש לו החלטה סופית) - השוואת הטיוטה שהמערכת מייצרת להחלטה הסופית. מדידת פער ווידוא שהוא קטן מ-10%. יצירת מבחן הסמכה מובנה לפני שימוש מבצעי. הוספת בדיקות ביצועים - וידוא שהמערכת מייצרת טיוטה תוך יום עבודה.",
@@ -296,7 +296,7 @@
"updatedAt": "2026-04-04T07:50:59.998Z" "updatedAt": "2026-04-04T07:50:59.998Z"
}, },
{ {
"id": "53", "id": 53,
"title": "הוספת שלב 6 - הגהת דפנה לדרישות הפונקציונליות", "title": "הוספת שלב 6 - הגהת דפנה לדרישות הפונקציונליות",
"description": "הגדרת שלב הגהת דפנה החסר מהדרישות הפונקציונליות, כולל זרימת העבודה והממשקים", "description": "הגדרת שלב הגהת דפנה החסר מהדרישות הפונקציונליות, כולל זרימת העבודה והממשקים",
"details": "יש להגדיר בדרישות הפונקציונליות: (1) איך דפנה מקבלת את הטיוטה בפורמט DOCX, (2) איך מחזירה הערות ותיקונים (ממשק או פורמט מובנה), (3) מי מעלה את הגרסה הסופית ללולאת הלמידה. כולל הגדרת API endpoints לקבלת הטיוטה ולהחזרת הערות, ומנגנון עדכון המודל על בסיס הפידבק.", "details": "יש להגדיר בדרישות הפונקציונליות: (1) איך דפנה מקבלת את הטיוטה בפורמט DOCX, (2) איך מחזירה הערות ותיקונים (ממשק או פורמט מובנה), (3) מי מעלה את הגרסה הסופית ללולאת הלמידה. כולל הגדרת API endpoints לקבלת הטיוטה ולהחזרת הערות, ומנגנון עדכון המודל על בסיס הפידבק.",
@@ -308,7 +308,7 @@
"updatedAt": "2026-04-02T20:58:19.827Z" "updatedAt": "2026-04-02T20:58:19.827Z"
}, },
{ {
"id": "54", "id": 54,
"title": "החלפת דרישת 'אפס הזיות' במנגנון grounding ווולידציה", "title": "החלפת דרישת 'אפס הזיות' במנגנון grounding ווולידציה",
"description": "החלפת הדרישה הלא ריאלית של אפס הזיות במנגנון grounding מתקדם ומערכת וולידציה אוטומטית", "description": "החלפת הדרישה הלא ריאלית של אפס הזיות במנגנון grounding מתקדם ומערכת וולידציה אוטומטית",
"details": "יישום מנגנון grounding שמקשר כל הפניה למסמך מקור ספציפי עם citation tracking. פיתוח מערכת וולידציה אוטומטית שבודקת כל ציטוט/הפניה מול המסמכים שסופקו. הגדרת מדד: שיעור הפניות שלא עוברות וולידציה = 0. כולל מנגנון flagging של הפניות חשודות ודרישה לאישור ידני.", "details": "יישום מנגנון grounding שמקשר כל הפניה למסמך מקור ספציפי עם citation tracking. פיתוח מערכת וולידציה אוטומטית שבודקת כל ציטוט/הפניה מול המסמכים שסופקו. הגדרת מדד: שיעור הפניות שלא עוברות וולידציה = 0. כולל מנגנון flagging של הפניות חשודות ודרישה לאישור ידני.",
@@ -320,7 +320,7 @@
"updatedAt": "2026-04-02T20:58:55.741Z" "updatedAt": "2026-04-02T20:58:55.741Z"
}, },
{ {
"id": "55", "id": 55,
"title": "הוספת ניהול context window overflow", "title": "הוספת ניהול context window overflow",
"description": "פיתוח מנגנון לטיפול בתיקים מורכבים שחורגים מ-context window של המודל", "description": "פיתוח מנגנון לטיפול בתיקים מורכבים שחורגים מ-context window של המודל",
"details": "יישום מדידת גודל חומרים בטוקנים, אסטרטגיית chunking חכמה ו/או summarization של מסמכים ארוכים. הגדרת סף התראה כשמתקרבים לגבול context window. פיתוח אלגוריתם לסדר עדיפויות של מסמכים והחלטה איזה חלקים לכלול בהקשר הנוכחי.", "details": "יישום מדידת גודל חומרים בטוקנים, אסטרטגיית chunking חכמה ו/או summarization של מסמכים ארוכים. הגדרת סף התראה כשמתקרבים לגבול context window. פיתוח אלגוריתם לסדר עדיפויות של מסמכים והחלטה איזה חלקים לכלול בהקשר הנוכחי.",
@@ -332,7 +332,7 @@
"updatedAt": "2026-04-02T20:59:34.704Z" "updatedAt": "2026-04-02T20:59:34.704Z"
}, },
{ {
"id": "56", "id": 56,
"title": "הגדרה מתמטית מדויקת של 'אחוז שינוי'", "title": "הגדרה מתמטית מדויקת של 'אחוז שינוי'",
"description": "הגדרה ברורה ומתמטית של מדד אחוז השינוי עם דוגמאות קונקרטיות", "description": "הגדרה ברורה ומתמטית של מדד אחוז השינוי עם דוגמאות קונקרטיות",
"details": "הגדרת מדד אחוז שינוי מבוסס edit distance על מילים (לא תווים). ספירת שינויים: הוספה, מחיקה, החלפה של מילים. נוסחה: (מספר שינויים / סך מילים בטקסט המקורי) * 100. כולל דוגמאות מפורטות ומקרי קצה כמו שינוי סדר מילים, שינויי פיסוק, וטיפול בסעיפים חדשים.", "details": "הגדרת מדד אחוז שינוי מבוסס edit distance על מילים (לא תווים). ספירת שינויים: הוספה, מחיקה, החלפה של מילים. נוסחה: (מספר שינויים / סך מילים בטקסט המקורי) * 100. כולל דוגמאות מפורטות ומקרי קצה כמו שינוי סדר מילים, שינויי פיסוק, וטיפול בסעיפים חדשים.",
@@ -344,7 +344,7 @@
"updatedAt": "2026-04-02T21:00:03.477Z" "updatedAt": "2026-04-02T21:00:03.477Z"
}, },
{ {
"id": "57", "id": 57,
"title": "הוספת דרישות לבלוקים א-ד ויב", "title": "הוספת דרישות לבלוקים א-ד ויב",
"description": "הגדרת דרישות פונקציונליות לבלוקים החסרים: כותרת, הרכב, צדדים וחתימות", "description": "הגדרת דרישות פונקציונליות לבלוקים החסרים: כותרת, הרכב, צדדים וחתימות",
"details": "הגדרת דרישות מפורטות לבלוק א (כותרת התיק), בלוק ב (הרכב בית הדין), בלוק ג (זיהוי הצדדים), בלוק ד (פרטים נוספים על הצדדים), ובלוק יב (חתימות). כולל פורמט הפלט, מקורות המידע, וכללי עיבוד לכל בלוק. התאמה לתבנית הפסיקה הסטנדרטית.", "details": "הגדרת דרישות מפורטות לבלוק א (כותרת התיק), בלוק ב (הרכב בית הדין), בלוק ג (זיהוי הצדדים), בלוק ד (פרטים נוספים על הצדדים), ובלוק יב (חתימות). כולל פורמט הפלט, מקורות המידע, וכללי עיבוד לכל בלוק. התאמה לתבנית הפסיקה הסטנדרטית.",
@@ -358,7 +358,7 @@
"updatedAt": "2026-04-02T20:58:19.831Z" "updatedAt": "2026-04-02T20:58:19.831Z"
}, },
{ {
"id": "58", "id": 58,
"title": "יישום מנגנון שמירת מצב ביניים (persistence)", "title": "יישום מנגנון שמירת מצב ביניים (persistence)",
"description": "פיתוח מערכת לשמירת מצב העבודה ו-recovery מנפילות מערכת", "description": "פיתוח מערכת לשמירת מצב העבודה ו-recovery מנפילות מערכת",
"details": "יישום מנגנון auto-save שמשמר את מצב העבודה כל כמה דקות. שמירת גרסאות ביניים של כל בלוק, מעקב אחר השלב הנוכחי בתהליך, ומנגנון recovery שמאפשר המשך עבודה מהנקודה האחרונה שנשמרה. כולל ממשק למשתמש לבחירת נקודת שחזור.", "details": "יישום מנגנון auto-save שמשמר את מצב העבודה כל כמה דקות. שמירת גרסאות ביניים של כל בלוק, מעקב אחר השלב הנוכחי בתהליך, ומנגנון recovery שמאפשר המשך עבודה מהנקודה האחרונה שנשמרה. כולל ממשק למשתמש לבחירת נקודת שחזור.",
@@ -370,7 +370,7 @@
"updatedAt": "2026-04-02T21:01:07.799Z" "updatedAt": "2026-04-02T21:01:07.799Z"
}, },
{ {
"id": "59", "id": 59,
"title": "תיקון ספירת שלבים בטבלת מעקב", "title": "תיקון ספירת שלבים בטבלת מעקב",
"description": "עדכון טבלת המעקב להתאמה למספר השלבים בפועל", "description": "עדכון טבלת המעקב להתאמה למספר השלבים בפועל",
"details": "עדכון הטבלה לציון 7 שלבים במקום 6, כולל השלב החדש של הגהת דפנה. עדכון כל הרפרנסים למספר השלבים במסמכי הדרישות והתיעוד. וידוא עקביות בין כל המסמכים.", "details": "עדכון הטבלה לציון 7 שלבים במקום 6, כולל השלב החדש של הגהת דפנה. עדכון כל הרפרנסים למספר השלבים במסמכי הדרישות והתיעוד. וידוא עקביות בין כל המסמכים.",
@@ -384,7 +384,7 @@
"updatedAt": "2026-04-02T21:01:45.876Z" "updatedAt": "2026-04-02T21:01:45.876Z"
}, },
{ {
"id": "60", "id": 60,
"title": "הכרה ב-MVP לרישוי והשבחה בלבד", "title": "הכרה ב-MVP לרישוי והשבחה בלבד",
"description": "הגדרת גרסה ראשונה שמכסה רק רישוי והשבחה בשל חוסר נתוני אימון לפיצויים", "description": "הגדרת גרסה ראשונה שמכסה רק רישוי והשבחה בשל חוסר נתוני אימון לפיצויים",
"details": "הגדרת MVP שמתמקד ברישוי והשבחה בלבד. תיעוד המגבלות הנוכחיות בנוגע לפיצויים ותכנית לאיסוף נתוני אימון עתידיים. הגדרת קריטריונים להרחבה לפיצויים בגרסאות עתידיות. עדכון מטריקות הצלחה בהתאם למגבלות הגרסה הראשונה.", "details": "הגדרת MVP שמתמקד ברישוי והשבחה בלבד. תיעוד המגבלות הנוכחיות בנוגע לפיצויים ותכנית לאיסוף נתוני אימון עתידיים. הגדרת קריטריונים להרחבה לפיצויים בגרסאות עתידיות. עדכון מטריקות הצלחה בהתאם למגבלות הגרסה הראשונה.",
@@ -396,7 +396,7 @@
"updatedAt": "2026-04-02T21:01:45.879Z" "updatedAt": "2026-04-02T21:01:45.879Z"
}, },
{ {
"id": "61", "id": 61,
"title": "בחינה מחדש של יעד 98% שיעור שינוי", "title": "בחינה מחדש של יעד 98% שיעור שינוי",
"description": "הערכה מחדש של ריאליות יעד 98% בהתבסס על מחקר Endsley על התנהגות מומחים", "description": "הערכה מחדש של ריאליות יעד 98% בהתבסס על מחקר Endsley על התנהגות מומחים",
"details": "ניתוח מחקרי על התנהגות מומחים ונטייתם לבצע שינויים. הגדרת יעד ריאלי יותר המתחשב בגורמים פסיכולוגיים. הצעת מדדי הצלחה חלופיים כמו שיעור שינויים משמעותיים או שביעות רצון המומחים. כולל הגדרת baseline מתוך נתונים היסטוריים אם קיימים.", "details": "ניתוח מחקרי על התנהגות מומחים ונטייתם לבצע שינויים. הגדרת יעד ריאלי יותר המתחשב בגורמים פסיכולוגיים. הצעת מדדי הצלחה חלופיים כמו שיעור שינויים משמעותיים או שביעות רצון המומחים. כולל הגדרת baseline מתוך נתונים היסטוריים אם קיימים.",
@@ -408,7 +408,7 @@
"updatedAt": "2026-04-02T21:02:13.446Z" "updatedAt": "2026-04-02T21:02:13.446Z"
}, },
{ {
"id": "62", "id": 62,
"title": "הגדרת מנגנון לולאת למידה", "title": "הגדרת מנגנון לולאת למידה",
"description": "פיתוח מנגנון עדכון המודל על בסיס פידבק מדפנה ומשתמשים", "description": "פיתוח מנגנון עדכון המודל על בסיס פידבק מדפנה ומשתמשים",
"details": "הגדרת אסטרטגיית עדכון המודל: fine-tuning מול prompt engineering מול עדכון RAG. יישום מנגנון איסוף פידבק מובנה, עיבוד הנתונים לפורמט מתאים לאימון, ותהליך עדכון אוטומטי או חצי-אוטומטי. כולל מנגנון A/B testing לבדיקת שיפורים.", "details": "הגדרת אסטרטגיית עדכון המודל: fine-tuning מול prompt engineering מול עדכון RAG. יישום מנגנון איסוף פידבק מובנה, עיבוד הנתונים לפורמט מתאים לאימון, ותהליך עדכון אוטומטי או חצי-אוטומטי. כולל מנגנון A/B testing לבדיקת שיפורים.",
@@ -423,7 +423,7 @@
"updatedAt": "2026-04-02T21:02:32.651Z" "updatedAt": "2026-04-02T21:02:32.651Z"
}, },
{ {
"id": "63", "id": 63,
"title": "הוספת הגנה מפני prompt injection", "title": "הוספת הגנה מפני prompt injection",
"description": "יישום מנגנון הגנה מפני prompt injection ממסמכי מקור חיצוניים", "description": "יישום מנגנון הגנה מפני prompt injection ממסמכי מקור חיצוניים",
"details": "פיתוח מנגנון סינון וסניטיזציה של מסמכי קלט לזיהוי ניסיונות prompt injection. יישום validation של תוכן המסמכים, הפרדה בין הוראות המערכת לתוכן המסמכים, ומנגנון flagging של מסמכים חשודים. כולל רשימה שחורה של דפוסים מסוכנים.", "details": "פיתוח מנגנון סינון וסניטיזציה של מסמכי קלט לזיהוי ניסיונות prompt injection. יישום validation של תוכן המסמכים, הפרדה בין הוראות המערכת לתוכן המסמכים, ומנגנון flagging של מסמכים חשודים. כולל רשימה שחורה של דפוסים מסוכנים.",
@@ -437,7 +437,7 @@
"updatedAt": "2026-04-02T21:02:49.768Z" "updatedAt": "2026-04-02T21:02:49.768Z"
}, },
{ {
"id": "64", "id": 64,
"title": "הוספת מנגנון back-flows בתהליך", "title": "הוספת מנגנון back-flows בתהליך",
"description": "יישום יכולת חזרה אחורה בתהליך לעריכת בלוקים קודמים או שינוי כיוון", "description": "יישום יכולת חזרה אחורה בתהליך לעריכת בלוקים קודמים או שינוי כיוון",
"details": "פיתוח ממשק לחזרה לשלבים קודמים בתהליך. מנגנון לעריכת בלוקים שכבר הושלמו, עדכון אוטומטי של בלוקים תלויים, ומעקב אחר שינויים. כולל אזהרות למשתמש על השפעת שינויים על בלוקים אחרים ואפשרות לביטול פעולות.", "details": "פיתוח ממשק לחזרה לשלבים קודמים בתהליך. מנגנון לעריכת בלוקים שכבר הושלמו, עדכון אוטומטי של בלוקים תלויים, ומעקב אחר שינויים. כולל אזהרות למשתמש על השפעת שינויים על בלוקים אחרים ואפשרות לביטול פעולות.",
@@ -451,7 +451,7 @@
"updatedAt": "2026-04-02T21:01:07.801Z" "updatedAt": "2026-04-02T21:01:07.801Z"
}, },
{ {
"id": "65", "id": 65,
"title": "הוספת שלב QA/ולידציה לפני שליחה לדפנה", "title": "הוספת שלב QA/ולידציה לפני שליחה לדפנה",
"description": "יישום checklist אוטומטי ומנגנון QA לפני הפלט הסופי", "description": "יישום checklist אוטומטי ומנגנון QA לפני הפלט הסופי",
"details": "פיתוח checklist אוטומטי שבודק שלמות כל הבלוקים, תקינות הפורמט, נוכחות כל הרכיבים הנדרשים, ועקביות פנימית. מנגנון וולידציה של ציטוטים והפניות, בדיקת איכות השפה, ואזהרות על בעיות פוטנציאליות. כולל דוח QA מפורט למשתמש.", "details": "פיתוח checklist אוטומטי שבודק שלמות כל הבלוקים, תקינות הפורמט, נוכחות כל הרכיבים הנדרשים, ועקביות פנימית. מנגנון וולידציה של ציטוטים והפניות, בדיקת איכות השפה, ואזהרות על בעיות פוטנציאליות. כולל דוח QA מפורט למשתמש.",
@@ -466,7 +466,7 @@
"updatedAt": "2026-04-02T21:03:09.658Z" "updatedAt": "2026-04-02T21:03:09.658Z"
}, },
{ {
"id": "66", "id": 66,
"title": "יישום ניהול גרסאות של בלוקים", "title": "יישום ניהול גרסאות של בלוקים",
"description": "פיתוח מערכת ניהול גרסאות לכל בלוק בנפרד", "description": "פיתוח מערכת ניהול גרסאות לכל בלוק בנפרד",
"details": "יישום version control לכל בלוק בנפרד, שמירת היסטוריית שינויים, יכולת השוואה בין גרסאות, ואפשרות לחזרה לגרסה קודמת של בלוק ספציפי. כולל ממשק גרפי להצגת ההבדלים בין גרסאות ומטא-דאטה על כל שינוי (זמן, משתמש, סיבה).", "details": "יישום version control לכל בלוק בנפרד, שמירת היסטוריית שינויים, יכולת השוואה בין גרסאות, ואפשרות לחזרה לגרסה קודמת של בלוק ספציפי. כולל ממשק גרפי להצגת ההבדלים בין גרסאות ומטא-דאטה על כל שינוי (זמן, משתמש, סיבה).",
@@ -480,7 +480,7 @@
"updatedAt": "2026-04-02T21:04:33.961Z" "updatedAt": "2026-04-02T21:04:33.961Z"
}, },
{ {
"id": "67", "id": 67,
"title": "טיפול באיחוד תיקים", "title": "טיפול באיחוד תיקים",
"description": "פיתוח מנגנון לטיפול באיחוד תיקים כמו במקרה אריאלי 1078+1083", "description": "פיתוח מנגנון לטיפול באיחוד תיקים כמו במקרה אריאלי 1078+1083",
"details": "יישום לוגיקה לזיהוי תיקים הקשורים זה לזה ומנגנון איחוד אוטומטי או חצי-אוטומטי. טיפול בחפיפות מידע, פתרון קונפליקטים, ושמירת קישוריות בין התיקים המאוחדים. כולל ממשק למשתמש לאישור ועריכת האיחוד המוצע.", "details": "יישום לוגיקה לזיהוי תיקים הקשורים זה לזה ומנגנון איחוד אוטומטי או חצי-אוטומטי. טיפול בחפיפות מידע, פתרון קונפליקטים, ושמירת קישוריות בין התיקים המאוחדים. כולל ממשק למשתמש לאישור ועריכת האיחוד המוצע.",
@@ -495,7 +495,7 @@
"updatedAt": "2026-04-02T21:04:33.964Z" "updatedAt": "2026-04-02T21:04:33.964Z"
}, },
{ {
"id": "68", "id": 68,
"title": "תיקון LOA של סיעור מוחות", "title": "תיקון LOA של סיעור מוחות",
"description": "תיקון רמת האוטומציה של סיעור מוחות מרמה ג' לרמה ב'", "description": "תיקון רמת האוטומציה של סיעור מוחות מרמה ג' לרמה ב'",
"details": "עדכון הגדרת רמת האוטומציה (LOA) של תהליך סיעור המוחות מרמה ג' (אוטומציה מלאה) לרמה ב' (אוטומציה עם פיקוח אנושי). עדכון כל המסמכים והממשקים הרלוונטיים. הבטחת התאמה לרמת הביקורת הנדרשת.", "details": "עדכון הגדרת רמת האוטומציה (LOA) של תהליך סיעור המוחות מרמה ג' (אוטומציה מלאה) לרמה ב' (אוטומציה עם פיקוח אנושי). עדכון כל המסמכים והממשקים הרלוונטיים. הבטחת התאמה לרמת הביקורת הנדרשת.",
@@ -507,7 +507,7 @@
"updatedAt": "2026-04-02T21:04:33.967Z" "updatedAt": "2026-04-02T21:04:33.967Z"
}, },
{ {
"id": "69", "id": 69,
"title": "הגדרת סיעור מוחות כאופציונלי", "title": "הגדרת סיעור מוחות כאופציונלי",
"description": "שינוי הגדרת סיעור המוחות לאופציונלי גם במקרים שיש נימוק קיים", "description": "שינוי הגדרת סיעור המוחות לאופציונלי גם במקרים שיש נימוק קיים",
"details": "עדכון הלוגיקה כך שסיעור מוחות יהיה אופציונלי בכל המקרים, כולל כאשר קיים נימוק בסיסי. הוספת אפשרות למשתמש לבחור האם להפעיל סיעור מוחות או לדלג עליו. עדכון ממשק המשתמש והדרישות בהתאם.", "details": "עדכון הלוגיקה כך שסיעור מוחות יהיה אופציונלי בכל המקרים, כולל כאשר קיים נימוק בסיסי. הוספת אפשרות למשתמש לבחור האם להפעיל סיעור מוחות או לדלג עליו. עדכון ממשק המשתמש והדרישות בהתאם.",
@@ -521,7 +521,7 @@
"updatedAt": "2026-04-02T21:04:33.969Z" "updatedAt": "2026-04-02T21:04:33.969Z"
}, },
{ {
"id": "70", "id": 70,
"title": "הוספת ניטרליות מבנית", "title": "הוספת ניטרליות מבנית",
"description": "הרחבת דרישות הניטרליות מלקסיקלית למבנית", "description": "הרחבת דרישות הניטרליות מלקסיקלית למבנית",
"details": "הגדרת כללים לניטרליות מבנית בנוסף ללקסיקלית: סדר הצגת הטיעונים, אורך היחסי של סעיפים, מיקום המידע, ומבנה הפסיקה. פיתוח מנגנון בדיקה אוטומטית לזיהוי הטיה מבנית ואזהרות למשתמש. כולל הנחיות לכתיבה מאוזנת.", "details": "הגדרת כללים לניטרליות מבנית בנוסף ללקסיקלית: סדר הצגת הטיעונים, אורך היחסי של סעיפים, מיקום המידע, ומבנה הפסיקה. פיתוח מנגנון בדיקה אוטומטית לזיהוי הטיה מבנית ואזהרות למשתמש. כולל הנחיות לכתיבה מאוזנת.",
@@ -535,7 +535,7 @@
"updatedAt": "2026-04-02T21:04:33.973Z" "updatedAt": "2026-04-02T21:04:33.973Z"
}, },
{ {
"id": "71", "id": 71,
"title": "מיפוי פרסורמן 4 stages", "title": "מיפוי פרסורמן 4 stages",
"description": "הרחבת המיפוי מ-LOA בלבד לכלל 4 השלבים של מודל פרסורמן", "description": "הרחבת המיפוי מ-LOA בלבד לכלל 4 השלבים של מודל פרסורמן",
"details": "מיפוי מלא של התהליך לפי 4 השלבים של פרסורמן: Information acquisition, Information analysis, Decision selection, Action implementation. הגדרת רמת האוטומציה לכל שלב בנפרד ולא רק LOA כללי. עדכון התיעוד והדרישות בהתאם.", "details": "מיפוי מלא של התהליך לפי 4 השלבים של פרסורמן: Information acquisition, Information analysis, Decision selection, Action implementation. הגדרת רמת האוטומציה לכל שלב בנפרד ולא רק LOA כללי. עדכון התיעוד והדרישות בהתאם.",
@@ -549,7 +549,7 @@
"updatedAt": "2026-04-02T21:04:33.976Z" "updatedAt": "2026-04-02T21:04:33.976Z"
}, },
{ {
"id": "72", "id": 72,
"title": "הגדרת דרישות ביצועים per-block וסינכרוני/אסינכרוני", "title": "הגדרת דרישות ביצועים per-block וסינכרוני/אסינכרוני",
"description": "הגדרת דרישות ביצועים מפורטות לכל בלוק ובחירה בין עיבוד סינכרוני לאסינכרוני", "description": "הגדרת דרישות ביצועים מפורטות לכל בלוק ובחירה בין עיבוד סינכרוני לאסינכרוני",
"details": "הגדרת SLA ספציפי לכל בלוק: זמני תגובה מקסימליים, throughput נדרש, ושיעור זמינות. החלטה על ארכיטקטורת עיבוד: סינכרונית לבלוקים קריטיים, אסינכרונית לבלוקים כבדים. יישום מנגנון ניטור ביצועים ואזהרות על חריגה מהסטנדרטים.", "details": "הגדרת SLA ספציפי לכל בלוק: זמני תגובה מקסימליים, throughput נדרש, ושיעור זמינות. החלטה על ארכיטקטורת עיבוד: סינכרונית לבלוקים קריטיים, אסינכרונית לבלוקים כבדים. יישום מנגנון ניטור ביצועים ואזהרות על חריגה מהסטנדרטים.",
@@ -563,7 +563,7 @@
"updatedAt": "2026-04-02T21:04:33.980Z" "updatedAt": "2026-04-02T21:04:33.980Z"
}, },
{ {
"id": "73", "id": 73,
"title": "הרחבת DB schema לתהליך מלא", "title": "הרחבת DB schema לתהליך מלא",
"description": "הוספת שדות וטבלאות חסרים לתמיכה בתהליך המלא של כתיבת החלטות משפטיות", "description": "הוספת שדות וטבלאות חסרים לתמיכה בתהליך המלא של כתיבת החלטות משפטיות",
"details": "בקובץ db.py:\n1. הוספת שדות לטבלת decisions:\n - direction_doc JSONB - לשמירת מסמך הכיוון\n - outcome_reasoning TEXT - לנימוק התוצאה\n2. הרחבת enum של status בטבלת cases ל-13 ערכים:\n ['new', 'uploading', 'processing', 'documents_ready', 'outcome_set', 'brainstorming', 'direction_approved', 'drafting', 'qa_review', 'drafted', 'exported', 'reviewed', 'final']\n3. יצירת טבלת qa_results חדשה:\n - id SERIAL PRIMARY KEY\n - case_number VARCHAR REFERENCES cases\n - validation_type VARCHAR\n - passed BOOLEAN\n - errors JSONB\n - created_at TIMESTAMP\n4. יישום כ-migration עם Alembic", "details": "בקובץ db.py:\n1. הוספת שדות לטבלת decisions:\n - direction_doc JSONB - לשמירת מסמך הכיוון\n - outcome_reasoning TEXT - לנימוק התוצאה\n2. הרחבת enum של status בטבלת cases ל-13 ערכים:\n ['new', 'uploading', 'processing', 'documents_ready', 'outcome_set', 'brainstorming', 'direction_approved', 'drafting', 'qa_review', 'drafted', 'exported', 'reviewed', 'final']\n3. יצירת טבלת qa_results חדשה:\n - id SERIAL PRIMARY KEY\n - case_number VARCHAR REFERENCES cases\n - validation_type VARCHAR\n - passed BOOLEAN\n - errors JSONB\n - created_at TIMESTAMP\n4. יישום כ-migration עם Alembic",
@@ -575,7 +575,7 @@
"updatedAt": "2026-04-03T08:54:55.256Z" "updatedAt": "2026-04-03T08:54:55.256Z"
}, },
{ {
"id": "74", "id": 74,
"title": "הוספת 5 API endpoints חדשים ב-MCP server", "title": "הוספת 5 API endpoints חדשים ב-MCP server",
"description": "יצירת endpoints חדשים לתמיכה בתהליך כתיבת ההחלטות", "description": "יצירת endpoints חדשים לתמיכה בתהליך כתיבת ההחלטות",
"details": "בקובץ server.py או בקבצי API:\n1. POST /api/cases/{case_number}/outcome\n - קבלת: {outcome: string, reasoning: string}\n - שמירה ב-DB\n - עדכון סטטוס ל-outcome_set\n2. GET /api/cases/{case_number}/claims\n - החזרת טענות מחולצות מה-JSONB\n3. POST /api/cases/{case_number}/direction\n - קבלת מסמך כיוון כ-JSON\n - שמירה בשדה direction_doc\n - עדכון סטטוס ל-direction_approved\n4. POST /api/cases/{case_number}/qa\n - הרצת בדיקות QA\n - שמירה בטבלת qa_results\n - החזרת תוצאות\n5. POST /api/cases/{case_number}/learn\n - הפעלת לולאת למידה\n - עדכון מודלים/פרמטרים", "details": "בקובץ server.py או בקבצי API:\n1. POST /api/cases/{case_number}/outcome\n - קבלת: {outcome: string, reasoning: string}\n - שמירה ב-DB\n - עדכון סטטוס ל-outcome_set\n2. GET /api/cases/{case_number}/claims\n - החזרת טענות מחולצות מה-JSONB\n3. POST /api/cases/{case_number}/direction\n - קבלת מסמך כיוון כ-JSON\n - שמירה בשדה direction_doc\n - עדכון סטטוס ל-direction_approved\n4. POST /api/cases/{case_number}/qa\n - הרצת בדיקות QA\n - שמירה בטבלת qa_results\n - החזרת תוצאות\n5. POST /api/cases/{case_number}/learn\n - הפעלת לולאת למידה\n - עדכון מודלים/פרמטרים",
@@ -589,7 +589,7 @@
"updatedAt": "2026-04-03T08:55:56.839Z" "updatedAt": "2026-04-03T08:55:56.839Z"
}, },
{ {
"id": "75", "id": 75,
"title": "הוספת 8 tools חדשים לפלאגין Paperclip", "title": "הוספת 8 tools חדשים לפלאגין Paperclip",
"description": "הרחבת הפלאגין עם כלים חדשים לאינטראקציה עם המערכת המשפטית", "description": "הרחבת הפלאגין עם כלים חדשים לאינטראקציה עם המערכת המשפטית",
"details": "1. בקובץ src/worker.ts - הוספת 8 tools:\n - legal_document_upload: העלאת מסמך\n - legal_document_list: רשימת מסמכים\n - legal_document_text: קריאת טקסט ממסמך\n - legal_search_case: חיפוש תיק\n - legal_find_similar: מציאת תקדימים\n - legal_set_outcome: הגדרת תוצאה\n - legal_get_claims: קבלת טענות\n - legal_style_guide: קבלת הנחיות סגנון\n\n2. בקובץ src/legal-api.ts - יישום 8 methods:\n ```typescript\n async uploadDocument(caseNumber: string, file: File) {...}\n async listDocuments(caseNumber: string) {...}\n async getDocumentText(docId: string) {...}\n async searchCase(query: string) {...}\n async findSimilar(caseNumber: string) {...}\n async setOutcome(caseNumber: string, outcome: string, reasoning: string) {...}\n async getClaims(caseNumber: string) {...}\n async getStyleGuide() {...}\n ```\n\n3. בקובץ plugin.json - עדכון manifest", "details": "1. בקובץ src/worker.ts - הוספת 8 tools:\n - legal_document_upload: העלאת מסמך\n - legal_document_list: רשימת מסמכים\n - legal_document_text: קריאת טקסט ממסמך\n - legal_search_case: חיפוש תיק\n - legal_find_similar: מציאת תקדימים\n - legal_set_outcome: הגדרת תוצאה\n - legal_get_claims: קבלת טענות\n - legal_style_guide: קבלת הנחיות סגנון\n\n2. בקובץ src/legal-api.ts - יישום 8 methods:\n ```typescript\n async uploadDocument(caseNumber: string, file: File) {...}\n async listDocuments(caseNumber: string) {...}\n async getDocumentText(docId: string) {...}\n async searchCase(query: string) {...}\n async findSimilar(caseNumber: string) {...}\n async setOutcome(caseNumber: string, outcome: string, reasoning: string) {...}\n async getClaims(caseNumber: string) {...}\n async getStyleGuide() {...}\n ```\n\n3. בקובץ plugin.json - עדכון manifest",
@@ -603,7 +603,7 @@
"updatedAt": "2026-04-03T08:59:27.838Z" "updatedAt": "2026-04-03T08:59:27.838Z"
}, },
{ {
"id": "76", "id": 76,
"title": "שיפור status sync ב-Paperclip", "title": "שיפור status sync ב-Paperclip",
"description": "מיפוי מלא של 13 סטטוסים והוספת comments מפורטים", "description": "מיפוי מלא של 13 סטטוסים והוספת comments מפורטים",
"details": "1. עדכון מיפוי סטטוסים:\n ```javascript\n const statusMapping = {\n 'new': 'תיק חדש',\n 'uploading': 'העלאת מסמכים',\n 'processing': 'עיבוד מסמכים',\n 'documents_ready': 'מסמכים מוכנים',\n 'outcome_set': 'תוצאה הוגדרה',\n 'brainstorming': 'גיבוש כיוון',\n 'direction_approved': 'כיוון אושר',\n 'drafting': 'כתיבת החלטה',\n 'qa_review': 'בדיקת איכות',\n 'drafted': 'טיוטה מוכנה',\n 'exported': 'יוצאה ל-DOCX',\n 'reviewed': 'נבדקה ע\"י עו\"ד',\n 'final': 'סופית'\n }\n ```\n\n2. הוספת comments אוטומטיים ב-Paperclip:\n - בכל מעבר סטטוס\n - עם timestamp\n - עם פירוט הפעולה\n\n3. עדכון job sync-case-status", "details": "1. עדכון מיפוי סטטוסים:\n ```javascript\n const statusMapping = {\n 'new': 'תיק חדש',\n 'uploading': 'העלאת מסמכים',\n 'processing': 'עיבוד מסמכים',\n 'documents_ready': 'מסמכים מוכנים',\n 'outcome_set': 'תוצאה הוגדרה',\n 'brainstorming': 'גיבוש כיוון',\n 'direction_approved': 'כיוון אושר',\n 'drafting': 'כתיבת החלטה',\n 'qa_review': 'בדיקת איכות',\n 'drafted': 'טיוטה מוכנה',\n 'exported': 'יוצאה ל-DOCX',\n 'reviewed': 'נבדקה ע\"י עו\"ד',\n 'final': 'סופית'\n }\n ```\n\n2. הוספת comments אוטומטיים ב-Paperclip:\n - בכל מעבר סטטוס\n - עם timestamp\n - עם פירוט הפעולה\n\n3. עדכון job sync-case-status",
@@ -617,7 +617,7 @@
"updatedAt": "2026-04-03T09:00:19.243Z" "updatedAt": "2026-04-03T09:00:19.243Z"
}, },
{ {
"id": "77", "id": 77,
"title": "כתיבת SOUL.md לסוכנים", "title": "כתיבת SOUL.md לסוכנים",
"description": "יצירת קבצי הנחיות לסוכני AI בעברית", "description": "יצירת קבצי הנחיות לסוכני AI בעברית",
"details": "1. CEO Agent SOUL.md:\n ```markdown\n # CEO Agent - סוכן מנהל\n \n ## תפקיד\n ניהול תהליך כתיבת החלטה משפטית מקצה לקצה\n \n ## הנחיות\n - עבוד בעברית תמיד\n - נהל את התהליך לפי 13 הסטטוסים\n - התרע לחיים במקרים: תקלה טכנית, החלטה מורכבת, חריגה מזמנים\n - וודא שכל שלב הושלם לפני מעבר לבא\n \n ## מיפוי סטטוסים\n [רשימת 13 סטטוסים עם הסבר לכל אחד]\n ```\n\n2. Case Analyst Agent SOUL.md:\n ```markdown\n # Case Analyst - סוכן מנתח\n \n ## תפקיד\n ניתוח מסמכים משפטיים וחילוץ מידע\n \n ## הנחיות\n - נתח מסמכים בעברית\n - חלץ טענות מרכזיות\n - זהה תקדימים רלוונטיים\n - סכם עובדות מהותיות\n ```", "details": "1. CEO Agent SOUL.md:\n ```markdown\n # CEO Agent - סוכן מנהל\n \n ## תפקיד\n ניהול תהליך כתיבת החלטה משפטית מקצה לקצה\n \n ## הנחיות\n - עבוד בעברית תמיד\n - נהל את התהליך לפי 13 הסטטוסים\n - התרע לחיים במקרים: תקלה טכנית, החלטה מורכבת, חריגה מזמנים\n - וודא שכל שלב הושלם לפני מעבר לבא\n \n ## מיפוי סטטוסים\n [רשימת 13 סטטוסים עם הסבר לכל אחד]\n ```\n\n2. Case Analyst Agent SOUL.md:\n ```markdown\n # Case Analyst - סוכן מנתח\n \n ## תפקיד\n ניתוח מסמכים משפטיים וחילוץ מידע\n \n ## הנחיות\n - נתח מסמכים בעברית\n - חלץ טענות מרכזיות\n - זהה תקדימים רלוונטיים\n - סכם עובדות מהותיות\n ```",
@@ -629,7 +629,7 @@
"updatedAt": "2026-04-03T08:57:14.984Z" "updatedAt": "2026-04-03T08:57:14.984Z"
}, },
{ {
"id": "78", "id": 78,
"title": "יישום skill /brainstorm", "title": "יישום skill /brainstorm",
"description": "יצירת skill לגיבוש כיוון ההחלטה בשיתוף עם המשתמש", "description": "יצירת skill לגיבוש כיוון ההחלטה בשיתוף עם המשתמש",
"details": "בקובץ skills/brainstorm.ts:\n```typescript\nexport async function brainstorm(caseNumber: string) {\n // שלב 1: הצגת טענות מרכזיות\n const claims = await api.getClaims(caseNumber);\n displayClaims(claims);\n \n // שלב 2: הצעת 2-3 כיוונים\n const directions = generateDirections(claims);\n displayDirections(directions);\n \n // שלב 3: דיון אינטראקטיבי\n let approved = false;\n while (!approved) {\n const feedback = await getUserFeedback();\n if (feedback.type === 'approve') {\n approved = true;\n } else {\n directions = refineDirections(directions, feedback);\n }\n }\n \n // שלב 4: יצירת מסמך כיוון\n const directionDoc = {\n mainDirection: directions.selected,\n keyPoints: directions.keyPoints,\n precedents: directions.precedents,\n approvedBy: 'user',\n timestamp: new Date()\n };\n \n // שלב 5: שמירה ועדכון סטטוס\n await api.saveDirection(caseNumber, directionDoc);\n}\n```", "details": "בקובץ skills/brainstorm.ts:\n```typescript\nexport async function brainstorm(caseNumber: string) {\n // שלב 1: הצגת טענות מרכזיות\n const claims = await api.getClaims(caseNumber);\n displayClaims(claims);\n \n // שלב 2: הצעת 2-3 כיוונים\n const directions = generateDirections(claims);\n displayDirections(directions);\n \n // שלב 3: דיון אינטראקטיבי\n let approved = false;\n while (!approved) {\n const feedback = await getUserFeedback();\n if (feedback.type === 'approve') {\n approved = true;\n } else {\n directions = refineDirections(directions, feedback);\n }\n }\n \n // שלב 4: יצירת מסמך כיוון\n const directionDoc = {\n mainDirection: directions.selected,\n keyPoints: directions.keyPoints,\n precedents: directions.precedents,\n approvedBy: 'user',\n timestamp: new Date()\n };\n \n // שלב 5: שמירה ועדכון סטטוס\n await api.saveDirection(caseNumber, directionDoc);\n}\n```",
@@ -643,7 +643,7 @@
"updatedAt": "2026-04-03T10:16:24.667Z" "updatedAt": "2026-04-03T10:16:24.667Z"
}, },
{ {
"id": "79", "id": 79,
"title": "שיפור skill /draft-decision לכתיבה בלוק-אחרי-בלוק", "title": "שיפור skill /draft-decision לכתיבה בלוק-אחרי-בלוק",
"description": "שדרוג מ-stub לכתיבה מלאה עם 12 בלוקים", "description": "שדרוג מ-stub לכתיבה מלאה עם 12 בלוקים",
"details": "בקובץ skills/draft-decision.ts:\n```typescript\nconst BLOCKS = [\n {id: 'ה', name: 'כותרת', temperature: 0.3},\n {id: 'ו', name: 'פתיח', temperature: 0.5},\n {id: 'ז', name: 'רקע', temperature: 0.4},\n {id: 'ח', name: 'טענות הצדדים', temperature: 0.3},\n {id: 'ט', name: 'תמצית', temperature: 0.6},\n {id: 'י', name: 'דיון והכרעה', temperature: 0.7, model: 'opus'},\n {id: 'יא', name: 'סוף דבר', temperature: 0.5}\n];\n\nexport async function draftDecision(caseNumber: string) {\n const direction = await api.getDirection(caseNumber);\n const lastBlock = await getLastCompletedBlock(caseNumber);\n \n for (let i = getBlockIndex(lastBlock) + 1; i < BLOCKS.length; i++) {\n const block = BLOCKS[i];\n \n // כתיבת בלוק\n const content = await writeBlock(block, {\n direction,\n previousBlocks: await getPreviousBlocks(caseNumber, i),\n temperature: block.temperature,\n model: block.model || 'default'\n });\n \n // שמירה מיידית\n await saveBlock(caseNumber, block.id, content);\n \n // בלוק י - CREAC + thinking\n if (block.id === 'י') {\n await applyCREAC(content);\n await addThinkingTags(content);\n }\n }\n}\n\n// Recovery function\nexport async function recoverDraft(caseNumber: string) {\n const lastBlock = await getLastCompletedBlock(caseNumber);\n return draftDecision(caseNumber); // ממשיך מאיפה שנפל\n}\n```", "details": "בקובץ skills/draft-decision.ts:\n```typescript\nconst BLOCKS = [\n {id: 'ה', name: 'כותרת', temperature: 0.3},\n {id: 'ו', name: 'פתיח', temperature: 0.5},\n {id: 'ז', name: 'רקע', temperature: 0.4},\n {id: 'ח', name: 'טענות הצדדים', temperature: 0.3},\n {id: 'ט', name: 'תמצית', temperature: 0.6},\n {id: 'י', name: 'דיון והכרעה', temperature: 0.7, model: 'opus'},\n {id: 'יא', name: 'סוף דבר', temperature: 0.5}\n];\n\nexport async function draftDecision(caseNumber: string) {\n const direction = await api.getDirection(caseNumber);\n const lastBlock = await getLastCompletedBlock(caseNumber);\n \n for (let i = getBlockIndex(lastBlock) + 1; i < BLOCKS.length; i++) {\n const block = BLOCKS[i];\n \n // כתיבת בלוק\n const content = await writeBlock(block, {\n direction,\n previousBlocks: await getPreviousBlocks(caseNumber, i),\n temperature: block.temperature,\n model: block.model || 'default'\n });\n \n // שמירה מיידית\n await saveBlock(caseNumber, block.id, content);\n \n // בלוק י - CREAC + thinking\n if (block.id === 'י') {\n await applyCREAC(content);\n await addThinkingTags(content);\n }\n }\n}\n\n// Recovery function\nexport async function recoverDraft(caseNumber: string) {\n const lastBlock = await getLastCompletedBlock(caseNumber);\n return draftDecision(caseNumber); // ממשיך מאיפה שנפל\n}\n```",
@@ -658,7 +658,7 @@
"updatedAt": "2026-04-03T10:16:24.670Z" "updatedAt": "2026-04-03T10:16:24.670Z"
}, },
{ {
"id": "80", "id": 80,
"title": "יישום skill /qa-validate", "title": "יישום skill /qa-validate",
"description": "בדיקות איכות אוטומטיות על ההחלטה", "description": "בדיקות איכות אוטומטיות על ההחלטה",
"details": "בקובץ skills/qa-validate.ts:\n```typescript\nexport async function qaValidate(caseNumber: string) {\n const decision = await api.getDecision(caseNumber);\n const documents = await api.getDocuments(caseNumber);\n const claims = await api.getClaims(caseNumber);\n \n const checks = [\n {\n name: 'grounding_check',\n fn: () => validateGrounding(decision, documents),\n critical: true\n },\n {\n name: 'claims_coverage',\n fn: () => validateClaimsCoverage(decision, claims),\n critical: true\n },\n {\n name: 'neutral_background',\n fn: () => validateNeutrality(decision.background),\n critical: false\n },\n {\n name: 'weights_range',\n fn: () => validateWeightsInRange(decision),\n critical: true\n },\n {\n name: 'sequential_numbering',\n fn: () => validateNumbering(decision),\n critical: false\n },\n {\n name: 'definitions',\n fn: () => validateDefinitions(decision),\n critical: false\n }\n ];\n \n const results = [];\n let hasErrors = false;\n \n for (const check of checks) {\n const result = await check.fn();\n results.push({...result, name: check.name});\n if (!result.passed && check.critical) {\n hasErrors = true;\n }\n }\n \n // שמירת תוצאות\n await api.saveQAResults(caseNumber, results);\n \n // חסימת ייצוא אם יש שגיאות קריטיות\n if (hasErrors) {\n await api.blockExport(caseNumber);\n throw new Error('QA failed - export blocked');\n }\n \n return results;\n}\n```", "details": "בקובץ skills/qa-validate.ts:\n```typescript\nexport async function qaValidate(caseNumber: string) {\n const decision = await api.getDecision(caseNumber);\n const documents = await api.getDocuments(caseNumber);\n const claims = await api.getClaims(caseNumber);\n \n const checks = [\n {\n name: 'grounding_check',\n fn: () => validateGrounding(decision, documents),\n critical: true\n },\n {\n name: 'claims_coverage',\n fn: () => validateClaimsCoverage(decision, claims),\n critical: true\n },\n {\n name: 'neutral_background',\n fn: () => validateNeutrality(decision.background),\n critical: false\n },\n {\n name: 'weights_range',\n fn: () => validateWeightsInRange(decision),\n critical: true\n },\n {\n name: 'sequential_numbering',\n fn: () => validateNumbering(decision),\n critical: false\n },\n {\n name: 'definitions',\n fn: () => validateDefinitions(decision),\n critical: false\n }\n ];\n \n const results = [];\n let hasErrors = false;\n \n for (const check of checks) {\n const result = await check.fn();\n results.push({...result, name: check.name});\n if (!result.passed && check.critical) {\n hasErrors = true;\n }\n }\n \n // שמירת תוצאות\n await api.saveQAResults(caseNumber, results);\n \n // חסימת ייצוא אם יש שגיאות קריטיות\n if (hasErrors) {\n await api.blockExport(caseNumber);\n throw new Error('QA failed - export blocked');\n }\n \n return results;\n}\n```",
@@ -672,7 +672,7 @@
"updatedAt": "2026-04-03T10:16:24.673Z" "updatedAt": "2026-04-03T10:16:24.673Z"
}, },
{ {
"id": "81", "id": 81,
"title": "אינטגרציה E2E וחיבור Paperclip events", "title": "אינטגרציה E2E וחיבור Paperclip events",
"description": "חיבור מלא בין Paperclip ל-Claude Code עם trigger אוטומטי", "description": "חיבור מלא בין Paperclip ל-Claude Code עם trigger אוטומטי",
"details": "1. חיבור Paperclip events:\n```javascript\n// בקובץ paperclip-integration.js\npaperclip.on('issue.comment.created', async (event) => {\n if (event.comment.includes('/draft')) {\n await claudeCode.trigger('draft-decision', {\n caseNumber: event.issue.number\n });\n }\n});\n```\n\n2. E2E test על תיק הכט:\n```javascript\ntest('full flow - Hecht case', async () => {\n // העלאת חומרים\n await uploadDocuments('hecht', ['doc1.pdf', 'doc2.pdf']);\n \n // הזנת תוצאה\n await setOutcome('hecht', 'rejected', 'אין עילה');\n \n // כתיבה\n await triggerDraft('hecht');\n await waitForStatus('drafted');\n \n // QA\n const qaResults = await runQA('hecht');\n expect(qaResults.passed).toBe(true);\n \n // ייצוא\n const docx = await exportToDocx('hecht');\n \n // השוואה\n const similarity = await compareToFinal(docx, 'hecht-final.docx');\n expect(similarity).toBeGreaterThan(0.9);\n});\n```", "details": "1. חיבור Paperclip events:\n```javascript\n// בקובץ paperclip-integration.js\npaperclip.on('issue.comment.created', async (event) => {\n if (event.comment.includes('/draft')) {\n await claudeCode.trigger('draft-decision', {\n caseNumber: event.issue.number\n });\n }\n});\n```\n\n2. E2E test על תיק הכט:\n```javascript\ntest('full flow - Hecht case', async () => {\n // העלאת חומרים\n await uploadDocuments('hecht', ['doc1.pdf', 'doc2.pdf']);\n \n // הזנת תוצאה\n await setOutcome('hecht', 'rejected', 'אין עילה');\n \n // כתיבה\n await triggerDraft('hecht');\n await waitForStatus('drafted');\n \n // QA\n const qaResults = await runQA('hecht');\n expect(qaResults.passed).toBe(true);\n \n // ייצוא\n const docx = await exportToDocx('hecht');\n \n // השוואה\n const similarity = await compareToFinal(docx, 'hecht-final.docx');\n expect(similarity).toBeGreaterThan(0.9);\n});\n```",
@@ -691,7 +691,7 @@
"updatedAt": "2026-04-03T10:19:26.776Z" "updatedAt": "2026-04-03T10:19:26.776Z"
}, },
{ {
"id": "82", "id": 82,
"title": "מבחן הסמכה", "title": "מבחן הסמכה",
"description": "בדיקת המערכת על תיק עם החלטה קיימת והשוואת איכות", "description": "בדיקת המערכת על תיק עם החלטה קיימת והשוואת איכות",
"details": "שלב ב - בדיקה על תיק עם החלטה:\n```javascript\nexport async function certificationTest() {\n // בחירת תיק עם החלטה סופית\n const testCase = await selectTestCase();\n \n // הסתרת ההחלטה המקורית\n await hideOriginalDecision(testCase.number);\n \n // הרצת המערכת\n await runFullFlow(testCase.number);\n \n // השוואה\n const draft = await getDecision(testCase.number);\n const original = testCase.originalDecision;\n \n const comparison = {\n structure: compareStructure(draft, original),\n content: compareContent(draft, original),\n reasoning: compareReasoning(draft, original),\n outcome: compareOutcome(draft, original)\n };\n \n // חישוב ציון כולל\n const score = calculateScore(comparison);\n \n // בדיקת סף - 90%\n if (score < 0.9) {\n throw new Error(`Score ${score} is below threshold`);\n }\n \n return {score, comparison};\n}\n\n// שלב ג - תיק חי\nexport async function liveTest() {\n const liveCase = await getLiveCase();\n await runFullFlow(liveCase.number);\n \n // שליחה לדפנה לבדיקה\n await sendForReview('dafna@law.firm', liveCase.number);\n}\n```", "details": "שלב ב - בדיקה על תיק עם החלטה:\n```javascript\nexport async function certificationTest() {\n // בחירת תיק עם החלטה סופית\n const testCase = await selectTestCase();\n \n // הסתרת ההחלטה המקורית\n await hideOriginalDecision(testCase.number);\n \n // הרצת המערכת\n await runFullFlow(testCase.number);\n \n // השוואה\n const draft = await getDecision(testCase.number);\n const original = testCase.originalDecision;\n \n const comparison = {\n structure: compareStructure(draft, original),\n content: compareContent(draft, original),\n reasoning: compareReasoning(draft, original),\n outcome: compareOutcome(draft, original)\n };\n \n // חישוב ציון כולל\n const score = calculateScore(comparison);\n \n // בדיקת סף - 90%\n if (score < 0.9) {\n throw new Error(`Score ${score} is below threshold`);\n }\n \n return {score, comparison};\n}\n\n// שלב ג - תיק חי\nexport async function liveTest() {\n const liveCase = await getLiveCase();\n await runFullFlow(liveCase.number);\n \n // שליחה לדפנה לבדיקה\n await sendForReview('dafna@law.firm', liveCase.number);\n}\n```",
@@ -705,7 +705,7 @@
"updatedAt": "2026-04-03T10:19:26.779Z" "updatedAt": "2026-04-03T10:19:26.779Z"
}, },
{ {
"id": "83", "id": 83,
"title": "Phase 1 — Project setup (legal-ai UI rewrite)", "title": "Phase 1 — Project setup (legal-ai UI rewrite)",
"description": "הקמת scaffold של Next.js עם TypeScript + Tailwind v4 + App Router ב-web-ui/. התקנת כל התלויות: @tanstack/react-query, @tanstack/react-table, react-hook-form, @hookform/resolvers, zod, lucide-react, react-dropzone, openapi-typescript. העברת design-system.css tokens (navy/gold/parchment, Heebo) ל-Tailwind theme דרך @theme ו-CSS variables. הגדרת RTL עברית עם Heebo via next/font/google. בניית AppShell עם navy header + gold rule + nav.", "description": "הקמת scaffold של Next.js עם TypeScript + Tailwind v4 + App Router ב-web-ui/. התקנת כל התלויות: @tanstack/react-query, @tanstack/react-table, react-hook-form, @hookform/resolvers, zod, lucide-react, react-dropzone, openapi-typescript. העברת design-system.css tokens (navy/gold/parchment, Heebo) ל-Tailwind theme דרך @theme ו-CSS variables. הגדרת RTL עברית עם Heebo via next/font/google. בניית AppShell עם navy header + gold rule + nav.",
"status": "done", "status": "done",
@@ -801,7 +801,7 @@
"updatedAt": "2026-04-11T13:50:47.941Z" "updatedAt": "2026-04-11T13:50:47.941Z"
}, },
{ {
"id": "84", "id": 84,
"title": "Phase 2 — API client + generated TypeScript types", "title": "Phase 2 — API client + generated TypeScript types",
"description": "Add npm run api:types script that runs openapi-typescript against FastAPI's /openapi.json -> src/lib/api/types.ts. Build lib/api/client.ts (typed fetch wrapper + TanStack Query client with default retry/staleTime). Create one lib/api/<domain>.ts per endpoint category (cases, upload, compose, training, system), each exporting typed useQuery/useMutation hooks. Build lib/sse.ts as EventSource -> Query cache adapter. Plan: ~/.claude/plans/joyful-marinating-sutton.md.", "description": "Add npm run api:types script that runs openapi-typescript against FastAPI's /openapi.json -> src/lib/api/types.ts. Build lib/api/client.ts (typed fetch wrapper + TanStack Query client with default retry/staleTime). Create one lib/api/<domain>.ts per endpoint category (cases, upload, compose, training, system), each exporting typed useQuery/useMutation hooks. Build lib/sse.ts as EventSource -> Query cache adapter. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 2 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.", "details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 2 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
@@ -815,7 +815,7 @@
"updatedAt": "2026-04-11T15:51:34.020Z" "updatedAt": "2026-04-11T15:51:34.020Z"
}, },
{ {
"id": "85", "id": 85,
"title": "Phase 3 — Core read views (home, case detail, compose)", "title": "Phase 3 — Core read views (home, case detail, compose)",
"description": "Port the 3 highest-value screens. Use the frontend-design Claude Code skill to generate layout + composition, passing design tokens (navy/gold/parchment, Heebo), editorial voice, and typed API hooks. Use shadcn Card/Badge/Tabs/Sheet/ScrollArea as primitives. Port the custom donut chart into <DonutChart> component. TanStack Query staleTime:5000 for case detail replaces manual 5s polling. Plan: ~/.claude/plans/joyful-marinating-sutton.md.", "description": "Port the 3 highest-value screens. Use the frontend-design Claude Code skill to generate layout + composition, passing design tokens (navy/gold/parchment, Heebo), editorial voice, and typed API hooks. Use shadcn Card/Badge/Tabs/Sheet/ScrollArea as primitives. Port the custom donut chart into <DonutChart> component. TanStack Query staleTime:5000 for case detail replaces manual 5s polling. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 3 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.", "details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 3 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
@@ -829,7 +829,7 @@
"updatedAt": "2026-04-11T16:09:18.006Z" "updatedAt": "2026-04-11T16:09:18.006Z"
}, },
{ {
"id": "86", "id": 86,
"title": "Phase 4 — Forms and wizards (new case, upload, inline edits)", "title": "Phase 4 — Forms and wizards (new case, upload, inline edits)",
"description": "Port new case wizard, bulk upload, inline forms on case detail. Use react-hook-form + zod with schemas in lib/schemas/<entity>.ts. Build shared <WizardShell> from shadcn Card + Progress + Tabs. Build <DropZone> (react-dropzone + shadcn). Integrate SSE for upload progress via lib/sse.ts. Plan: ~/.claude/plans/joyful-marinating-sutton.md.", "description": "Port new case wizard, bulk upload, inline forms on case detail. Use react-hook-form + zod with schemas in lib/schemas/<entity>.ts. Build shared <WizardShell> from shadcn Card + Progress + Tabs. Build <DropZone> (react-dropzone + shadcn). Integrate SSE for upload progress via lib/sse.ts. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 4 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.", "details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 4 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
@@ -843,7 +843,7 @@
"updatedAt": "2026-04-11T16:25:55.569Z" "updatedAt": "2026-04-11T16:25:55.569Z"
}, },
{ {
"id": "87", "id": 87,
"title": "Phase 5 — Secondary screens (compare, training, style report, skills, diagnostics)", "title": "Phase 5 — Secondary screens (compare, training, style report, skills, diagnostics)",
"description": "Port the remaining 5 views. Use TanStack Table for training corpus and diagnostics lists. Port any charts/visualizations from current index.html. Plan: ~/.claude/plans/joyful-marinating-sutton.md.", "description": "Port the remaining 5 views. Use TanStack Table for training corpus and diagnostics lists. Port any charts/visualizations from current index.html. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 5 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.", "details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 5 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
@@ -857,7 +857,7 @@
"updatedAt": "2026-04-11T17:33:42.976Z" "updatedAt": "2026-04-11T17:33:42.976Z"
}, },
{ {
"id": "88", "id": 88,
"title": "Phase 6 — Polish & testing", "title": "Phase 6 — Polish & testing",
"description": "Accessibility pass (keyboard nav, aria-label on RTL icons, focus trap in modals). Error boundaries + toast notifications for failed mutations. Loading states for every query. Cross-browser smoke test (Chrome, Firefox, Safari) + mobile device test. Document E2E smoke test script in web-ui/README.md. Plan: ~/.claude/plans/joyful-marinating-sutton.md.", "description": "Accessibility pass (keyboard nav, aria-label on RTL icons, focus trap in modals). Error boundaries + toast notifications for failed mutations. Loading states for every query. Cross-browser smoke test (Chrome, Firefox, Safari) + mobile device test. Document E2E smoke test script in web-ui/README.md. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 6 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.", "details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 6 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
@@ -871,7 +871,7 @@
"updatedAt": "2026-04-11T17:44:08.337Z" "updatedAt": "2026-04-11T17:44:08.337Z"
}, },
{ {
"id": "89", "id": 89,
"title": "Phase 7 — Deployment & cutover", "title": "Phase 7 — Deployment & cutover",
"description": "Add multi-stage Dockerfile for web-ui/ (Node 20 build -> nginx serve of out/). Add web-ui as new app in Coolify project pointing to staging subdomain legal-ai-next.nautilus.marcusgroup.org. Run full smoke test against staging. Cutover: DNS flip legal-ai.nautilus.marcusgroup.org to new app, keep old on rollback subdomain for 1 week. Follow-up PR removes legal-ai/web/static/index.html + design-system.css once stable. Plan: ~/.claude/plans/joyful-marinating-sutton.md.", "description": "Add multi-stage Dockerfile for web-ui/ (Node 20 build -> nginx serve of out/). Add web-ui as new app in Coolify project pointing to staging subdomain legal-ai-next.nautilus.marcusgroup.org. Run full smoke test against staging. Cutover: DNS flip legal-ai.nautilus.marcusgroup.org to new app, keep old on rollback subdomain for 1 week. Follow-up PR removes legal-ai/web/static/index.html + design-system.css once stable. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 7 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.", "details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 7 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
@@ -884,7 +884,7 @@
"subtasks": [] "subtasks": []
}, },
{ {
"id": "90", "id": 90,
"title": "Phase 4.5 — Practice area integration", "title": "Phase 4.5 — Practice area integration",
"description": "Add practice_area + appeal_subtype to the wizard, types, schema, case header, and cases table. Gap identified after backend commit 26d09d6 (multi-tenant axis) — new Next.js UI has zero integration while vanilla UI is fully wired. Plan: ~/.claude/plans/woolly-cooking-graham.md", "description": "Add practice_area + appeal_subtype to the wizard, types, schema, case header, and cases table. Gap identified after backend commit 26d09d6 (multi-tenant axis) — new Next.js UI has zero integration while vanilla UI is fully wired. Plan: ~/.claude/plans/woolly-cooking-graham.md",
"details": "", "details": "",
@@ -898,7 +898,7 @@
"updatedAt": "2026-04-11T17:15:57.831Z" "updatedAt": "2026-04-11T17:15:57.831Z"
}, },
{ {
"id": "91", "id": 91,
"title": "Precedent attachment in compose screen", "title": "Precedent attachment in compose screen",
"description": "Add case_precedents table + FastAPI endpoints + MCP tools + Next.js compose UI for attaching legal precedents (quote + citation + optional archived PDF) to threshold_claims/issues and to the case as a whole. Plan: ~/.claude/plans/woolly-cooking-graham.md", "description": "Add case_precedents table + FastAPI endpoints + MCP tools + Next.js compose UI for attaching legal precedents (quote + citation + optional archived PDF) to threshold_claims/issues and to the case as a whole. Plan: ~/.claude/plans/woolly-cooking-graham.md",
"details": "", "details": "",
@@ -974,5 +974,131 @@
"updated": "2026-04-13T14:20:54.888Z", "updated": "2026-04-13T14:20:54.888Z",
"description": "Tasks for master context" "description": "Tasks for master context"
} }
},
"legal-ai": {
"tasks": [
{
"id": "1",
"title": "V7 schema: precedent library + halachot tables",
"description": "Add SCHEMA_V7_SQL to db.py: extend case_law with source_kind/document_id/extraction_status/halacha_extraction_status/practice_area (CHECK constraint for 3 areas)/appeal_subtype/headnote. Create precedent_chunks table with vector(1024). Create halachot table with vector(1024), review_status, practice_areas array. Add IVFFlat indexes. Register V7 in init_schema().",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-03T08:17:59.928Z"
},
{
"id": "2",
"title": "Chunker: add court ruling section patterns",
"description": "Extend services/chunker.py SECTION_PATTERNS with 4 patterns for external court rulings: פסק דין→ruling, נימוקים→legal_analysis, סוף דבר→conclusion, העובדות הצריכות לעניין→facts",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [
"1"
],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-05-03T08:18:33.239Z"
},
{
"id": "3",
"title": "Service: halacha_extractor.py",
"description": "New service that runs claude_session.query_json() over chunks where section_type IN (legal_analysis, ruling, conclusion). Concurrency=3, retry=1. Validates supporting_quote with substring check after Hebrew normalization. All halachot inserted with review_status=pending_review (no auto-publish). Embeds rule_statement+reasoning_summary via Voyage. Uses Hebrew prompt from plan appendix א. Idempotent on case_law_id.",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [
"1",
"2"
],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-03T08:22:12.392Z"
},
{
"id": "4",
"title": "Service: precedent_library.py orchestrator",
"description": "New service with ingest_precedent(file_path, citation, court, decision_date, source_type, precedent_level, practice_area, appeal_subtype, subject_tags, case_name, task_id) that orchestrates: extract_text → proofread → INSERT case_law (source_kind=external_upload) → chunk → embed → store precedent_chunks → halacha_extractor.extract → embed halachot → publish progress. Plus delete_precedent (cascading), list_precedents(filters), get_precedent(id), search_library(query, filters, limit) merging chunks+approved-halachot ranked.",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [
"1",
"2",
"3"
],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-03T08:23:33.235Z"
},
{
"id": "5",
"title": "MCP tools: precedent_library + halacha_review",
"description": "Create mcp-server/src/legal_mcp/tools/precedent_library.py with tools: precedent_library_upload, precedent_library_list, precedent_library_get, precedent_library_delete, precedent_extract_halachot, search_precedent_library (semantic, returns merged halachot+chunks), halacha_review (approve/reject). Register all in server.py. Do NOT modify existing precedent_search_library or search_decisions.",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [
"4"
],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-03T08:25:07.439Z"
},
{
"id": "6",
"title": "FastAPI endpoints under /api/precedent-library",
"description": "Add to web/app.py: POST /api/precedent-library/upload (multipart), GET /api/precedent-library (filters), GET /api/precedent-library/{id}, PATCH /api/precedent-library/{id}, DELETE /api/precedent-library/{id}, POST /api/precedent-library/{id}/extract-halachot, GET /api/precedent-library/search, GET /api/halachot?status=pending_review, PATCH /api/halachot/{id}, GET /api/precedent-library/stats. Reuse existing /api/progress/{task_id} SSE.",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [
"5"
],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-03T08:26:21.860Z"
},
{
"id": "7",
"title": "UI: /precedents page with 4 tabs",
"description": "New web-ui/src/app/precedents/page.tsx with tabs: Library (table+filters+upload), Semantic Search, Pending Review (PRIMARY - bulk approval UX with J/K nav, A/R/E shortcuts, side-by-side rule_statement vs supporting_quote, badge count), Stats. New components in web-ui/src/components/precedents/: precedent-upload-sheet, precedent-list-table, precedent-search-panel, precedent-detail-panel, halacha-review-card. New hooks in web-ui/src/lib/api/precedent-library.ts. Add nav link in app-shell.tsx.",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [
"6"
],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-03T08:34:00.548Z"
},
{
"id": "8",
"title": "Agent integration: legal-writer + 3 others",
"description": "Update .claude/agents/legal-writer.md (PRIMARY) — add mcp__legal-ai__search_precedent_library to tools and prompt section explaining when to use it for CREAC rule+explanation in block י. Update legal-researcher.md, legal-analyst.md, legal-ceo.md, legal-qa.md to add the tool. Update skills/decision/SKILL.md with section explaining the 3 corpora (style_corpus, case_precedents, precedent_library).",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [
"5"
],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-05-03T08:36:24.711Z"
}
],
"metadata": {
"version": "1.0.0",
"lastModified": "2026-05-03T08:36:24.711Z",
"taskCount": 8,
"completedCount": 8,
"tags": [
"legal-ai"
]
}
} }
} }

View File

@@ -47,6 +47,7 @@ mcp = FastMCP(
from legal_mcp.tools import ( # noqa: E402 from legal_mcp.tools import ( # noqa: E402
cases, documents, search, drafting, workflow, precedents, cases, documents, search, drafting, workflow, precedents,
precedent_library as plib,
) )
@@ -142,10 +143,114 @@ async def precedent_remove(precedent_id: str) -> str:
async def precedent_search_library( async def precedent_search_library(
query: str, practice_area: str = "", limit: int = 10, query: str, practice_area: str = "", limit: int = 10,
) -> str: ) -> str:
"""חיפוש בספרייה הרוחבית של ציטוטים שנצברו בין תיקים.""" """חיפוש בציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
שונה מ-search_precedent_library שמחפש בקורפוס הפסיקה הסמכותית."""
return await precedents.precedent_search_library(query, practice_area, limit) return await precedents.precedent_search_library(query, practice_area, limit)
# ── External Precedent Library — authoritative case-law corpus ─────
# Distinct from precedent_search_library above (chair-attached quotes)
# and from search_decisions (Daphna's style corpus).
@mcp.tool()
async def precedent_library_upload(
file_path: str,
citation: str,
case_name: str = "",
court: str = "",
decision_date: str = "",
source_type: str = "",
precedent_level: str = "",
practice_area: str = "",
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
is_binding: bool = True,
headnote: str = "",
summary: str = "",
) -> str:
"""העלאת פסיקה חיצונית (פס"ד / החלטה של ועדה אחרת) לקורפוס הסמכותי. מחלץ הלכות אוטומטית — כולן ממתינות לאישור היו"ר. practice_area: rishuy_uvniya / betterment_levy / compensation_197."""
return await plib.precedent_library_upload(
file_path, citation, case_name, court, decision_date,
source_type, precedent_level, practice_area, appeal_subtype,
subject_tags, is_binding, headnote, summary,
)
@mcp.tool()
async def precedent_library_list(
practice_area: str = "",
court: str = "",
precedent_level: str = "",
source_type: str = "",
search: str = "",
limit: int = 100,
) -> str:
"""רשימת הפסיקה בקורפוס הסמכותי, עם פילטרים."""
return await plib.precedent_library_list(
practice_area, court, precedent_level, source_type, search, limit,
)
@mcp.tool()
async def precedent_library_get(case_law_id: str) -> str:
"""פסיקה ספציפית בקורפוס + רשימת ההלכות שחולצו ממנה (כולל ממתינות לאישור)."""
return await plib.precedent_library_get(case_law_id)
@mcp.tool()
async def precedent_library_delete(case_law_id: str) -> str:
"""מחיקת פסיקה מהקורפוס (cascade: chunks + halachot)."""
return await plib.precedent_library_delete(case_law_id)
@mcp.tool()
async def precedent_extract_halachot(case_law_id: str) -> str:
"""הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review."""
return await plib.precedent_extract_halachot(case_law_id)
@mcp.tool()
async def search_precedent_library(
query: str,
practice_area: str = "",
court: str = "",
precedent_level: str = "",
appeal_subtype: str = "",
subject_tag: str = "",
limit: int = 10,
include_halachot: bool = True,
) -> str:
"""חיפוש סמנטי בקורפוס הפסיקה הסמכותית. מחזיר הלכות (מאושרות בלבד) + קטעי טקסט. השתמש כש-legal-writer צריך לצטט פסיקה מחייבת בבלוק י (CREAC: rule + explanation)."""
return await plib.search_precedent_library(
query, practice_area, court, precedent_level, appeal_subtype,
None, subject_tag, limit, include_halachot,
)
@mcp.tool()
async def halacha_review(
halacha_id: str,
status: str,
reviewer: str = "דפנה",
rule_statement: str = "",
reasoning_summary: str = "",
subject_tags: list[str] | None = None,
practice_areas: list[str] | None = None,
) -> str:
"""אישור / דחייה / עריכה של הלכה שחולצה אוטומטית. status: pending_review / approved / rejected / published."""
return await plib.halacha_review(
halacha_id, status, reviewer, rule_statement, reasoning_summary,
subject_tags, practice_areas,
)
@mcp.tool()
async def halachot_pending(limit: int = 100) -> str:
"""תור ההלכות הממתינות לאישור."""
return await plib.halachot_pending(limit)
# Documents # Documents
@mcp.tool() @mcp.tool()
async def document_upload( async def document_upload(

View File

@@ -7,14 +7,16 @@ from dataclasses import dataclass, field
from legal_mcp import config from legal_mcp import config
# Hebrew legal section headers # Hebrew legal section headers.
# Covers both appeals committee decisions and external court rulings —
# court rulings use slightly different vocabulary (פסק דין, נימוקים, סוף דבר).
SECTION_PATTERNS = [ SECTION_PATTERNS = [
(r"רקע\s*עובדתי|רקע\s*כללי|העובדות|הרקע", "facts"), (r"רקע\s*עובדתי|רקע\s*כללי|העובדות|הרקע", "facts"),
(r"טענות\s*העוררי[םן]|טענות\s*המערערי[םן]|עיקר\s*טענות\s*העוררי[םן]", "appellant_claims"), (r"טענות\s*העוררי[םן]|טענות\s*המערערי[םן]|עיקר\s*טענות\s*העוררי[םן]", "appellant_claims"),
(r"טענות\s*המשיבי[םן]|תשובת\s*המשיבי[םן]|עיקר\s*טענות\s*המשיבי[םן]", "respondent_claims"), (r"טענות\s*המשיבי[םן]|תשובת\s*המשיבי[םן]|עיקר\s*טענות\s*המשיבי[םן]", "respondent_claims"),
(r"דיון\s*והכרעה|דיון|הכרעה|ניתוח\s*משפטי|המסגרת\s*המשפטית", "legal_analysis"), (r"דיון\s*והכרעה|דיון|הכרעה|ניתוח\s*משפטי|המסגרת\s*המשפטית|נימוקים", "legal_analysis"),
(r"מסקנ[הות]|סיכום", "conclusion"), (r"מסקנ[הות]|סיכום|סוף\s*דבר", "conclusion"),
(r"החלטה|לפיכך\s*אני\s*מחליט|התוצאה", "ruling"), (r"פסק[- ]?דין|החלטה|לפיכך\s*אני\s*מחליט|התוצאה", "ruling"),
(r"מבוא|פתיחה|לפניי", "intro"), (r"מבוא|פתיחה|לפניי", "intro"),
] ]

View File

@@ -518,6 +518,91 @@ CREATE INDEX IF NOT EXISTS idx_cases_archived ON cases(archived_at) WHERE archiv
""" """
# ── V7: External Precedent Library + halacha extraction ──────────
# Chair-uploaded external court rulings and other appeals committee decisions
# become an authoritative law corpus. Distinct from style_corpus (Daphna's
# style) and case_precedents (chair-attached quotes scoped to a single case).
SCHEMA_V7_SQL = """
-- case_law extensions: distinguish chair-uploaded full rulings from
-- auto-extracted citation stubs, and track ingestion progress.
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS source_kind TEXT DEFAULT 'cited_only';
-- 'external_upload' (chair uploaded full ruling) | 'cited_only' (stub from
-- references_extractor) | 'nevo_seed' (future: auto-fetched from Nevo).
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS document_id UUID REFERENCES documents(id) ON DELETE SET NULL;
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS extraction_status TEXT DEFAULT 'pending';
-- 'pending' | 'processing' | 'completed' | 'failed'
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS halacha_extraction_status TEXT DEFAULT 'pending';
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS practice_area TEXT DEFAULT '';
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS appeal_subtype TEXT DEFAULT '';
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS headnote TEXT DEFAULT '';
-- chair-editable abstract shown in search results.
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS source_type TEXT DEFAULT '';
-- 'court_ruling' | 'appeals_committee'
-- practice_area is closed to the three appeals committee domains.
DO $$ BEGIN
ALTER TABLE case_law ADD CONSTRAINT case_law_practice_area_check
CHECK (practice_area IN ('', 'rishuy_uvniya', 'betterment_levy', 'compensation_197'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE INDEX IF NOT EXISTS idx_case_law_source_kind ON case_law(source_kind);
CREATE INDEX IF NOT EXISTS idx_case_law_practice ON case_law(practice_area, appeal_subtype);
-- precedent_chunks: full-text chunks of an uploaded ruling, with embeddings.
-- Analog of document_chunks for case_law rows where source_kind='external_upload'.
CREATE TABLE IF NOT EXISTS precedent_chunks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
section_type TEXT DEFAULT 'other',
-- intro | facts | legal_analysis | ruling | conclusion | other
page_number INTEGER,
embedding vector(1024),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_precedent_chunks_case_law ON precedent_chunks(case_law_id);
CREATE INDEX IF NOT EXISTS idx_precedent_chunks_section ON precedent_chunks(case_law_id, section_type);
CREATE INDEX IF NOT EXISTS idx_precedent_chunks_vec
ON precedent_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 50);
-- halachot: extracted binding rules. One halacha = one rule + verbatim quote.
-- Embedded separately for rule-precision semantic match (chunks centroid is
-- dominated by surrounding context). All halachot start as pending_review;
-- only approved/published rows are visible to search_precedent_library.
CREATE TABLE IF NOT EXISTS halachot (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE,
halacha_index INTEGER NOT NULL,
rule_statement TEXT NOT NULL,
rule_type TEXT DEFAULT 'binding',
-- binding | interpretive | procedural | obiter
reasoning_summary TEXT DEFAULT '',
supporting_quote TEXT NOT NULL,
page_reference TEXT DEFAULT '',
practice_areas TEXT[] DEFAULT '{}',
subject_tags TEXT[] DEFAULT '{}',
cites TEXT[] DEFAULT '{}',
confidence NUMERIC(3,2) DEFAULT 0.0,
quote_verified BOOLEAN DEFAULT FALSE,
review_status TEXT DEFAULT 'pending_review',
-- pending_review | approved | rejected | published
reviewer TEXT DEFAULT '',
reviewed_at TIMESTAMPTZ,
embedding vector(1024),
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_halachot_case_law ON halachot(case_law_id);
CREATE INDEX IF NOT EXISTS idx_halachot_status ON halachot(review_status);
CREATE INDEX IF NOT EXISTS idx_halachot_practice ON halachot USING gin(practice_areas);
CREATE INDEX IF NOT EXISTS idx_halachot_tags ON halachot USING gin(subject_tags);
CREATE INDEX IF NOT EXISTS idx_halachot_vec
ON halachot USING ivfflat (embedding vector_cosine_ops) WITH (lists = 50);
"""
async def init_schema() -> None: async def init_schema() -> None:
pool = await get_pool() pool = await get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
@@ -528,7 +613,8 @@ async def init_schema() -> None:
await conn.execute(SCHEMA_V4_SQL) await conn.execute(SCHEMA_V4_SQL)
await conn.execute(SCHEMA_V5_SQL) await conn.execute(SCHEMA_V5_SQL)
await conn.execute(SCHEMA_V6_SQL) await conn.execute(SCHEMA_V6_SQL)
logger.info("Database schema initialized (v1-v6)") await conn.execute(SCHEMA_V7_SQL)
logger.info("Database schema initialized (v1-v7)")
# ── Case CRUD ─────────────────────────────────────────────────────── # ── Case CRUD ───────────────────────────────────────────────────────
@@ -1518,3 +1604,590 @@ async def detect_appraiser_conflicts(case_id: UUID) -> list[dict]:
"entries": entries, "entries": entries,
}) })
return conflicts return conflicts
# ── V7: External precedent library + halachot ─────────────────────
def _row_to_case_law(row: asyncpg.Record) -> dict:
"""Normalize a case_law row, parsing subject_tags JSONB to list."""
d = dict(row)
if isinstance(d.get("subject_tags"), str):
try:
d["subject_tags"] = json.loads(d["subject_tags"])
except (TypeError, ValueError):
d["subject_tags"] = []
if d.get("date") is not None:
d["date"] = d["date"].isoformat()
return d
async def get_case_law(case_law_id: UUID) -> dict | None:
pool = await get_pool()
row = await pool.fetchrow(
"SELECT * FROM case_law WHERE id = $1", case_law_id,
)
return _row_to_case_law(row) if row else None
async def get_case_law_by_citation(case_number: str) -> dict | None:
pool = await get_pool()
row = await pool.fetchrow(
"SELECT * FROM case_law WHERE case_number = $1", case_number,
)
return _row_to_case_law(row) if row else None
async def create_external_case_law(
case_number: str,
case_name: str,
full_text: str,
court: str = "",
decision_date: date | None = None,
practice_area: str = "",
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
summary: str = "",
headnote: str = "",
key_quote: str = "",
source_url: str = "",
source_type: str = "",
precedent_level: str = "",
is_binding: bool = True,
document_id: UUID | None = None,
) -> dict:
"""Insert a chair-uploaded external precedent into case_law.
If a row with this ``case_number`` already exists with
source_kind='cited_only' (auto-discovered), promote it to
source_kind='external_upload' and fill in the missing fields.
"""
pool = await get_pool()
tags_json = json.dumps(subject_tags or [], ensure_ascii=False)
async with pool.acquire() as conn:
existing = await conn.fetchrow(
"SELECT id, source_kind FROM case_law WHERE case_number = $1",
case_number,
)
if existing:
row = await conn.fetchrow(
"""
UPDATE case_law SET
case_name = $2,
court = COALESCE(NULLIF($3, ''), court),
date = COALESCE($4, date),
practice_area = $5,
appeal_subtype = $6,
subject_tags = $7,
summary = COALESCE(NULLIF($8, ''), summary),
headnote = $9,
key_quote = COALESCE(NULLIF($10, ''), key_quote),
full_text = $11,
source_url = COALESCE(NULLIF($12, ''), source_url),
source_type = $13,
precedent_level = $14,
is_binding = $15,
document_id = COALESCE($16, document_id),
source_kind = 'external_upload',
extraction_status = 'processing',
halacha_extraction_status = 'pending'
WHERE id = $1
RETURNING *
""",
existing["id"], case_name, court, decision_date,
practice_area, appeal_subtype, tags_json, summary, headnote,
key_quote, full_text, source_url, source_type,
precedent_level, is_binding, document_id,
)
else:
row = await conn.fetchrow(
"""
INSERT INTO case_law (
case_number, case_name, court, date, subject_tags,
summary, key_quote, full_text, source_url,
source_kind, document_id, extraction_status,
halacha_extraction_status, practice_area, appeal_subtype,
headnote, source_type, precedent_level, is_binding
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
'external_upload', $10, 'processing', 'pending',
$11, $12, $13, $14, $15, $16
)
RETURNING *
""",
case_number, case_name, court, decision_date, tags_json,
summary, key_quote, full_text, source_url,
document_id, practice_area, appeal_subtype, headnote,
source_type, precedent_level, is_binding,
)
return _row_to_case_law(row)
async def update_case_law(case_law_id: UUID, **fields) -> dict | None:
"""Patch metadata fields on a case_law row.
Allowed fields: case_name, court, date, practice_area, appeal_subtype,
subject_tags, summary, headnote, key_quote, source_url, source_type,
precedent_level, is_binding.
"""
allowed = {
"case_name", "court", "date", "practice_area", "appeal_subtype",
"subject_tags", "summary", "headnote", "key_quote", "source_url",
"source_type", "precedent_level", "is_binding",
}
updates = {k: v for k, v in fields.items() if k in allowed}
if not updates:
return await get_case_law(case_law_id)
pool = await get_pool()
set_parts = []
params: list = [case_law_id]
for i, (k, v) in enumerate(updates.items(), start=2):
if k == "subject_tags":
v = json.dumps(v or [], ensure_ascii=False)
set_parts.append(f"{k} = ${i}")
params.append(v)
sql = f"UPDATE case_law SET {', '.join(set_parts)} WHERE id = $1 RETURNING *"
row = await pool.fetchrow(sql, *params)
return _row_to_case_law(row) if row else None
async def set_case_law_extraction_status(case_law_id: UUID, status: str) -> None:
pool = await get_pool()
await pool.execute(
"UPDATE case_law SET extraction_status = $2 WHERE id = $1",
case_law_id, status,
)
async def set_case_law_halacha_status(case_law_id: UUID, status: str) -> None:
pool = await get_pool()
await pool.execute(
"UPDATE case_law SET halacha_extraction_status = $2 WHERE id = $1",
case_law_id, status,
)
async def list_external_case_law(
practice_area: str = "",
court: str = "",
precedent_level: str = "",
source_type: str = "",
search: str = "",
limit: int = 100,
offset: int = 0,
) -> list[dict]:
"""List chair-uploaded precedents, with simple filters."""
pool = await get_pool()
conditions = ["source_kind = 'external_upload'"]
params: list = []
idx = 1
if practice_area:
conditions.append(f"practice_area = ${idx}")
params.append(practice_area)
idx += 1
if court:
conditions.append(f"court ILIKE ${idx}")
params.append(f"%{court}%")
idx += 1
if precedent_level:
conditions.append(f"precedent_level = ${idx}")
params.append(precedent_level)
idx += 1
if source_type:
conditions.append(f"source_type = ${idx}")
params.append(source_type)
idx += 1
if search:
conditions.append(
f"(case_number ILIKE ${idx} OR case_name ILIKE ${idx} "
f"OR summary ILIKE ${idx} OR headnote ILIKE ${idx})"
)
params.append(f"%{search}%")
idx += 1
where_sql = " AND ".join(conditions)
params.extend([limit, offset])
sql = f"""
SELECT id, case_number, case_name, court, date, practice_area,
appeal_subtype, source_type, precedent_level, is_binding,
summary, headnote, subject_tags, source_kind,
extraction_status, halacha_extraction_status,
created_at,
(SELECT COUNT(*) FROM halachot h WHERE h.case_law_id = case_law.id) AS halachot_count,
(SELECT COUNT(*) FROM halachot h WHERE h.case_law_id = case_law.id
AND h.review_status IN ('approved', 'published')) AS approved_count
FROM case_law
WHERE {where_sql}
ORDER BY created_at DESC
LIMIT ${idx} OFFSET ${idx + 1}
"""
rows = await pool.fetch(sql, *params)
return [_row_to_case_law(r) for r in rows]
async def delete_case_law(case_law_id: UUID) -> bool:
"""Delete a precedent and cascade chunks + halachot."""
pool = await get_pool()
result = await pool.execute(
"DELETE FROM case_law WHERE id = $1", case_law_id,
)
return result == "DELETE 1"
async def store_precedent_chunks(
case_law_id: UUID, chunks: list[dict],
) -> int:
"""Replace precedent chunks for a case_law row.
Each chunk dict has: chunk_index, content, section_type, page_number,
embedding (list[float] or None).
"""
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute(
"DELETE FROM precedent_chunks WHERE case_law_id = $1",
case_law_id,
)
for c in chunks:
await conn.execute(
"""INSERT INTO precedent_chunks
(case_law_id, chunk_index, content, section_type,
page_number, embedding)
VALUES ($1, $2, $3, $4, $5, $6)""",
case_law_id,
c["chunk_index"],
c["content"],
c.get("section_type", "other"),
c.get("page_number"),
c.get("embedding"),
)
return len(chunks)
async def list_precedent_chunks(
case_law_id: UUID,
section_types: tuple[str, ...] | None = None,
) -> list[dict]:
pool = await get_pool()
if section_types:
rows = await pool.fetch(
"""SELECT id, chunk_index, content, section_type, page_number
FROM precedent_chunks
WHERE case_law_id = $1 AND section_type = ANY($2::text[])
ORDER BY chunk_index""",
case_law_id, list(section_types),
)
else:
rows = await pool.fetch(
"""SELECT id, chunk_index, content, section_type, page_number
FROM precedent_chunks
WHERE case_law_id = $1
ORDER BY chunk_index""",
case_law_id,
)
return [dict(r) for r in rows]
async def delete_halachot(case_law_id: UUID) -> int:
pool = await get_pool()
result = await pool.execute(
"DELETE FROM halachot WHERE case_law_id = $1", case_law_id,
)
# result is e.g. "DELETE 5" — extract the number.
try:
return int(result.split()[-1])
except (ValueError, IndexError):
return 0
async def store_halachot(case_law_id: UUID, halachot: list[dict]) -> int:
"""Bulk-insert extracted halachot. Always with review_status='pending_review'."""
if not halachot:
return 0
pool = await get_pool()
async with pool.acquire() as conn:
for i, h in enumerate(halachot):
await conn.execute(
"""INSERT INTO halachot
(case_law_id, halacha_index, rule_statement, rule_type,
reasoning_summary, supporting_quote, page_reference,
practice_areas, subject_tags, cites, confidence,
quote_verified, embedding, review_status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
$12, $13, 'pending_review')""",
case_law_id,
i,
h["rule_statement"],
h.get("rule_type", "binding"),
h.get("reasoning_summary", ""),
h["supporting_quote"],
h.get("page_reference", ""),
h.get("practice_areas", []),
h.get("subject_tags", []),
h.get("cites", []),
h.get("confidence", 0.0),
h.get("quote_verified", False),
h.get("embedding"),
)
return len(halachot)
async def list_halachot(
case_law_id: UUID | None = None,
review_status: str | None = None,
practice_area: str | None = None,
limit: int = 200,
offset: int = 0,
) -> list[dict]:
pool = await get_pool()
conditions = []
params: list = []
idx = 1
if case_law_id is not None:
conditions.append(f"h.case_law_id = ${idx}")
params.append(case_law_id)
idx += 1
if review_status:
conditions.append(f"h.review_status = ${idx}")
params.append(review_status)
idx += 1
if practice_area:
conditions.append(f"${idx} = ANY(h.practice_areas)")
params.append(practice_area)
idx += 1
where_sql = f"WHERE {' AND '.join(conditions)}" if conditions else ""
params.extend([limit, offset])
sql = f"""
SELECT h.id, h.case_law_id, h.halacha_index, h.rule_statement,
h.rule_type, h.reasoning_summary, h.supporting_quote,
h.page_reference, h.practice_areas, h.subject_tags,
h.cites, h.confidence, h.quote_verified, h.review_status,
h.reviewer, h.reviewed_at, h.created_at, h.updated_at,
cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
cl.precedent_level
FROM halachot h
LEFT JOIN case_law cl ON cl.id = h.case_law_id
{where_sql}
ORDER BY h.case_law_id, h.halacha_index
LIMIT ${idx} OFFSET ${idx + 1}
"""
rows = await pool.fetch(sql, *params)
out = []
for r in rows:
d = dict(r)
if d.get("decision_date") is not None:
d["decision_date"] = d["decision_date"].isoformat()
out.append(d)
return out
async def update_halacha(
halacha_id: UUID,
review_status: str | None = None,
reviewer: str = "",
rule_statement: str | None = None,
reasoning_summary: str | None = None,
subject_tags: list[str] | None = None,
practice_areas: list[str] | None = None,
) -> dict | None:
"""Update a halacha — used by the chair to approve/reject/edit."""
pool = await get_pool()
set_parts: list[str] = []
params: list = [halacha_id]
idx = 2
if review_status is not None:
set_parts.append(f"review_status = ${idx}")
params.append(review_status)
idx += 1
if review_status in ("approved", "rejected", "published"):
set_parts.append(f"reviewed_at = now()")
set_parts.append(f"reviewer = ${idx}")
params.append(reviewer)
idx += 1
if rule_statement is not None:
set_parts.append(f"rule_statement = ${idx}")
params.append(rule_statement)
idx += 1
if reasoning_summary is not None:
set_parts.append(f"reasoning_summary = ${idx}")
params.append(reasoning_summary)
idx += 1
if subject_tags is not None:
set_parts.append(f"subject_tags = ${idx}")
params.append(subject_tags)
idx += 1
if practice_areas is not None:
set_parts.append(f"practice_areas = ${idx}")
params.append(practice_areas)
idx += 1
if not set_parts:
return None
set_parts.append("updated_at = now()")
sql = f"UPDATE halachot SET {', '.join(set_parts)} WHERE id = $1 RETURNING *"
row = await pool.fetchrow(sql, *params)
return dict(row) if row else None
async def search_precedent_library_semantic(
query_embedding: list[float],
practice_area: str = "",
court: str = "",
precedent_level: str = "",
appeal_subtype: str = "",
is_binding: bool | None = None,
subject_tag: str = "",
limit: int = 10,
include_halachot: bool = True,
) -> list[dict]:
"""Semantic search over chair-uploaded precedents.
Returns merged halachot + chunks. Halachot are pre-distilled rules, so
they get a small score boost. Only ``approved`` / ``published`` halachot
are visible (per chair-review policy). Chunks are visible regardless
of halacha review status.
"""
pool = await get_pool()
halacha_filters = ["h.review_status IN ('approved', 'published')"]
chunk_filters = ["cl.source_kind = 'external_upload'"]
h_params: list = [query_embedding, limit]
c_params: list = [query_embedding, limit]
h_idx = 3
c_idx = 3
if practice_area:
halacha_filters.append(f"${h_idx} = ANY(h.practice_areas)")
h_params.append(practice_area)
h_idx += 1
chunk_filters.append(f"cl.practice_area = ${c_idx}")
c_params.append(practice_area)
c_idx += 1
if court:
halacha_filters.append(f"cl.court ILIKE ${h_idx}")
h_params.append(f"%{court}%")
h_idx += 1
chunk_filters.append(f"cl.court ILIKE ${c_idx}")
c_params.append(f"%{court}%")
c_idx += 1
if precedent_level:
halacha_filters.append(f"cl.precedent_level = ${h_idx}")
h_params.append(precedent_level)
h_idx += 1
chunk_filters.append(f"cl.precedent_level = ${c_idx}")
c_params.append(precedent_level)
c_idx += 1
if appeal_subtype:
halacha_filters.append(f"cl.appeal_subtype = ${h_idx}")
h_params.append(appeal_subtype)
h_idx += 1
chunk_filters.append(f"cl.appeal_subtype = ${c_idx}")
c_params.append(appeal_subtype)
c_idx += 1
if is_binding is not None:
halacha_filters.append(f"cl.is_binding = ${h_idx}")
h_params.append(is_binding)
h_idx += 1
chunk_filters.append(f"cl.is_binding = ${c_idx}")
c_params.append(is_binding)
c_idx += 1
if subject_tag:
halacha_filters.append(f"${h_idx} = ANY(h.subject_tags)")
h_params.append(subject_tag)
h_idx += 1
halacha_sql = f"""
SELECT h.id AS halacha_id, h.case_law_id, h.rule_statement,
h.reasoning_summary, h.supporting_quote, h.page_reference,
h.practice_areas, h.subject_tags, h.confidence, h.rule_type,
cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
cl.precedent_level,
1 - (h.embedding <=> $1) AS score
FROM halachot h
JOIN case_law cl ON cl.id = h.case_law_id
WHERE {' AND '.join(halacha_filters)}
AND h.embedding IS NOT NULL
ORDER BY h.embedding <=> $1
LIMIT $2
"""
chunk_sql = f"""
SELECT pc.id AS chunk_id, pc.case_law_id, pc.content,
pc.section_type, pc.page_number,
cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
cl.precedent_level, cl.practice_area,
1 - (pc.embedding <=> $1) AS score
FROM precedent_chunks pc
JOIN case_law cl ON cl.id = pc.case_law_id
WHERE {' AND '.join(chunk_filters)}
AND pc.embedding IS NOT NULL
ORDER BY pc.embedding <=> $1
LIMIT $2
"""
results: list[dict] = []
if include_halachot:
rows = await pool.fetch(halacha_sql, *h_params)
for r in rows:
d = dict(r)
if d.get("decision_date") is not None:
d["decision_date"] = d["decision_date"].isoformat()
d["score"] = float(d["score"]) + 0.05 # rule-level boost
d["type"] = "halacha"
results.append(d)
rows = await pool.fetch(chunk_sql, *c_params)
for r in rows:
d = dict(r)
if d.get("decision_date") is not None:
d["decision_date"] = d["decision_date"].isoformat()
d["score"] = float(d["score"])
d["type"] = "passage"
results.append(d)
results.sort(key=lambda x: x["score"], reverse=True)
return results[:limit]
async def precedent_library_stats() -> dict:
"""Aggregate stats for the /precedents stats tab."""
pool = await get_pool()
async with pool.acquire() as conn:
total = await conn.fetchval(
"SELECT COUNT(*) FROM case_law WHERE source_kind = 'external_upload'"
)
by_practice = await conn.fetch(
"""SELECT practice_area, COUNT(*) AS n
FROM case_law
WHERE source_kind = 'external_upload'
GROUP BY practice_area
ORDER BY n DESC"""
)
by_level = await conn.fetch(
"""SELECT precedent_level, COUNT(*) AS n
FROM case_law
WHERE source_kind = 'external_upload'
GROUP BY precedent_level
ORDER BY n DESC"""
)
halachot_total = await conn.fetchval(
"SELECT COUNT(*) FROM halachot"
)
halachot_pending = await conn.fetchval(
"SELECT COUNT(*) FROM halachot WHERE review_status = 'pending_review'"
)
halachot_approved = await conn.fetchval(
"SELECT COUNT(*) FROM halachot WHERE review_status IN ('approved', 'published')"
)
return {
"precedents_total": int(total or 0),
"by_practice_area": [
{"practice_area": r["practice_area"], "count": int(r["n"])}
for r in by_practice
],
"by_precedent_level": [
{"precedent_level": r["precedent_level"], "count": int(r["n"])}
for r in by_level
],
"halachot_total": int(halachot_total or 0),
"halachot_pending": int(halachot_pending or 0),
"halachot_approved": int(halachot_approved or 0),
}

View File

@@ -0,0 +1,326 @@
"""Extract binding legal rules (הלכות) from external court rulings.
Runs Claude (via the local headless ``claude -p`` bridge) over the
legal_analysis / ruling / conclusion chunks of a precedent, returns a
structured list of halachot, validates each one against the source text,
embeds the rule statement, and stores everything as ``pending_review`` in
the ``halachot`` table.
All extraction is idempotent — calling ``extract(case_law_id)`` twice
deletes prior rows for that precedent first.
Trust model:
Per chair decision, NO halacha is auto-published. Every extracted
halacha enters with ``review_status='pending_review'``. The chair
approves/rejects via the UI, and only ``approved`` (or ``published``)
rows are visible to ``search_precedent_library`` and the writing
agents.
"""
from __future__ import annotations
import asyncio
import logging
import re
from uuid import UUID
from legal_mcp import config
from legal_mcp.config import parse_llm_json
from legal_mcp.services import claude_session, db, embeddings, proofreader
logger = logging.getLogger(__name__)
# Concurrency model mirrors claims_extractor — each ``claude -p`` subprocess
# holds ~300 MB RSS, so we cap parallel chunks to keep the box healthy.
CHUNK_CONCURRENCY = 3
CHUNK_RETRY_ATTEMPTS = 1
# Sections from which to extract. facts/intro/appellant_claims/respondent_claims
# never contain holdings, only positions, so we skip them.
EXTRACTABLE_SECTIONS = ("legal_analysis", "ruling", "conclusion")
HALACHA_EXTRACTION_PROMPT = """אתה משפטן בכיר המתמחה בדיני תכנון ובניה (ועדות ערר, היטל השבחה, פיצויים לפי סעיף 197 לחוק התכנון והבניה). תפקידך: לחלץ הלכות מחייבות מתוך פסק דין/החלטה משפטית.
## הגדרות מחייבות
הלכה (binding rule) = כלל משפטי שהפסק קובע או מאמץ ומיישם, באופן שניתן להסתמך עליו בהחלטות עתידיות.
לא-הלכה (אין לחלץ):
- אמרת אגב (obiter dicta) — הערות שאינן הכרחיות להכרעה.
- ממצאים עובדתיים ספציפיים לתיק ("העורר לא הוכיח X").
- ציטוטי הלכות מפסקי דין אחרים שלא אומצו במפורש בפסק זה.
- הצהרות על דין קיים שאינן מיושמות בהכרעה.
הבחנה קריטית: כאשר הפסק מצטט הלכה מפסק קודם, חלץ אותה רק אם בית המשפט בפסק הנוכחי **מאמץ ומחיל** אותה (לא רק מזכיר אותה ברקע).
## תחומים אפשריים (practice_areas) — תחומי ועדת הערר בלבד
- rishuy_uvniya — רישוי ובניה (תיקי 1xxx: היתרים, שימוש חורג, תכניות, קווי בניין, גובה, חניה)
- betterment_levy — היטל השבחה (תיקי 8xxx: שומה, מערכות, תכניות המקנות בה, מועד קובע, סופיות ההחלטה)
- compensation_197 — פיצויים לפי ס' 197 (תיקי 9xxx: פגיעה במקרקעין, ירידת ערך, ס' 200/פטור)
הלכה אחת יכולה לחול על כמה תחומים — practice_areas הוא array ולא string יחיד.
## סוגי הלכה (rule_type)
- binding — הלכה מחייבת שהוחלה על התיק.
- interpretive — פרשנות סעיף חוק/תכנית שאומצה.
- procedural — כלל פרוצדורלי (סמכות, מועדים, הליכי שמיעה).
- obiter — אמרת אגב חשובה (חלץ רק אם משמעותית; סמן confidence נמוך).
## פלט נדרש
החזר JSON array בלבד, ללא markdown, ללא הסברים. דוגמה:
[
{
"rule_statement": "ניסוח הכלל בלשון משפטית מדויקת בגוף שלישי, 1-3 משפטים.",
"rule_type": "binding",
"reasoning_summary": "תמצית ההיגיון: למה בית המשפט הגיע לכלל הזה (1-2 משפטים).",
"supporting_quote": "ציטוט מילולי מדויק מהפסק התומך בכלל. חייב להופיע מילה במילה בטקסט הקלט.",
"page_reference": "פס' 12 / עמ' 8 — ככל שניתן לזהות מהקלט.",
"practice_areas": ["betterment_levy"],
"subject_tags": ["מועד_קביעת_שומה", "סופיות_ההחלטה"],
"cites": ["עע\\"מ 3975/22"],
"confidence": 0.85
}
]
## כללי איכות
1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תמציא הלכה.
2. **מספר הלכות** — פסק רגיל מכיל 1-4 הלכות מחייבות. אל תמתח את הרשימה. אם אין הלכה — החזר [].
3. **לא לפצל יתר על המידה** — אם שני סעיפים מבטאים את אותו עיקרון, אחד את הניסוח.
4. **שפה** — rule_statement בעברית משפטית מקצועית, לא צמצום מילולי של הציטוט.
5. **subject_tags** — 2-5 תגיות בעברית, snake_case (חניה, קווי_בניין, שיקול_דעת, פגם_פרוצדורלי, סמכות, מועדים, פגיעה_במקרקעין, ירידת_ערך).
6. **confidence** — 0..1. מתחת ל-0.7 = ספק לגבי היות זה הלכה מחייבת.
"""
_VALID_PRACTICE_AREAS = {"rishuy_uvniya", "betterment_levy", "compensation_197"}
_VALID_RULE_TYPES = {"binding", "interpretive", "procedural", "obiter"}
def _normalize_for_comparison(text: str) -> str:
"""Normalize Hebrew text for substring matching.
Collapses whitespace and unifies the half-dozen Hebrew quote-mark
variants. Use ``proofreader._fix_hebrew_quotes`` for the quote part
so we stay consistent with the proofreader pipeline.
"""
fixed = proofreader._fix_hebrew_quotes(text)
# Collapse all whitespace (newlines, tabs, multiple spaces) to a single space.
return re.sub(r"\s+", " ", fixed).strip()
def _verify_quote(supporting_quote: str, full_text: str) -> bool:
"""Return True if ``supporting_quote`` appears verbatim in ``full_text``
after Hebrew quote/whitespace normalization.
The LLM occasionally trims a leading/trailing word from the quote;
we accept the quote if at least 90% of its characters match a
contiguous substring of the source.
"""
if not supporting_quote.strip():
return False
normalized_quote = _normalize_for_comparison(supporting_quote)
normalized_text = _normalize_for_comparison(full_text)
if not normalized_quote:
return False
if normalized_quote in normalized_text:
return True
# Fallback: try the inner 90% of the quote (drops boundary trim).
if len(normalized_quote) >= 30:
trim = max(2, len(normalized_quote) // 20)
inner = normalized_quote[trim:-trim]
if inner and inner in normalized_text:
return True
return False
def _coerce_halacha(raw: dict) -> dict | None:
"""Validate and normalize one LLM-returned halacha dict.
Returns ``None`` if the entry is missing required fields.
"""
if not isinstance(raw, dict):
return None
rule_statement = (raw.get("rule_statement") or "").strip()
supporting_quote = (raw.get("supporting_quote") or "").strip()
if not rule_statement or not supporting_quote:
return None
rule_type = (raw.get("rule_type") or "binding").strip().lower()
if rule_type not in _VALID_RULE_TYPES:
rule_type = "binding"
practice_areas_raw = raw.get("practice_areas") or []
if isinstance(practice_areas_raw, str):
practice_areas_raw = [practice_areas_raw]
practice_areas = [p for p in practice_areas_raw if p in _VALID_PRACTICE_AREAS]
subject_tags_raw = raw.get("subject_tags") or []
if isinstance(subject_tags_raw, str):
subject_tags_raw = [subject_tags_raw]
subject_tags = [str(t).strip() for t in subject_tags_raw if str(t).strip()]
cites_raw = raw.get("cites") or []
if isinstance(cites_raw, str):
cites_raw = [cites_raw]
cites = [str(c).strip() for c in cites_raw if str(c).strip()]
try:
confidence = float(raw.get("confidence", 0.0))
except (TypeError, ValueError):
confidence = 0.0
confidence = max(0.0, min(1.0, confidence))
return {
"rule_statement": rule_statement,
"rule_type": rule_type,
"reasoning_summary": (raw.get("reasoning_summary") or "").strip(),
"supporting_quote": supporting_quote,
"page_reference": (raw.get("page_reference") or "").strip(),
"practice_areas": practice_areas,
"subject_tags": subject_tags,
"cites": cites,
"confidence": confidence,
}
async def _extract_chunk(
chunk_text: str,
section_type: str,
chunk_index: int,
chunk_total: int,
context: str,
) -> list[dict]:
"""Run the halacha extractor on one chunk with retry."""
chunk_label = f" (חלק {chunk_index + 1}/{chunk_total})" if chunk_total > 1 else ""
prompt = (
f"{HALACHA_EXTRACTION_PROMPT}\n\n"
f"## הקלט\n"
f"סוג קטע: {section_type}\n"
f"{context}{chunk_label}\n\n"
f"--- תחילת הטקסט ---\n{chunk_text}\n--- סוף הטקסט ---"
)
last_err: Exception | None = None
for attempt in range(CHUNK_RETRY_ATTEMPTS + 1):
try:
result = await claude_session.query_json(prompt)
except Exception as e:
last_err = e
logger.warning(
"halacha_extractor chunk %d/%d attempt %d raised: %s",
chunk_index + 1, chunk_total, attempt + 1, e,
)
continue
if isinstance(result, list):
return result
logger.warning(
"halacha_extractor chunk %d/%d attempt %d returned non-list (%s)",
chunk_index + 1, chunk_total, attempt + 1, type(result).__name__,
)
logger.error(
"halacha_extractor chunk %d/%d failed after %d attempts: %s",
chunk_index + 1, chunk_total, CHUNK_RETRY_ATTEMPTS + 1, last_err,
)
return []
async def extract(case_law_id: UUID | str) -> dict:
"""Extract halachot from an uploaded precedent and store them.
Idempotent: replaces any existing halachot for this case_law_id.
All inserted rows start as ``review_status='pending_review'``.
Returns:
``{"status": "...", "extracted": N, "verified": M, "stored": K, ...}``
"""
if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id)
record = await db.get_case_law(case_law_id)
if not record:
return {"status": "not_found", "extracted": 0, "stored": 0}
chunks = await db.list_precedent_chunks(
case_law_id, section_types=EXTRACTABLE_SECTIONS,
)
if not chunks:
await db.set_case_law_halacha_status(case_law_id, "completed")
return {"status": "no_chunks", "extracted": 0, "stored": 0}
await db.set_case_law_halacha_status(case_law_id, "processing")
await db.delete_halachot(case_law_id)
citation = record.get("case_number", "")
court = record.get("court", "")
date_str = str(record.get("date") or "")
context = f"מקור: {citation}{court}, {date_str}"
sem = asyncio.Semaphore(CHUNK_CONCURRENCY)
async def _bounded(idx: int, chunk_row: dict) -> list[dict]:
async with sem:
return await _extract_chunk(
chunk_row["content"], chunk_row["section_type"],
idx, len(chunks), context,
)
chunk_results = await asyncio.gather(
*[_bounded(i, c) for i, c in enumerate(chunks)]
)
raw_halachot: list[dict] = []
for items in chunk_results:
raw_halachot.extend(items)
if not raw_halachot:
await db.set_case_law_halacha_status(case_law_id, "completed")
return {"status": "no_halachot", "extracted": 0, "stored": 0}
# Validate against the full text of the precedent for the quote check.
full_text = record.get("full_text") or ""
cleaned: list[dict] = []
for raw in raw_halachot:
coerced = _coerce_halacha(raw)
if coerced is None:
continue
coerced["quote_verified"] = _verify_quote(
coerced["supporting_quote"], full_text,
)
cleaned.append(coerced)
if not cleaned:
await db.set_case_law_halacha_status(case_law_id, "completed")
return {"status": "no_valid_halachot", "extracted": len(raw_halachot), "stored": 0}
# Embed rule_statement + reasoning_summary so semantic search hits the
# rule directly rather than the surrounding chunk centroid.
embed_inputs = [
f"{h['rule_statement']}{h['reasoning_summary']}".strip("")
for h in cleaned
]
try:
vectors = await embeddings.embed_texts(embed_inputs, input_type="document")
except Exception as e:
logger.error("halacha_extractor: embeddings failed: %s", e)
vectors = [None] * len(cleaned)
for halacha, vec in zip(cleaned, vectors):
halacha["embedding"] = vec
stored = await db.store_halachot(case_law_id, cleaned)
verified = sum(1 for h in cleaned if h["quote_verified"])
await db.set_case_law_halacha_status(case_law_id, "completed")
logger.info(
"halacha_extractor: case_law=%s extracted=%d cleaned=%d verified=%d stored=%d",
case_law_id, len(raw_halachot), len(cleaned), verified, stored,
)
return {
"status": "completed",
"extracted": len(raw_halachot),
"valid": len(cleaned),
"verified": verified,
"stored": stored,
}

View File

@@ -0,0 +1,309 @@
"""Orchestrator for the External Precedent Library.
Ingest pipeline (one upload):
file → extract_text → proofread → INSERT case_law (source_kind='external_upload')
→ chunk → embed → store precedent_chunks
→ halacha_extractor.extract → embed halachot → store halachot
→ set extraction_status='completed'
Progress is reported via a caller-supplied async callback so the
web layer can pipe updates into the existing Redis ProgressStore /
SSE plumbing without this module knowing about Redis.
"""
from __future__ import annotations
import logging
import re
import shutil
from datetime import date
from pathlib import Path
from typing import Awaitable, Callable
from uuid import UUID, uuid4
from legal_mcp import config
from legal_mcp.services import (
chunker,
db,
embeddings,
extractor,
halacha_extractor,
)
logger = logging.getLogger(__name__)
ProgressCb = Callable[[str, int, str], Awaitable[None]]
PRECEDENT_LIBRARY_DIR = Path(config.DATA_DIR) / "precedent-library"
_VALID_PRACTICE_AREAS = {"", "rishuy_uvniya", "betterment_levy", "compensation_197"}
_VALID_SOURCE_TYPES = {"", "court_ruling", "appeals_committee"}
_VALID_PRECEDENT_LEVELS = {
"", "עליון", "מנהלי", "ועדת_ערר_ארצית", "ועדת_ערר_מחוזית",
"supreme", "administrative", "national_appeals_committee", "district_appeals_committee",
}
async def _noop_progress(_status: str, _percent: int, _msg: str) -> None:
return None
def _safe_filename(name: str) -> str:
"""Strip path separators and unsafe chars from a user-provided name."""
base = Path(name).name
return re.sub(r"[^\w.\-+א-ת ]", "_", base) or f"upload-{uuid4().hex[:8]}"
def _stage_file(src_path: Path, source_type: str) -> Path:
"""Copy the uploaded file into data/precedent-library/<source_type>/.
Returns the destination path. Source file is not deleted (caller decides).
"""
sub = source_type if source_type in {"court_ruling", "appeals_committee"} else "other"
dest_dir = PRECEDENT_LIBRARY_DIR / sub
dest_dir.mkdir(parents=True, exist_ok=True)
safe_name = _safe_filename(src_path.name)
dest = dest_dir / f"{uuid4().hex[:8]}_{safe_name}"
shutil.copy2(src_path, dest)
return dest
def _coerce_date(value) -> date | None:
if value is None or value == "":
return None
if isinstance(value, date):
return value
if isinstance(value, str):
try:
return date.fromisoformat(value[:10])
except ValueError:
return None
return None
async def ingest_precedent(
*,
file_path: str | Path,
citation: str,
case_name: str = "",
court: str = "",
decision_date=None,
source_type: str = "",
precedent_level: str = "",
practice_area: str = "",
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
is_binding: bool = True,
headnote: str = "",
summary: str = "",
document_id: UUID | None = None,
progress: ProgressCb | None = None,
) -> dict:
"""Ingest a single uploaded precedent through the full pipeline.
Required: file_path + citation. Everything else has a sensible default.
Returns:
``{"status": "...", "case_law_id": "...", "chunks": N, "halachot": M}``
"""
progress = progress or _noop_progress
src = Path(file_path)
if not src.is_file():
raise FileNotFoundError(f"file not found: {src}")
if not citation.strip():
raise ValueError("citation is required")
if practice_area not in _VALID_PRACTICE_AREAS:
raise ValueError(f"invalid practice_area: {practice_area!r}")
if source_type not in _VALID_SOURCE_TYPES:
raise ValueError(f"invalid source_type: {source_type!r}")
await progress("staging", 5, "מעתיק את הקובץ לאחסון")
staged = _stage_file(src, source_type)
await progress("extracting", 15, "מחלץ טקסט מהקובץ")
try:
text, page_count = await extractor.extract_text(str(staged))
except Exception as e:
await progress("failed", 100, f"כשל בחילוץ טקסט: {e}")
raise
text = (text or "").strip()
if not text:
await progress("failed", 100, "לא נמצא טקסט בקובץ")
raise ValueError("no extractable text in file")
# Strip any Nevo preamble that might wrap court rulings downloaded from Nevo.
text = extractor.strip_nevo_preamble(text)
await progress("storing_metadata", 25, "שומר את הפסיקה במסד הנתונים")
record = await db.create_external_case_law(
case_number=citation.strip(),
case_name=case_name.strip() or citation.strip(),
full_text=text,
court=court.strip(),
decision_date=_coerce_date(decision_date),
practice_area=practice_area,
appeal_subtype=appeal_subtype.strip(),
subject_tags=list(subject_tags or []),
summary=summary.strip(),
headnote=headnote.strip(),
source_type=source_type,
precedent_level=precedent_level,
is_binding=is_binding,
document_id=document_id,
)
case_law_id = UUID(str(record["id"]))
try:
await progress("chunking", 40, f"מחלק את הטקסט ל-chunks ({page_count} עמ')")
chunks = chunker.chunk_document(text)
if not chunks:
await db.set_case_law_extraction_status(case_law_id, "completed")
await db.set_case_law_halacha_status(case_law_id, "completed")
await progress("completed", 100, "אין טקסט לעיבוד")
return {
"status": "completed",
"case_law_id": str(case_law_id),
"chunks": 0,
"halachot": 0,
}
await progress("embedding", 55, f"מייצר embeddings ל-{len(chunks)} chunks")
chunk_texts = [c.content for c in chunks]
chunk_vectors = await embeddings.embed_texts(chunk_texts, input_type="document")
chunk_dicts = [
{
"chunk_index": c.chunk_index,
"content": c.content,
"section_type": c.section_type,
"page_number": c.page_number,
"embedding": v,
}
for c, v in zip(chunks, chunk_vectors)
]
stored_chunks = await db.store_precedent_chunks(case_law_id, chunk_dicts)
await progress("extracting_halachot", 75, "מחלץ הלכות מחייבות")
await db.set_case_law_extraction_status(case_law_id, "completed")
halacha_result = await halacha_extractor.extract(case_law_id)
await progress(
"completed",
100,
f"הוכנס לספרייה: {stored_chunks} chunks, "
f"{halacha_result.get('stored', 0)} הלכות ממתינות לאישור",
)
return {
"status": "completed",
"case_law_id": str(case_law_id),
"chunks": stored_chunks,
"halachot": halacha_result.get("stored", 0),
"halachot_extracted_raw": halacha_result.get("extracted", 0),
"halachot_verified": halacha_result.get("verified", 0),
"pages": page_count,
}
except Exception as e:
logger.exception("precedent_library.ingest_precedent failed: %s", e)
await db.set_case_law_extraction_status(case_law_id, "failed")
await progress("failed", 100, f"כשל בעיבוד: {e}")
raise
async def reextract_halachot(
case_law_id: UUID | str,
progress: ProgressCb | None = None,
) -> dict:
"""Re-run the halacha extractor on an existing precedent. Idempotent."""
progress = progress or _noop_progress
if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id)
record = await db.get_case_law(case_law_id)
if not record or record.get("source_kind") != "external_upload":
raise ValueError("precedent not found or not chair-uploaded")
await progress("extracting_halachot", 50, "מחלץ הלכות מחדש")
result = await halacha_extractor.extract(case_law_id)
await progress(
"completed",
100,
f"הופקו {result.get('stored', 0)} הלכות (ממתינות לאישור)",
)
return result
async def delete_precedent(case_law_id: UUID | str) -> bool:
"""Delete a precedent and cascade chunks + halachot."""
if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id)
return await db.delete_case_law(case_law_id)
async def get_precedent(case_law_id: UUID | str) -> dict | None:
"""Get a precedent with its halachot attached."""
if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id)
record = await db.get_case_law(case_law_id)
if not record:
return None
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
return record
async def list_precedents(
practice_area: str = "",
court: str = "",
precedent_level: str = "",
source_type: str = "",
search: str = "",
limit: int = 100,
offset: int = 0,
) -> list[dict]:
return await db.list_external_case_law(
practice_area=practice_area,
court=court,
precedent_level=precedent_level,
source_type=source_type,
search=search,
limit=limit,
offset=offset,
)
async def search_library(
query: str,
practice_area: str = "",
court: str = "",
precedent_level: str = "",
appeal_subtype: str = "",
is_binding: bool | None = None,
subject_tag: str = "",
limit: int = 10,
include_halachot: bool = True,
) -> list[dict]:
"""Semantic search merging halachot (rule-level) and chunks (passage-level).
Only ``approved`` / ``published`` halachot are returned, per chair-review
policy. Chunks are returned regardless of halacha review status.
"""
if not query.strip():
return []
query_vec = await embeddings.embed_query(query)
return await db.search_precedent_library_semantic(
query_embedding=query_vec,
practice_area=practice_area,
court=court,
precedent_level=precedent_level,
appeal_subtype=appeal_subtype,
is_binding=is_binding,
subject_tag=subject_tag,
limit=limit,
include_halachot=include_halachot,
)

View File

@@ -0,0 +1,234 @@
"""MCP tools for the External Precedent Library.
This is distinct from:
- ``precedents`` (case_precedents table) — chair-attached quotes scoped to
a specific case section. Use ``precedent_search_library`` for that.
- ``style_corpus`` (Daphna's prior decisions) — searched via
``search_decisions`` for style/voice.
The precedent library is the **authoritative law** corpus: external court
rulings and other appeals committees' decisions, with halachot extracted
and reviewed by the chair.
All halachot enter as ``pending_review`` and are invisible to search until
the chair approves them — per project review policy.
"""
from __future__ import annotations
import json
from uuid import UUID
from legal_mcp.services import db, precedent_library
def _ok(payload) -> str:
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
def _err(msg: str) -> str:
return json.dumps({"error": msg}, ensure_ascii=False)
async def precedent_library_upload(
file_path: str,
citation: str,
case_name: str = "",
court: str = "",
decision_date: str = "",
source_type: str = "",
precedent_level: str = "",
practice_area: str = "",
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
is_binding: bool = True,
headnote: str = "",
summary: str = "",
) -> str:
"""העלאת פסיקה חיצונית לקורפוס הסמכותי + חילוץ הלכות אוטומטי.
Args:
file_path: נתיב מלא לקובץ PDF/DOCX/RTF/TXT/MD.
citation: מראה המקום ("עע\\"מ 3975/22 ב. קרן-נכסים נ' ועדה מקומית").
case_name: שם קצר.
court: ערכאה (עליון / מנהלי / ועדת ערר ארצית / ועדת ערר מחוזית).
decision_date: ISO date (YYYY-MM-DD), אופציונלי.
source_type: court_ruling / appeals_committee.
precedent_level: עליון / מנהלי / ועדת_ערר_ארצית / ועדת_ערר_מחוזית.
practice_area: rishuy_uvniya / betterment_levy / compensation_197.
subject_tags: תגיות נושא (חניה, קווי_בניין, וכד').
Returns: JSON עם case_law_id, מספר chunks, מספר הלכות שנכנסו לתור אישור.
"""
if not citation.strip():
return _err("citation חובה")
try:
result = await precedent_library.ingest_precedent(
file_path=file_path,
citation=citation,
case_name=case_name,
court=court,
decision_date=decision_date or None,
source_type=source_type,
precedent_level=precedent_level,
practice_area=practice_area,
appeal_subtype=appeal_subtype,
subject_tags=subject_tags or [],
is_binding=is_binding,
headnote=headnote,
summary=summary,
)
except Exception as e:
return _err(str(e))
return _ok(result)
async def precedent_library_list(
practice_area: str = "",
court: str = "",
precedent_level: str = "",
source_type: str = "",
search: str = "",
limit: int = 100,
) -> str:
"""רשימה של פסיקה בקורפוס הסמכותי, עם פילטרים."""
rows = await precedent_library.list_precedents(
practice_area=practice_area,
court=court,
precedent_level=precedent_level,
source_type=source_type,
search=search,
limit=limit,
)
return _ok(rows)
async def precedent_library_get(case_law_id: str) -> str:
"""פסיקה ספציפית עם כל ההלכות שלה (כולל ממתינות לאישור)."""
try:
cid = UUID(case_law_id)
except ValueError:
return _err("case_law_id לא תקין")
record = await precedent_library.get_precedent(cid)
if not record:
return _err("פסיקה לא נמצאה")
return _ok(record)
async def precedent_library_delete(case_law_id: str) -> str:
"""מחיקת פסיקה מהקורפוס. cascade: chunks + halachot."""
try:
cid = UUID(case_law_id)
except ValueError:
return _err("case_law_id לא תקין")
ok = await precedent_library.delete_precedent(cid)
return _ok({"deleted": ok, "case_law_id": case_law_id})
async def precedent_extract_halachot(case_law_id: str) -> str:
"""הרצה מחדש של חילוץ ההלכות לפסיקה קיימת. הלכות קודמות נמחקות."""
try:
cid = UUID(case_law_id)
except ValueError:
return _err("case_law_id לא תקין")
try:
result = await precedent_library.reextract_halachot(cid)
except Exception as e:
return _err(str(e))
return _ok(result)
async def search_precedent_library(
query: str,
practice_area: str = "",
court: str = "",
precedent_level: str = "",
appeal_subtype: str = "",
is_binding: bool | None = None,
subject_tag: str = "",
limit: int = 10,
include_halachot: bool = True,
) -> str:
"""חיפוש סמנטי בקורפוס הפסיקה הסמכותית.
מחזיר תוצאות מעורבות: הלכות (rule-level, מאושרות בלבד) + קטעי טקסט
(passage-level). הלכות מקבלות boost קל בדירוג כי הן מזוקקות מראש.
Args:
query: שאילתת חיפוש בעברית.
practice_area: rishuy_uvniya / betterment_levy / compensation_197.
court: סינון לפי ערכאה (substring).
precedent_level: עליון / מנהלי / ועדת_ערר_ארצית / ועדת_ערר_מחוזית.
appeal_subtype: סינון לתת-סוג.
is_binding: True/False (None = ללא סינון).
subject_tag: סינון לפי תגית נושא (לדוגמה "מועד_קביעת_שומה").
limit: מספר תוצאות מקסימלי.
include_halachot: האם לכלול הלכות (ברירת מחדל: כן).
Returns: רשימה מדורגת. כל פריט הוא {"type": "halacha"|"passage", "score", ...}.
"""
if not query or len(query.strip()) < 2:
return json.dumps([], ensure_ascii=False)
results = await precedent_library.search_library(
query=query.strip(),
practice_area=practice_area,
court=court,
precedent_level=precedent_level,
appeal_subtype=appeal_subtype,
is_binding=is_binding,
subject_tag=subject_tag,
limit=limit,
include_halachot=include_halachot,
)
return _ok(results)
async def halacha_review(
halacha_id: str,
status: str,
reviewer: str = "דפנה",
rule_statement: str = "",
reasoning_summary: str = "",
subject_tags: list[str] | None = None,
practice_areas: list[str] | None = None,
) -> str:
"""אישור / דחייה / עריכה של הלכה שחולצה אוטומטית.
Args:
halacha_id: מזהה ההלכה.
status: pending_review / approved / rejected / published.
reviewer: שם המאשר (ברירת מחדל: דפנה).
rule_statement: עריכת ניסוח הכלל (ריק = ללא שינוי).
reasoning_summary: עריכת תמצית ההיגיון (ריק = ללא שינוי).
subject_tags: עריכת תגיות (None = ללא שינוי).
practice_areas: עריכת תחומים (None = ללא שינוי).
"""
if status not in {"pending_review", "approved", "rejected", "published"}:
return _err(
"status לא חוקי. ערכים תקינים: "
"pending_review / approved / rejected / published"
)
try:
hid = UUID(halacha_id)
except ValueError:
return _err("halacha_id לא תקין")
row = await db.update_halacha(
halacha_id=hid,
review_status=status,
reviewer=reviewer,
rule_statement=rule_statement or None,
reasoning_summary=reasoning_summary or None,
subject_tags=subject_tags,
practice_areas=practice_areas,
)
if row is None:
return _err("הלכה לא נמצאה")
return _ok(row)
async def halachot_pending(limit: int = 100) -> str:
"""תור ההלכות הממתינות לאישור (review_status='pending_review')."""
rows = await db.list_halachot(review_status="pending_review", limit=limit)
return _ok(rows)

View File

@@ -291,6 +291,24 @@ description: This skill should be used when writing legal decisions (החלטו
במקום לצטט כל פסק דין בנפרד, דפנה מפנה להחלטה שכבר ריכזה את הפסיקה: "בכל הנוגע ל[נושא], נפנה לניתוח המקיף שערכה ועדת הערר במסגרת ערר [שם] (פורסם בנבו) משם עולה כי..." ואז ציטוט בלוק ארוך (200-500 מילים) מתוך ההחלטה המרכזת שכוללת הפניות לפסיקה רלוונטית. הסיום: "אם כך, לעת הזו, הגישה הנוהגת היא ש..." במקום לצטט כל פסק דין בנפרד, דפנה מפנה להחלטה שכבר ריכזה את הפסיקה: "בכל הנוגע ל[נושא], נפנה לניתוח המקיף שערכה ועדת הערר במסגרת ערר [שם] (פורסם בנבו) משם עולה כי..." ואז ציטוט בלוק ארוך (200-500 מילים) מתוך ההחלטה המרכזת שכוללת הפניות לפסיקה רלוונטית. הסיום: "אם כך, לעת הזו, הגישה הנוהגת היא ש..."
### 7.5 שלושה מקורות פסיקה — אל תבלבל
המערכת מפרידה בין שלושה קורפוסי פסיקה. כל אחד מהם משמש למטרה אחרת ויש כלי MCP נפרד לחיפוש בו:
| קורפוס | טבלה | כלי חיפוש | תפקיד |
|---|---|---|---|
| תקדימי דפנה (סגנון) | `style_corpus` + `paragraph_embeddings` | `search_decisions` | החלטות שדפנה עצמה כתבה. מקור לסגנון, ניסוחים, ג'וריספרודנציה אישית. |
| ספריית הפסיקה הסמכותית | `case_law` (`source_kind='external_upload'`) + `halachot` | `search_precedent_library` | פסיקה חיצונית מחייבת — עליון, מנהלי, ועדות ערר אחרות — עם הלכות שאושרו ע"י דפנה. **המקור היחיד לציטוטים בבלוק י לפי CREAC.** |
| ציטוטים שצורפו ידנית | `case_precedents` | `precedent_search_library` | quotes שדפנה צירפה לתיק ספציפי בעבר. דומה לקורפוס סמכותי אך פר-תיק, ידני, לא עוברת חילוץ הלכות. |
**הזרימה הסטנדרטית בבלוק י:**
1. `search_decisions` קודם — בדוק אם דפנה כבר הכריעה בסוגיה דומה (חיסכון דוקטרינרי / הבחנה).
2. `search_precedent_library` — חפש את הכלל המחייב והציטוט התומך לפסקת CREAC.
3. אם הצדדים הפנו לפסיקה שלא בקורפוס — דפנה מעלה אותה דרך `/precedents` ב-UI; חילוץ ההלכות אוטומטי וההלכות מחכות לאישורה.
**איסור על המצאת ציטוטים** — ציטוט פסיקה חייב להגיע מאחד מהקורפוסים. אם אין הלכה מאושרת תומכת בנקודה — אל תמציא; ציין שהנושא דורש הוספת פסיקה לקורפוס.
## 8. כתיבת סיכום / סוף דבר ## 8. כתיבת סיכום / סוף דבר
### 8.1 ערר שנדחה ### 8.1 ערר שנדחה

View File

@@ -0,0 +1,96 @@
"use client";
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 { Badge } from "@/components/ui/badge";
import { LibraryListPanel } from "@/components/precedents/library-list-panel";
import { LibrarySearchPanel } from "@/components/precedents/library-search-panel";
import { HalachaReviewPanel } from "@/components/precedents/halacha-review-panel";
import { LibraryStatsPanel } from "@/components/precedents/library-stats-panel";
import { useHalachotPending } from "@/lib/api/precedent-library";
/**
* Precedent Library admin page.
*
* Four tabs:
* - ספרייה — browse all uploaded precedents (filters + upload + delete)
* - חיפוש סמנטי — semantic search across halachot + chunks
* - ממתין לאישור — chair review queue (PRIMARY tab; halachot from
* auto-extraction must be approved before agents can use them)
* - סטטיסטיקה — counts and coverage
*
* Distinct from /training (style corpus = Daphna's voice) and the
* per-case precedent attacher (chair-attached quotes scoped to a case).
*/
function PendingBadge() {
const { data } = useHalachotPending();
const n = data?.count ?? 0;
if (!n) return null;
return (
<Badge
variant="outline"
className="ms-1 bg-gold-wash text-gold-deep border-gold/40 text-[0.65rem]"
>
{n}
</Badge>
);
}
export default function PrecedentsPage() {
return (
<AppShell>
<section className="space-y-6">
<header>
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden> · </span>
<span className="text-navy">ספריית פסיקה</span>
</nav>
<h1 className="text-navy mb-0">ספריית הפסיקה הסמכותית</h1>
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
פסיקה חיצונית פסקי דין של ערכאות עליונות והחלטות של ועדות ערר אחרות.
כל קובץ עובר חילוץ הלכות אוטומטי, וההלכות ממתינות לאישור היו&quot;ר לפני
שהן זמינות לסוכני הכתיבה (legal-writer וכו&apos;).
</p>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<Tabs defaultValue="library" dir="rtl">
<TabsList className="bg-rule-soft/60">
<TabsTrigger value="library">ספרייה</TabsTrigger>
<TabsTrigger value="search">חיפוש סמנטי</TabsTrigger>
<TabsTrigger value="review">
ממתין לאישור
<PendingBadge />
</TabsTrigger>
<TabsTrigger value="stats">סטטיסטיקה</TabsTrigger>
</TabsList>
<TabsContent value="library" className="mt-5">
<LibraryListPanel />
</TabsContent>
<TabsContent value="search" className="mt-5">
<LibrarySearchPanel />
</TabsContent>
<TabsContent value="review" className="mt-5">
<HalachaReviewPanel />
</TabsContent>
<TabsContent value="stats" className="mt-5">
<LibraryStatsPanel />
</TabsContent>
</Tabs>
</CardContent>
</Card>
</section>
</AppShell>
);
}

View File

@@ -26,6 +26,7 @@ const NAV_ITEMS: NavItem[] = [
{ href: "/", label: "בית" }, { href: "/", label: "בית" },
{ href: "/archive", label: "ארכיון" }, { href: "/archive", label: "ארכיון" },
{ href: "/training", label: "אימון סגנון" }, { href: "/training", label: "אימון סגנון" },
{ href: "/precedents", label: "ספריית פסיקה" },
{ href: "/methodology", label: "מתודולוגיה" }, { href: "/methodology", label: "מתודולוגיה" },
{ href: "/skills", label: "מיומנויות" }, { href: "/skills", label: "מיומנויות" },
{ href: "/diagnostics", label: "אבחון" }, { href: "/diagnostics", label: "אבחון" },

View File

@@ -0,0 +1,348 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Check, X, Edit2, ChevronDown, ChevronUp, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import {
useHalachotPending, useUpdateHalacha, type Halacha,
} from "@/lib/api/precedent-library";
/**
* Halacha review queue — the chair-only path between automatic
* extraction and agent visibility. Per the project's review policy,
* NO halacha is auto-published; every row sits in pending_review until
* approved.
*
* UX is optimised for high-throughput approval:
* - Keyboard: J/K cycle rows, A approve, R reject, E edit, Esc cancel
* - Side-by-side: rule_statement vs supporting_quote
* - Quote-verified pill: green check or red triangle
* - Confidence sort: low-confidence rows shown first to be skeptical of
*/
function formatDate(iso: string | null | undefined) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("he-IL");
} catch {
return iso;
}
}
type EditState = { rule_statement: string; reasoning_summary: string };
function HalachaCard({
h, focused, onApprove, onReject, onSave,
}: {
h: Halacha;
focused: boolean;
onApprove: () => void;
onReject: () => void;
onSave: (patch: Partial<EditState>) => Promise<void>;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<EditState>({
rule_statement: h.rule_statement,
reasoning_summary: h.reasoning_summary,
});
// Reset the editable draft whenever the underlying halacha row changes
// (id or any of its synced fields). Cascade-render is intentional here —
// the form is keyed off the row so a re-mount would also work but loses
// editing affordances on rapid navigation.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setDraft({
rule_statement: h.rule_statement,
reasoning_summary: h.reasoning_summary,
});
}, [h.id, h.rule_statement, h.reasoning_summary]);
const onSubmitEdit = async () => {
await onSave(draft);
setEditing(false);
};
return (
<div
data-halacha-id={h.id}
className={`
rounded-lg border bg-surface p-4 space-y-3 transition-colors
${focused ? "border-gold ring-2 ring-gold/40 shadow-md" : "border-rule"}
`}
>
{/* Header */}
<div className="flex items-start gap-2 text-[0.78rem] text-ink-muted flex-wrap">
<span className="font-mono text-navy" dir="ltr">{h.case_number ?? "—"}</span>
{h.court && <span>· {h.court}</span>}
{h.decision_date && <span>· {formatDate(h.decision_date)}</span>}
{h.precedent_level && <span>· {h.precedent_level}</span>}
<span className="ms-auto flex items-center gap-2">
{h.quote_verified ? (
<Badge variant="outline" className="text-[0.65rem] bg-gold-wash text-gold-deep border-gold/40">
<Check className="w-3 h-3 me-1" /> ציטוט מאומת
</Badge>
) : (
<Badge variant="outline" className="text-[0.65rem] bg-danger-bg text-danger border-danger/40">
<AlertTriangle className="w-3 h-3 me-1" /> ציטוט לא מאומת
</Badge>
)}
<Badge variant="outline" className="text-[0.65rem] tabular-nums">
confidence {h.confidence.toFixed(2)}
</Badge>
<Badge variant="outline" className="text-[0.65rem]">
{h.rule_type}
</Badge>
</span>
</div>
{/* Side-by-side rule vs quote */}
<div className="grid md:grid-cols-2 gap-3">
<div>
<div className="text-[0.7rem] text-ink-muted mb-1">ניסוח הכלל</div>
{editing ? (
<Textarea
value={draft.rule_statement} rows={4} dir="rtl"
onChange={(e) => setDraft({ ...draft, rule_statement: e.target.value })}
className="bg-gold-wash/50 border-gold/30"
/>
) : (
<p className="text-navy font-medium leading-relaxed" dir="rtl">
{h.rule_statement}
</p>
)}
</div>
<div>
<div className="text-[0.7rem] text-ink-muted mb-1">
ציטוט תומך
{h.page_reference && (
<span className="ms-2 text-[0.65rem]">({h.page_reference})</span>
)}
</div>
<blockquote className="text-ink-soft text-sm leading-relaxed border-r-2 border-gold pr-3" dir="rtl">
&ldquo;{h.supporting_quote}&rdquo;
</blockquote>
</div>
</div>
{/* Reasoning + tags (collapsible) */}
{(editing || h.reasoning_summary) && (
<div>
<div className="text-[0.7rem] text-ink-muted mb-1">תמצית ההיגיון</div>
{editing ? (
<Textarea
value={draft.reasoning_summary} rows={2} dir="rtl"
onChange={(e) => setDraft({ ...draft, reasoning_summary: e.target.value })}
className="bg-gold-wash/50 border-gold/30"
/>
) : (
<p className="text-ink-soft text-sm leading-relaxed" dir="rtl">
{h.reasoning_summary}
</p>
)}
</div>
)}
<div className="flex items-center gap-2 flex-wrap">
{h.practice_areas?.map((p) => (
<Badge key={p} variant="outline" className="text-[0.65rem] bg-navy-soft/30 text-navy">
{p}
</Badge>
))}
{h.subject_tags?.map((t) => (
<Badge key={t} variant="outline" className="text-[0.65rem]">
{t}
</Badge>
))}
</div>
<div className="flex items-center gap-2 justify-end pt-1 border-t border-rule-soft">
{editing ? (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
ביטול
</Button>
<Button size="sm" onClick={onSubmitEdit}
className="bg-navy text-parchment hover:bg-navy-soft">
שמור שינויים
</Button>
</>
) : (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
<Edit2 className="w-3.5 h-3.5 me-1" />
ערוך (E)
</Button>
<Button size="sm" variant="ghost"
onClick={onReject}
className="text-danger hover:text-danger hover:bg-danger-bg">
<X className="w-3.5 h-3.5 me-1" />
דחה (R)
</Button>
<Button size="sm" onClick={onApprove}
className="bg-gold text-navy hover:bg-gold-deep">
<Check className="w-3.5 h-3.5 me-1" />
אשר (A)
</Button>
</>
)}
</div>
</div>
);
}
export function HalachaReviewPanel() {
const { data, isPending, error } = useHalachotPending(500);
const update = useUpdateHalacha();
const [focusIdx, setFocusIdx] = useState(0);
// Sort: low confidence first → forces the chair to scrutinize doubtful rows
const items = useMemo(() => {
const list = [...(data?.items ?? [])];
list.sort((a, b) => a.confidence - b.confidence);
return list;
}, [data]);
// Keep focus index in range as items change (e.g. after approve/reject
// shrinks the queue). Cascade-render is intentional and bounded.
useEffect(() => {
if (focusIdx >= items.length && items.length > 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setFocusIdx(items.length - 1);
}
}, [items.length, focusIdx]);
// Scroll the focused row into view
useEffect(() => {
if (!items.length) return;
const target = items[focusIdx];
if (!target) return;
const el = document.querySelector(`[data-halacha-id="${target.id}"]`);
el?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [focusIdx, items]);
const focused = items[focusIdx];
const review = async (
h: Halacha,
status: "approved" | "rejected",
extra?: Partial<EditState>,
) => {
try {
await update.mutateAsync({
id: h.id,
patch: {
review_status: status,
...extra,
},
});
toast.success(status === "approved" ? "אושר" : "נדחה");
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
// Keyboard navigation
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName?.toLowerCase();
if (tag === "input" || tag === "textarea") return;
if (!focused) return;
if (e.key === "j") {
e.preventDefault();
setFocusIdx((i) => Math.min(items.length - 1, i + 1));
} else if (e.key === "k") {
e.preventDefault();
setFocusIdx((i) => Math.max(0, i - 1));
} else if (e.key === "a" || e.key === "A") {
e.preventDefault();
review(focused, "approved");
} else if (e.key === "r" || e.key === "R") {
e.preventDefault();
if (window.confirm("לדחות הלכה זו?")) {
review(focused, "rejected");
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [focused, items.length]);
if (error) {
return (
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
{error.message}
</div>
);
}
if (isPending) {
return (
<div className="space-y-3">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-40 w-full" />)}
</div>
);
}
if (!items.length) {
return (
<div className="text-center text-ink-muted py-16">
<p className="text-lg">אין הלכות הממתינות לאישור.</p>
<p className="text-sm mt-2">העלה פסיקה חדשה ההלכות שיחולצו ממנה יופיעו כאן.</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center gap-3 text-sm text-ink-muted">
<span>
<span className="text-navy font-semibold">{items.length}</span> ממתינות לאישור
</span>
<span className="me-auto text-[0.72rem]">
ניווט: <kbd className="bg-rule-soft px-1.5 rounded">J</kbd>/<kbd className="bg-rule-soft px-1.5 rounded">K</kbd>
{" "}· אישור: <kbd className="bg-rule-soft px-1.5 rounded">A</kbd>
{" "}· דחייה: <kbd className="bg-rule-soft px-1.5 rounded">R</kbd>
{" "}· עריכה: <kbd className="bg-rule-soft px-1.5 rounded">E</kbd>
</span>
<Button
size="sm" variant="ghost"
onClick={() => setFocusIdx((i) => Math.max(0, i - 1))}
disabled={focusIdx === 0}>
<ChevronUp className="w-4 h-4" />
</Button>
<Button
size="sm" variant="ghost"
onClick={() => setFocusIdx((i) => Math.min(items.length - 1, i + 1))}
disabled={focusIdx >= items.length - 1}>
<ChevronDown className="w-4 h-4" />
</Button>
</div>
<div className="space-y-4">
{items.map((h, i) => (
<HalachaCard
key={h.id} h={h} focused={i === focusIdx}
onApprove={() => review(h, "approved")}
onReject={() => {
if (window.confirm("לדחות הלכה זו?")) review(h, "rejected");
}}
onSave={async (patch) => {
try {
await update.mutateAsync({ id: h.id, patch });
toast.success("נשמר");
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
}}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,235 @@
"use client";
import { useState } from "react";
import { Trash2, Plus, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
usePrecedents,
useDeletePrecedent,
useReExtractHalachot,
type Precedent,
type PracticeArea,
} from "@/lib/api/precedent-library";
import { PRACTICE_AREAS, PRECEDENT_LEVELS, practiceAreaShort } from "./practice-area";
import { PrecedentUploadSheet } from "./precedent-upload-sheet";
function formatDate(iso: string | null) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("he-IL");
} catch {
return iso;
}
}
function StatusPill({ p }: { p: Precedent }) {
if (p.extraction_status === "failed") {
return <Badge variant="outline" className="bg-danger-bg text-danger border-danger/40">נכשל</Badge>;
}
if (p.extraction_status !== "completed") {
return <Badge variant="outline" className="bg-rule-soft text-ink-muted">בעיבוד</Badge>;
}
if (p.halacha_extraction_status !== "completed") {
return <Badge variant="outline" className="bg-gold-wash text-gold-deep">מחלץ הלכות</Badge>;
}
if (p.halachot_count === 0) {
return <Badge variant="outline">ללא הלכות</Badge>;
}
return (
<Badge
variant="outline"
className="bg-gold-wash text-gold-deep border-gold/40"
>
{p.approved_count}/{p.halachot_count} מאושרות
</Badge>
);
}
function PrecedentRow({ p }: { p: Precedent }) {
const del = useDeletePrecedent();
const reExtract = useReExtractHalachot();
const onDelete = async () => {
if (!window.confirm(`למחוק את ${p.case_number}? cascade ימחק את ה-chunks וההלכות.`)) return;
try {
await del.mutateAsync(p.id);
toast.success("נמחק");
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
const onReExtract = async () => {
try {
await reExtract.mutateAsync(p.id);
toast.success("חילוץ הלכות החל");
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
return (
<TableRow className="border-rule hover:bg-gold-wash/30">
<TableCell className="font-semibold text-navy" dir="ltr">
{p.case_number}
</TableCell>
<TableCell className="text-ink">
<div className="font-medium">{p.case_name || "—"}</div>
<div className="text-[0.72rem] text-ink-muted">{p.court || "—"}</div>
</TableCell>
<TableCell className="text-ink-muted">{formatDate(p.date)}</TableCell>
<TableCell>
{p.practice_area ? (
<Badge variant="outline" className="bg-navy-soft/40 text-navy border-navy/30">
{practiceAreaShort(p.practice_area)}
</Badge>
) : (
<span className="text-ink-light"></span>
)}
</TableCell>
<TableCell className="text-ink-muted text-[0.78rem]">
{p.precedent_level || "—"}
</TableCell>
<TableCell>
<StatusPill p={p} />
</TableCell>
<TableCell className="text-end">
<div className="flex items-center gap-1 justify-end">
<Button
variant="ghost" size="sm" onClick={onReExtract}
disabled={reExtract.isPending}
aria-label="חלץ הלכות מחדש"
title="חלץ הלכות מחדש"
className="text-ink-muted hover:text-navy"
>
<RefreshCw className="w-4 h-4" />
</Button>
<Button
variant="ghost" size="sm" onClick={onDelete}
disabled={del.isPending}
aria-label={`מחק את ${p.case_number}`}
className="text-danger hover:text-danger hover:bg-danger-bg"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
}
export function LibraryListPanel() {
const [practiceArea, setPracticeArea] = useState<PracticeArea>("");
const [precedentLevel, setPrecedentLevel] = useState("");
const [search, setSearch] = useState("");
const [uploadOpen, setUploadOpen] = useState(false);
const { data, isPending, error } = usePrecedents({
practiceArea: practiceArea || undefined,
precedentLevel: precedentLevel || undefined,
search: search.trim() || undefined,
limit: 200,
});
return (
<div className="space-y-4">
<div className="flex items-end gap-3 flex-wrap">
<div className="flex-1 min-w-[200px]">
<label className="text-[0.78rem] text-ink-muted">חיפוש (מספר תיק / שם / תקציר)</label>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="עע&quot;מ 3975/22"
dir="rtl"
/>
</div>
<div className="min-w-[180px]">
<label className="text-[0.78rem] text-ink-muted">תחום</label>
<Select value={practiceArea || "_all"} onValueChange={(v) => setPracticeArea(v === "_all" ? "" : v as PracticeArea)}>
<SelectTrigger><SelectValue placeholder="הכל" /></SelectTrigger>
<SelectContent>
<SelectItem value="_all">הכל</SelectItem>
{PRACTICE_AREAS.map((a) => (
<SelectItem key={a.value} value={a.value}>{a.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="min-w-[170px]">
<label className="text-[0.78rem] text-ink-muted">רמת תקדים</label>
<Select value={precedentLevel || "_all"} onValueChange={(v) => setPrecedentLevel(v === "_all" ? "" : v)}>
<SelectTrigger><SelectValue placeholder="הכל" /></SelectTrigger>
<SelectContent>
<SelectItem value="_all">הכל</SelectItem>
{PRECEDENT_LEVELS.map((l) => (
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={() => setUploadOpen(true)} className="bg-navy text-parchment hover:bg-navy-soft">
<Plus className="w-4 h-4 me-1" />
העלאת פסיקה
</Button>
</div>
{error ? (
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
{error.message}
</div>
) : (
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-rule-soft/60">
<TableRow className="border-rule">
<TableHead className="text-navy text-right">מס׳ / מראה מקום</TableHead>
<TableHead className="text-navy text-right">שם / ערכאה</TableHead>
<TableHead className="text-navy text-right">תאריך</TableHead>
<TableHead className="text-navy text-right">תחום</TableHead>
<TableHead className="text-navy text-right">רמה</TableHead>
<TableHead className="text-navy text-right">הלכות</TableHead>
<TableHead className="text-navy" />
</TableRow>
</TableHeader>
<TableBody>
{isPending ? (
[...Array(5)].map((_, i) => (
<TableRow key={i} className="border-rule">
{[...Array(7)].map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-5 w-full" />
</TableCell>
))}
</TableRow>
))
) : !data?.items.length ? (
<TableRow className="border-rule">
<TableCell colSpan={7} className="text-center text-ink-muted py-10">
אין פסיקה בקורפוס. העלה את פסק הדין הראשון.
</TableCell>
</TableRow>
) : (
data.items.map((p) => <PrecedentRow key={p.id} p={p} />)
)}
</TableBody>
</Table>
</div>
)}
<PrecedentUploadSheet open={uploadOpen} onOpenChange={setUploadOpen} />
</div>
);
}

View File

@@ -0,0 +1,173 @@
"use client";
import { useState } from "react";
import { Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
useLibrarySearch, type PracticeArea, type SearchHit,
} from "@/lib/api/precedent-library";
import { PRACTICE_AREAS, PRECEDENT_LEVELS } from "./practice-area";
function formatDate(iso: string | null) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("he-IL");
} catch {
return iso;
}
}
function HalachaCard({ hit }: { hit: Extract<SearchHit, { type: "halacha" }> }) {
return (
<div className="rounded-lg border border-gold/40 bg-gold-wash/40 p-4 space-y-2">
<div className="flex items-center gap-2 text-[0.78rem] text-ink-muted flex-wrap">
<Badge className="bg-gold text-navy border-0">הלכה</Badge>
<span className="font-mono" dir="ltr">{hit.case_number}</span>
{hit.court && <span>· {hit.court}</span>}
{hit.decision_date && <span>· {formatDate(hit.decision_date)}</span>}
{hit.precedent_level && <span>· {hit.precedent_level}</span>}
<span className="ms-auto tabular-nums">דירוג {hit.score.toFixed(2)}</span>
</div>
<p className="text-navy font-medium text-[0.95rem]" dir="rtl">
{hit.rule_statement}
</p>
<blockquote className="text-ink-soft text-sm border-r-2 border-gold pr-3" dir="rtl">
&ldquo;{hit.supporting_quote}&rdquo;
{hit.page_reference && <span className="text-ink-muted text-[0.72rem] ms-2">({hit.page_reference})</span>}
</blockquote>
{hit.subject_tags?.length > 0 && (
<div className="flex flex-wrap gap-1">
{hit.subject_tags.map((t) => (
<Badge key={t} variant="outline" className="text-[0.65rem] bg-surface">
{t}
</Badge>
))}
</div>
)}
</div>
);
}
function PassageCard({ hit }: { hit: Extract<SearchHit, { type: "passage" }> }) {
return (
<div className="rounded-lg border border-rule bg-surface p-4 space-y-2">
<div className="flex items-center gap-2 text-[0.78rem] text-ink-muted flex-wrap">
<Badge variant="outline" className="bg-rule-soft text-ink-muted">קטע</Badge>
<span className="font-mono" dir="ltr">{hit.case_number}</span>
{hit.court && <span>· {hit.court}</span>}
{hit.decision_date && <span>· {formatDate(hit.decision_date)}</span>}
<span className="text-[0.7rem]">· {hit.section_type}</span>
<span className="ms-auto tabular-nums">דירוג {hit.score.toFixed(2)}</span>
</div>
<p className="text-ink text-sm leading-relaxed" dir="rtl">
{hit.content.slice(0, 600)}
{hit.content.length > 600 && <span></span>}
</p>
</div>
);
}
export function LibrarySearchPanel() {
const [draft, setDraft] = useState("");
const [query, setQuery] = useState("");
const [practiceArea, setPracticeArea] = useState<PracticeArea>("");
const [precedentLevel, setPrecedentLevel] = useState("");
const [includeHalachot, setIncludeHalachot] = useState(true);
const { data, isFetching, error } = useLibrarySearch(query, {
practiceArea: practiceArea || undefined,
precedentLevel: precedentLevel || undefined,
includeHalachot,
limit: 20,
});
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
setQuery(draft.trim());
};
return (
<div className="space-y-4">
<form onSubmit={onSubmit} className="flex items-end gap-3 flex-wrap">
<div className="flex-1 min-w-[300px]">
<label className="text-[0.78rem] text-ink-muted">שאילתת חיפוש</label>
<Input value={draft} onChange={(e) => setDraft(e.target.value)}
placeholder="השבחה אובייקטיבית" dir="rtl" />
</div>
<div className="min-w-[180px]">
<label className="text-[0.78rem] text-ink-muted">תחום</label>
<Select value={practiceArea || "_all"}
onValueChange={(v) => setPracticeArea(v === "_all" ? "" : v as PracticeArea)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_all">הכל</SelectItem>
{PRACTICE_AREAS.map((a) => (
<SelectItem key={a.value} value={a.value}>{a.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="min-w-[170px]">
<label className="text-[0.78rem] text-ink-muted">רמת תקדים</label>
<Select value={precedentLevel || "_all"}
onValueChange={(v) => setPrecedentLevel(v === "_all" ? "" : v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_all">הכל</SelectItem>
{PRECEDENT_LEVELS.map((l) => (
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit" className="bg-navy text-parchment hover:bg-navy-soft">
<Search className="w-4 h-4 me-1" />
חפש
</Button>
</form>
<label className="flex items-center gap-2 cursor-pointer text-sm text-ink-muted">
<input type="checkbox" checked={includeHalachot}
onChange={(e) => setIncludeHalachot(e.target.checked)} />
כלול הלכות (rule-level matches)
</label>
{!query.trim() ? (
<div className="text-center text-ink-muted py-12">
הקלד שאילתא כדי לחפש בקורפוס. החיפוש סמנטי לא טקסטואלי.
</div>
) : error ? (
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
{error.message}
</div>
) : isFetching ? (
<div className="space-y-3">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
) : !data?.items.length ? (
<div className="text-center text-ink-muted py-12">
לא נמצאו תוצאות. נסה ניסוח אחר או הסר פילטרים.
</div>
) : (
<div className="space-y-3">
<p className="text-[0.78rem] text-ink-muted">
{data.count} תוצאות (הלכות מאושרות בלבד)
</p>
{data.items.map((hit, i) =>
hit.type === "halacha" ? (
<HalachaCard key={`h-${hit.halacha_id ?? i}`} hit={hit} />
) : (
<PassageCard key={`p-${hit.chunk_id ?? i}`} hit={hit} />
),
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { Skeleton } from "@/components/ui/skeleton";
import { useLibraryStats } from "@/lib/api/precedent-library";
import { practiceAreaLabel } from "./practice-area";
function StatCard({ label, value, accent }: { label: string; value: number | string; accent?: boolean }) {
return (
<div
className={`
rounded-lg border p-5 bg-surface shadow-sm
${accent ? "border-gold bg-gold-wash/40" : "border-rule"}
`}
>
<div className="text-[0.78rem] text-ink-muted mb-1">{label}</div>
<div className={`text-3xl font-bold tabular-nums ${accent ? "text-gold-deep" : "text-navy"}`}>
{value}
</div>
</div>
);
}
export function LibraryStatsPanel() {
const { data, isPending, error } = useLibraryStats();
if (error) {
return (
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
{error.message}
</div>
);
}
if (isPending || !data) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-28 w-full" />)}
</div>
);
}
return (
<div className="space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="פסיקה בקורפוס" value={data.precedents_total} />
<StatCard label="הלכות בסך הכל" value={data.halachot_total} />
<StatCard
label="ממתינות לאישור" value={data.halachot_pending}
accent={data.halachot_pending > 0}
/>
<StatCard label="מאושרות (זמינות לסוכנים)" value={data.halachot_approved} />
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="rounded-lg border border-rule bg-surface p-5">
<h3 className="text-navy font-semibold mb-3">פילוח לפי תחום</h3>
{data.by_practice_area.length === 0 ? (
<p className="text-ink-muted text-sm">אין נתונים</p>
) : (
<ul className="space-y-2">
{data.by_practice_area.map((row) => (
<li key={row.practice_area || "—"} className="flex items-center gap-2 text-sm">
<span className="text-ink">{practiceAreaLabel(row.practice_area || null)}</span>
<span className="ms-auto tabular-nums text-navy font-semibold">{row.count}</span>
</li>
))}
</ul>
)}
</div>
<div className="rounded-lg border border-rule bg-surface p-5">
<h3 className="text-navy font-semibold mb-3">פילוח לפי רמת תקדים</h3>
{data.by_precedent_level.length === 0 ? (
<p className="text-ink-muted text-sm">אין נתונים</p>
) : (
<ul className="space-y-2">
{data.by_precedent_level.map((row) => (
<li key={row.precedent_level || "—"} className="flex items-center gap-2 text-sm">
<span className="text-ink">{row.precedent_level || "—"}</span>
<span className="ms-auto tabular-nums text-navy font-semibold">{row.count}</span>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
/**
* Practice-area constants for the precedent library.
*
* The chair confined the library to the three appeals committee
* domains — no national-insurance corpus. The DB enforces this
* via a CHECK constraint on case_law.practice_area.
*/
export const PRACTICE_AREAS = [
{ value: "rishuy_uvniya", label: "רישוי ובניה", short: "רישוי" },
{ value: "betterment_levy", label: "היטל השבחה", short: "השבחה" },
{ value: "compensation_197", label: "פיצויים לפי ס' 197", short: "פיצויים" },
] as const;
export const PRECEDENT_LEVELS = [
{ value: "עליון", label: "עליון" },
{ value: "מנהלי", label: "מנהלי" },
{ value: "ועדת_ערר_ארצית", label: "ועדת ערר ארצית" },
{ value: "ועדת_ערר_מחוזית", label: "ועדת ערר מחוזית" },
] as const;
export const SOURCE_TYPES = [
{ value: "court_ruling", label: "פסק דין" },
{ value: "appeals_committee", label: "החלטת ועדת ערר" },
] as const;
export function practiceAreaLabel(value: string | null | undefined): string {
if (!value) return "—";
const match = PRACTICE_AREAS.find((p) => p.value === value);
return match ? match.label : value;
}
export function practiceAreaShort(value: string | null | undefined): string {
if (!value) return "—";
const match = PRACTICE_AREAS.find((p) => p.value === value);
return match ? match.short : value;
}

View File

@@ -0,0 +1,290 @@
"use client";
import { useEffect, useState } from "react";
import { Upload, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
import { toast } from "sonner";
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Progress } from "@/components/ui/progress";
import { useUploadPrecedent, type PracticeArea, type SourceType } from "@/lib/api/precedent-library";
import { useProgress } from "@/lib/api/documents";
import {
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES,
} from "./practice-area";
const ACCEPT = ".pdf,.docx,.doc,.rtf,.txt,.md";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
const [file, setFile] = useState<File | null>(null);
const [citation, setCitation] = useState("");
const [caseName, setCaseName] = useState("");
const [court, setCourt] = useState("");
const [decisionDate, setDecisionDate] = useState("");
const [sourceType, setSourceType] = useState<SourceType>("");
const [precedentLevel, setPrecedentLevel] = useState("");
const [practiceArea, setPracticeArea] = useState<PracticeArea>("");
const [appealSubtype, setAppealSubtype] = useState("");
const [subjectTags, setSubjectTags] = useState("");
const [headnote, setHeadnote] = useState("");
const [isBinding, setIsBinding] = useState(true);
const [taskId, setTaskId] = useState<string | null>(null);
const upload = useUploadPrecedent();
const progress = useProgress(taskId);
// Reset form when the sheet closes — fields, file input, and any in-flight
// task subscription. We accept the cascade-render warning because resetting
// form state on close is exactly the intended side effect.
useEffect(() => {
if (open) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setFile(null); setCitation(""); setCaseName(""); setCourt("");
// eslint-disable-next-line react-hooks/set-state-in-effect
setDecisionDate(""); setSourceType(""); setPrecedentLevel("");
// eslint-disable-next-line react-hooks/set-state-in-effect
setPracticeArea(""); setAppealSubtype(""); setSubjectTags("");
// eslint-disable-next-line react-hooks/set-state-in-effect
setHeadnote(""); setIsBinding(true); setTaskId(null);
}, [open]);
// Auto-close on completion
useEffect(() => {
if (progress?.status === "completed") {
toast.success("הפסיקה הוכנסה לקורפוס. ההלכות ממתינות לאישור.");
const t = window.setTimeout(() => onOpenChange(false), 1200);
return () => window.clearTimeout(t);
}
if (progress?.status === "failed") {
toast.error(`כשל בעיבוד: ${progress.error || "שגיאה לא ידועה"}`);
}
}, [progress, onOpenChange]);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!file) {
toast.error("בחר קובץ");
return;
}
if (!citation.trim()) {
toast.error("מראה המקום (citation) חובה");
return;
}
if (!practiceArea) {
toast.error("בחר תחום משפט");
return;
}
try {
const tags = subjectTags
.split(",")
.map((t) => t.trim())
.filter(Boolean);
const res = await upload.mutateAsync({
file,
citation: citation.trim(),
case_name: caseName.trim(),
court: court.trim(),
decision_date: decisionDate || undefined,
source_type: sourceType || undefined,
precedent_level: precedentLevel || undefined,
practice_area: practiceArea,
appeal_subtype: appealSubtype.trim(),
subject_tags: tags,
is_binding: isBinding,
headnote: headnote.trim(),
});
setTaskId(res.task_id);
} catch (err) {
toast.error(err instanceof Error ? err.message : "כשל בהעלאה");
}
};
const isProcessing = taskId !== null && progress?.status !== "completed" && progress?.status !== "failed";
const stage = (progress as { stage?: string; percent?: number; step?: string } | null)?.stage;
const percent = (progress as { percent?: number } | null)?.percent ?? 0;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-full sm:max-w-2xl overflow-y-auto" dir="rtl">
<SheetHeader>
<SheetTitle className="text-navy">העלאת פסיקה לקורפוס הסמכותי</SheetTitle>
<SheetDescription className="text-ink-muted">
הקובץ יעבור חילוץ טקסט, יצירת embeddings, וחילוץ הלכות אוטומטי.
ההלכות יחכו לאישורך לפני שהן זמינות לסוכני הכתיבה.
</SheetDescription>
</SheetHeader>
<form onSubmit={onSubmit} className="px-6 pb-6 space-y-4 mt-4">
{/* File */}
<div className="space-y-1">
<Label htmlFor="file">קובץ (PDF / DOCX / DOC / RTF / TXT / MD)</Label>
<Input
id="file" type="file" accept={ACCEPT}
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
disabled={isProcessing}
/>
</div>
{/* Citation */}
<div className="space-y-1">
<Label htmlFor="citation">מראה המקום (חובה)</Label>
<Input
id="citation" value={citation}
onChange={(e) => setCitation(e.target.value)}
placeholder={`עע"מ 3975/22 ב. קרן-נכסים נ' ועדה מקומית`}
disabled={isProcessing} dir="rtl"
/>
</div>
{/* Two-col grid */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="case-name">שם קצר</Label>
<Input id="case-name" value={caseName}
onChange={(e) => setCaseName(e.target.value)}
placeholder="ב. קרן-נכסים" disabled={isProcessing} />
</div>
<div className="space-y-1">
<Label htmlFor="court">ערכאה</Label>
<Input id="court" value={court}
onChange={(e) => setCourt(e.target.value)}
placeholder='בית משפט עליון / בג"ץ / מנהלי / ועדת ערר'
disabled={isProcessing} />
</div>
<div className="space-y-1">
<Label htmlFor="date">תאריך החלטה</Label>
<Input id="date" type="date" value={decisionDate}
onChange={(e) => setDecisionDate(e.target.value)}
disabled={isProcessing} />
</div>
<div className="space-y-1">
<Label htmlFor="appeal-subtype">תת-סוג (חופשי)</Label>
<Input id="appeal-subtype" value={appealSubtype}
onChange={(e) => setAppealSubtype(e.target.value)}
placeholder="שימוש חורג / סופיות ההחלטה" disabled={isProcessing} />
</div>
</div>
{/* Practice area (required radio) */}
<div className="space-y-1">
<Label>תחום משפט (חובה)</Label>
<div className="flex gap-4 flex-wrap">
{PRACTICE_AREAS.map((a) => (
<label key={a.value} className="flex items-center gap-2 cursor-pointer">
<input
type="radio" name="practice_area" value={a.value}
checked={practiceArea === a.value}
onChange={() => setPracticeArea(a.value as PracticeArea)}
disabled={isProcessing}
/>
<span className="text-sm text-ink">{a.label}</span>
</label>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="source-type">סוג מקור</Label>
<Select value={sourceType || "_none"}
onValueChange={(v) => setSourceType(v === "_none" ? "" : v as SourceType)}
disabled={isProcessing}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{SOURCE_TYPES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="precedent-level">רמת תקדים</Label>
<Select value={precedentLevel || "_none"}
onValueChange={(v) => setPrecedentLevel(v === "_none" ? "" : v)}
disabled={isProcessing}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{PRECEDENT_LEVELS.map((l) => (
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="tags">תגיות נושא (מופרדות בפסיקים)</Label>
<Input id="tags" value={subjectTags}
onChange={(e) => setSubjectTags(e.target.value)}
placeholder="חניה, קווי בניין, שיקול דעת" disabled={isProcessing} />
</div>
<div className="space-y-1">
<Label htmlFor="headnote">תקציר / headnote (אופציונלי)</Label>
<Textarea id="headnote" value={headnote} rows={2}
onChange={(e) => setHeadnote(e.target.value)}
placeholder="תקציר חופשי שיוצג ברשימה" disabled={isProcessing} dir="rtl" />
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={isBinding}
onChange={(e) => setIsBinding(e.target.checked)}
disabled={isProcessing} />
<span className="text-sm">הלכה מחייבת</span>
</label>
{isProcessing && (
<div className="rounded-lg border border-rule bg-rule-soft/40 p-4 space-y-2">
<div className="flex items-center gap-2 text-sm text-navy">
<Loader2 className="w-4 h-4 animate-spin" />
<span>{(progress as { step?: string } | null)?.step || stage || "מעבד"}</span>
</div>
<Progress value={percent} className="h-1.5" />
</div>
)}
{progress?.status === "completed" && (
<div className="rounded-lg border border-gold/40 bg-gold-wash p-4 flex items-center gap-2 text-gold-deep text-sm">
<CheckCircle2 className="w-4 h-4" />
נכנס לקורפוס. ההלכות ממתינות בתור האישור.
</div>
)}
{progress?.status === "failed" && (
<div className="rounded-lg border border-danger/40 bg-danger-bg p-4 flex items-center gap-2 text-danger text-sm">
<AlertCircle className="w-4 h-4" />
{progress.error || "שגיאה"}
</div>
)}
<div className="flex gap-2 justify-end pt-2">
<Button type="button" variant="ghost"
onClick={() => onOpenChange(false)} disabled={upload.isPending}>
ביטול
</Button>
<Button type="submit"
disabled={upload.isPending || isProcessing}
className="bg-navy text-parchment hover:bg-navy-soft">
<Upload className="w-4 h-4 me-1" />
העלה
</Button>
</div>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,387 @@
/**
* External Precedent Library hooks.
*
* The library is the authoritative case-law corpus — chair-uploaded
* court rulings + other appeals committee decisions, with halachot
* extracted automatically and queued for chair approval. Distinct from:
* - /api/training (Daphna's style corpus — sample decisions for tone)
* - /api/precedents (chair-attached quotes per case section)
*
* Endpoints touched (all under /api/precedent-library and /api/halachot):
* - POST /upload (multipart) → task_id (consumed by useProgress)
* - GET / (filters) → list
* - GET /{id} → detail with halachot
* - PATCH /{id} → metadata edit
* - DELETE /{id} → remove
* - POST /{id}/extract-halachot → re-run halacha extractor
* - GET /search → semantic search (halachot + chunks)
* - GET /stats
* - GET /api/halachot?status=... → review queue
* - PATCH /api/halachot/{id} → approve/reject/edit
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, apiRequest } from "./client";
export type PracticeArea =
| ""
| "rishuy_uvniya"
| "betterment_levy"
| "compensation_197";
export type SourceType = "" | "court_ruling" | "appeals_committee";
export type Precedent = {
id: string;
case_number: string;
case_name: string;
court: string;
date: string | null;
practice_area: PracticeArea | "";
appeal_subtype: string;
source_type: SourceType | "";
precedent_level: string;
is_binding: boolean;
summary: string;
headnote: string;
subject_tags: string[];
source_kind: string;
extraction_status: string;
halacha_extraction_status: string;
created_at: string;
halachot_count: number;
approved_count: number;
};
export type Halacha = {
id: string;
case_law_id: string;
halacha_index: number;
rule_statement: string;
rule_type: string;
reasoning_summary: string;
supporting_quote: string;
page_reference: string;
practice_areas: string[];
subject_tags: string[];
cites: string[];
confidence: number;
quote_verified: boolean;
review_status: "pending_review" | "approved" | "rejected" | "published";
reviewer: string;
reviewed_at: string | null;
created_at: string;
updated_at: string;
/* Joined from case_law for review/list views */
case_number?: string;
case_name?: string;
court?: string;
decision_date?: string | null;
precedent_level?: string;
};
export type PrecedentDetail = Precedent & {
full_text: string;
halachot: Halacha[];
};
export type SearchHit =
| {
type: "halacha";
score: number;
halacha_id: string;
case_law_id: string;
rule_statement: string;
reasoning_summary: string;
supporting_quote: string;
page_reference: string;
practice_areas: string[];
subject_tags: string[];
confidence: number;
rule_type: string;
case_number: string;
case_name: string;
court: string;
decision_date: string | null;
precedent_level: string;
}
| {
type: "passage";
score: number;
chunk_id: string;
case_law_id: string;
content: string;
section_type: string;
page_number: number | null;
case_number: string;
case_name: string;
court: string;
decision_date: string | null;
precedent_level: string;
practice_area: string;
};
export type LibraryStats = {
precedents_total: number;
by_practice_area: { practice_area: string; count: number }[];
by_precedent_level: { precedent_level: string; count: number }[];
halachot_total: number;
halachot_pending: number;
halachot_approved: number;
};
export type ListFilters = {
practiceArea?: PracticeArea;
court?: string;
precedentLevel?: string;
sourceType?: SourceType;
search?: string;
limit?: number;
offset?: number;
};
export const libraryKeys = {
all: ["precedent-library"] as const,
list: (filters: ListFilters) =>
[...libraryKeys.all, "list", filters] as const,
detail: (id: string) => [...libraryKeys.all, "detail", id] as const,
search: (q: string, filters: Record<string, string | boolean>) =>
[...libraryKeys.all, "search", q, filters] as const,
stats: () => [...libraryKeys.all, "stats"] as const,
halachotPending: () => [...libraryKeys.all, "halachot", "pending"] as const,
halachot: (filters: Record<string, string>) =>
[...libraryKeys.all, "halachot", filters] as const,
};
export function usePrecedents(filters: ListFilters = {}) {
return useQuery({
queryKey: libraryKeys.list(filters),
queryFn: ({ signal }) => {
const p = new URLSearchParams();
if (filters.practiceArea) p.set("practice_area", filters.practiceArea);
if (filters.court) p.set("court", filters.court);
if (filters.precedentLevel) p.set("precedent_level", filters.precedentLevel);
if (filters.sourceType) p.set("source_type", filters.sourceType);
if (filters.search) p.set("search", filters.search);
if (filters.limit) p.set("limit", String(filters.limit));
if (filters.offset) p.set("offset", String(filters.offset));
const qs = p.toString();
return apiRequest<{ items: Precedent[]; count: number }>(
`/api/precedent-library${qs ? `?${qs}` : ""}`,
{ signal },
);
},
staleTime: 30_000,
});
}
export function usePrecedent(id: string | null) {
return useQuery({
queryKey: libraryKeys.detail(id ?? ""),
queryFn: ({ signal }) =>
apiRequest<PrecedentDetail>(
`/api/precedent-library/${encodeURIComponent(id!)}`,
{ signal },
),
enabled: Boolean(id),
staleTime: 30_000,
});
}
export function useLibraryStats() {
return useQuery({
queryKey: libraryKeys.stats(),
queryFn: ({ signal }) =>
apiRequest<LibraryStats>("/api/precedent-library/stats", { signal }),
staleTime: 60_000,
});
}
export type SearchFilters = {
practiceArea?: PracticeArea;
court?: string;
precedentLevel?: string;
appealSubtype?: string;
subjectTag?: string;
includeHalachot?: boolean;
limit?: number;
};
export function useLibrarySearch(query: string, filters: SearchFilters = {}) {
const params: Record<string, string | boolean> = {};
if (filters.practiceArea) params.practice_area = filters.practiceArea;
if (filters.court) params.court = filters.court;
if (filters.precedentLevel) params.precedent_level = filters.precedentLevel;
if (filters.appealSubtype) params.appeal_subtype = filters.appealSubtype;
if (filters.subjectTag) params.subject_tag = filters.subjectTag;
if (filters.includeHalachot !== undefined)
params.include_halachot = filters.includeHalachot;
return useQuery({
queryKey: libraryKeys.search(query, params),
queryFn: ({ signal }) => {
const p = new URLSearchParams({ q: query });
for (const [k, v] of Object.entries(params)) p.set(k, String(v));
if (filters.limit) p.set("limit", String(filters.limit));
return apiRequest<{ items: SearchHit[]; count: number }>(
`/api/precedent-library/search?${p.toString()}`,
{ signal },
);
},
enabled: query.trim().length >= 2,
staleTime: 10_000,
placeholderData: (prev) => prev,
});
}
export type PrecedentUploadInput = {
file: File;
citation: string;
case_name?: string;
court?: string;
decision_date?: string;
source_type?: SourceType;
precedent_level?: string;
practice_area?: PracticeArea;
appeal_subtype?: string;
subject_tags?: string[];
is_binding?: boolean;
headnote?: string;
summary?: string;
};
export function useUploadPrecedent() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: PrecedentUploadInput) => {
const fd = new FormData();
fd.append("file", input.file);
fd.append("citation", input.citation);
if (input.case_name) fd.append("case_name", input.case_name);
if (input.court) fd.append("court", input.court);
if (input.decision_date) fd.append("decision_date", input.decision_date);
if (input.source_type) fd.append("source_type", input.source_type);
if (input.precedent_level)
fd.append("precedent_level", input.precedent_level);
if (input.practice_area)
fd.append("practice_area", input.practice_area);
if (input.appeal_subtype)
fd.append("appeal_subtype", input.appeal_subtype);
if (input.subject_tags && input.subject_tags.length)
fd.append("subject_tags", JSON.stringify(input.subject_tags));
fd.append("is_binding", String(input.is_binding ?? true));
if (input.headnote) fd.append("headnote", input.headnote);
if (input.summary) fd.append("summary", input.summary);
const res = await fetch("/api/precedent-library/upload", {
method: "POST",
body: fd,
});
const parsed = await res.json().catch(() => null);
if (!res.ok) {
throw new ApiError(
`Upload failed with ${res.status}`,
res.status,
parsed,
);
}
return parsed as { task_id: string };
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: libraryKeys.all });
},
});
}
export function useDeletePrecedent() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiRequest<{ deleted: boolean }>(
`/api/precedent-library/${encodeURIComponent(id)}`,
{ method: "DELETE" },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: libraryKeys.all });
},
});
}
export type PrecedentPatch = Partial<{
case_name: string;
court: string;
decision_date: string;
practice_area: PracticeArea;
appeal_subtype: string;
subject_tags: string[];
summary: string;
headnote: string;
source_type: SourceType;
precedent_level: string;
is_binding: boolean;
}>;
export function useUpdatePrecedent() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, patch }: { id: string; patch: PrecedentPatch }) =>
apiRequest<Precedent>(
`/api/precedent-library/${encodeURIComponent(id)}`,
{ method: "PATCH", body: patch },
),
onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: libraryKeys.detail(id) });
qc.invalidateQueries({ queryKey: libraryKeys.all });
},
});
}
export function useReExtractHalachot() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiRequest<{ task_id: string }>(
`/api/precedent-library/${encodeURIComponent(id)}/extract-halachot`,
{ method: "POST" },
),
onSuccess: (_, id) => {
qc.invalidateQueries({ queryKey: libraryKeys.detail(id) });
},
});
}
export function useHalachotPending(limit = 200) {
return useQuery({
queryKey: libraryKeys.halachotPending(),
queryFn: ({ signal }) =>
apiRequest<{ items: Halacha[]; count: number }>(
`/api/halachot?review_status=pending_review&limit=${limit}`,
{ signal },
),
staleTime: 5_000,
refetchOnMount: "always",
});
}
export type HalachaPatch = Partial<{
review_status: "pending_review" | "approved" | "rejected" | "published";
reviewer: string;
rule_statement: string;
reasoning_summary: string;
subject_tags: string[];
practice_areas: string[];
}>;
export function useUpdateHalacha() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, patch }: { id: string; patch: HalachaPatch }) =>
apiRequest<Halacha>(
`/api/halachot/${encodeURIComponent(id)}`,
{ method: "PATCH", body: patch },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: libraryKeys.all });
},
});
}

View File

@@ -3514,3 +3514,314 @@ async def _process_training_document(task_id: str, source: Path, req: ClassifyRe
"chunks": chunk_count, "chunks": chunk_count,
}, },
}) })
# ── External Precedent Library ────────────────────────────────────
# Chair-uploaded court rulings + appeals committee decisions, with
# automatic halacha extraction. Distinct from /api/training (style
# corpus) and /api/cases/{n}/precedents (chair-attached quotes).
from legal_mcp.services import precedent_library as plib_service # noqa: E402
_PRACTICE_AREAS = {"", "rishuy_uvniya", "betterment_levy", "compensation_197"}
_SOURCE_TYPES = {"", "court_ruling", "appeals_committee"}
def _make_progress_publisher(task_id: str, filename: str):
"""Build an async callback that pipes ingestion progress to Redis."""
async def publish(status: str, percent: int, message: str) -> None:
await _progress.set(task_id, {
"status": status if status in ("completed", "failed") else "processing",
"stage": status,
"filename": filename,
"step": message,
"percent": percent,
})
return publish
class PrecedentUpdateRequest(BaseModel):
case_name: str | None = None
court: str | None = None
decision_date: str | None = None
practice_area: str | None = None
appeal_subtype: str | None = None
subject_tags: list[str] | None = None
summary: str | None = None
headnote: str | None = None
key_quote: str | None = None
source_url: str | None = None
source_type: str | None = None
precedent_level: str | None = None
is_binding: bool | None = None
class HalachaUpdateRequest(BaseModel):
review_status: str | None = None
reviewer: str | None = "דפנה"
rule_statement: str | None = None
reasoning_summary: str | None = None
subject_tags: list[str] | None = None
practice_areas: list[str] | None = None
@app.post("/api/precedent-library/upload")
async def precedent_library_upload(
file: UploadFile = File(...),
citation: str = Form(...),
case_name: str = Form(""),
court: str = Form(""),
decision_date: str = Form(""),
source_type: str = Form(""),
precedent_level: str = Form(""),
practice_area: str = Form(""),
appeal_subtype: str = Form(""),
subject_tags: str = Form("[]"), # JSON array string
is_binding: bool = Form(True),
headnote: str = Form(""),
summary: str = Form(""),
):
"""Upload a court ruling / appeals committee decision to the
authoritative precedent library. Halachot are extracted in the
background and queued for chair approval.
"""
if practice_area not in _PRACTICE_AREAS:
raise HTTPException(400, "practice_area לא תקין")
if source_type not in _SOURCE_TYPES:
raise HTTPException(400, "source_type לא תקין")
if not citation.strip():
raise HTTPException(400, "citation חובה")
suffix = Path(file.filename or "").suffix.lower()
if suffix not in ALLOWED_EXTENSIONS:
raise HTTPException(400, f"סוג קובץ לא נתמך: {suffix}")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
staged = UPLOAD_DIR / f"plib_{uuid4().hex[:8]}_{file.filename}"
size = 0
with staged.open("wb") as out:
while chunk := await file.read(1024 * 1024):
size += len(chunk)
if size > MAX_FILE_SIZE:
staged.unlink(missing_ok=True)
raise HTTPException(413, "קובץ גדול מדי")
out.write(chunk)
try:
tags = json.loads(subject_tags) if subject_tags else []
if not isinstance(tags, list):
tags = []
except json.JSONDecodeError:
tags = []
task_id = str(uuid4())
await _progress.set(task_id, {
"status": "queued", "filename": file.filename or "",
"stage": "queued", "percent": 0,
})
publish = _make_progress_publisher(task_id, file.filename or "")
async def _run():
try:
await plib_service.ingest_precedent(
file_path=staged,
citation=citation.strip(),
case_name=case_name.strip(),
court=court.strip(),
decision_date=decision_date or None,
source_type=source_type,
precedent_level=precedent_level,
practice_area=practice_area,
appeal_subtype=appeal_subtype.strip(),
subject_tags=tags,
is_binding=is_binding,
headnote=headnote.strip(),
summary=summary.strip(),
progress=publish,
)
except Exception as e:
logger.exception("precedent-library upload failed")
await _progress.set(task_id, {
"status": "failed", "error": str(e),
"filename": file.filename or "",
})
finally:
staged.unlink(missing_ok=True)
asyncio.create_task(_run())
return {"task_id": task_id}
@app.get("/api/precedent-library")
async def precedent_library_list(
practice_area: str = "",
court: str = "",
precedent_level: str = "",
source_type: str = "",
search: str = "",
limit: int = 100,
offset: int = 0,
):
rows = await db.list_external_case_law(
practice_area=practice_area, court=court,
precedent_level=precedent_level, source_type=source_type,
search=search, limit=limit, offset=offset,
)
return {"items": rows, "count": len(rows)}
@app.get("/api/precedent-library/stats")
async def precedent_library_stats():
return await db.precedent_library_stats()
@app.get("/api/precedent-library/search")
async def precedent_library_search(
q: str,
practice_area: str = "",
court: str = "",
precedent_level: str = "",
appeal_subtype: str = "",
subject_tag: str = "",
limit: int = 10,
include_halachot: bool = True,
):
if not q or len(q.strip()) < 2:
return {"items": [], "count": 0}
results = await plib_service.search_library(
query=q.strip(),
practice_area=practice_area,
court=court,
precedent_level=precedent_level,
appeal_subtype=appeal_subtype,
subject_tag=subject_tag,
limit=limit,
include_halachot=include_halachot,
)
return {"items": results, "count": len(results)}
@app.get("/api/precedent-library/{case_law_id}")
async def precedent_library_get(case_law_id: str):
try:
cid = UUID(case_law_id)
except ValueError:
raise HTTPException(400, "case_law_id לא תקין")
record = await plib_service.get_precedent(cid)
if not record:
raise HTTPException(404, "פסיקה לא נמצאה")
return record
@app.patch("/api/precedent-library/{case_law_id}")
async def precedent_library_update(case_law_id: str, req: PrecedentUpdateRequest):
try:
cid = UUID(case_law_id)
except ValueError:
raise HTTPException(400, "case_law_id לא תקין")
fields = {k: v for k, v in req.model_dump(exclude_unset=True).items() if v is not None}
if "practice_area" in fields and fields["practice_area"] not in _PRACTICE_AREAS:
raise HTTPException(400, "practice_area לא תקין")
if "decision_date" in fields and fields["decision_date"]:
try:
from datetime import date as date_type
fields["date"] = date_type.fromisoformat(fields.pop("decision_date")[:10])
except ValueError:
raise HTTPException(400, "decision_date לא תקין")
record = await db.update_case_law(cid, **fields)
if not record:
raise HTTPException(404, "פסיקה לא נמצאה")
return record
@app.delete("/api/precedent-library/{case_law_id}")
async def precedent_library_delete(case_law_id: str):
try:
cid = UUID(case_law_id)
except ValueError:
raise HTTPException(400, "case_law_id לא תקין")
ok = await plib_service.delete_precedent(cid)
if not ok:
raise HTTPException(404, "פסיקה לא נמצאה")
return {"deleted": True, "case_law_id": case_law_id}
@app.post("/api/precedent-library/{case_law_id}/extract-halachot")
async def precedent_library_reextract(case_law_id: str):
"""Re-run halacha extraction in background. Returns a task_id for SSE."""
try:
cid = UUID(case_law_id)
except ValueError:
raise HTTPException(400, "case_law_id לא תקין")
record = await db.get_case_law(cid)
if not record:
raise HTTPException(404, "פסיקה לא נמצאה")
task_id = str(uuid4())
label = record.get("case_number") or case_law_id
await _progress.set(task_id, {
"status": "queued", "filename": label, "stage": "queued", "percent": 0,
})
publish = _make_progress_publisher(task_id, label)
async def _run():
try:
await plib_service.reextract_halachot(cid, progress=publish)
except Exception as e:
logger.exception("re-extract halachot failed")
await _progress.set(task_id, {
"status": "failed", "error": str(e), "filename": label,
})
asyncio.create_task(_run())
return {"task_id": task_id}
@app.get("/api/halachot")
async def halachot_list(
case_law_id: str = "",
review_status: str = "",
practice_area: str = "",
limit: int = 200,
offset: int = 0,
):
cid: UUID | None = None
if case_law_id:
try:
cid = UUID(case_law_id)
except ValueError:
raise HTTPException(400, "case_law_id לא תקין")
rows = await db.list_halachot(
case_law_id=cid,
review_status=review_status or None,
practice_area=practice_area or None,
limit=limit, offset=offset,
)
return {"items": rows, "count": len(rows)}
@app.patch("/api/halachot/{halacha_id}")
async def halacha_update(halacha_id: str, req: HalachaUpdateRequest):
"""Approve / reject / edit a halacha. Used by the chair review queue."""
try:
hid = UUID(halacha_id)
except ValueError:
raise HTTPException(400, "halacha_id לא תקין")
if req.review_status and req.review_status not in {
"pending_review", "approved", "rejected", "published",
}:
raise HTTPException(400, "review_status לא תקין")
row = await db.update_halacha(
halacha_id=hid,
review_status=req.review_status,
reviewer=req.reviewer or "",
rule_statement=req.rule_statement,
reasoning_summary=req.reasoning_summary,
subject_tags=req.subject_tags,
practice_areas=req.practice_areas,
)
if not row:
raise HTTPException(404, "הלכה לא נמצאה")
return row