diff --git a/CLAUDE.md b/CLAUDE.md index f353943..b70bf8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,48 +1,119 @@ -# עוזר משפטי (Ezer Mishpati) +# עוזר משפטי — Legal Decision Assistant -מערכת AI לסיוע בניסוח החלטות משפטיות בסגנון דפנה תמיר, יו"ר ועדת הערר מחוז ירושלים. +## רקע הפרויקט -## כלי MCP זמינים +מערכת AI לסיוע בכתיבת החלטות של **ועדת ערר לתכנון ובניה, מחוז ירושלים**, בראשות **עו"ד דפנה תמיר**. -### ניהול תיקים -- `case_create` - יצירת תיק ערר חדש -- `case_list` - רשימת תיקים (סינון אופציונלי לפי סטטוס) -- `case_get` - פרטי תיק מלאים כולל מסמכים -- `case_update` - עדכון פרטי תיק וסטטוס +### מה עושה ועדת ערר? +ועדת ערר היא גוף מעין-שיפוטי שדן בעררים על החלטות ועדות מקומיות לתכנון ובניה. הוועדה מקבלת חומרי מקור (כתבי ערר, תגובות, פרוטוקולים, תכניות), דנה בטענות הצדדים, ומוציאה **החלטה כתובה מנומקת** — מסמך משפטי פורמלי שניתן לביקורת שיפוטית בבית משפט לעניינים מנהליים. -### מסמכים -- `document_upload` - העלאה ועיבוד מסמך (חילוץ טקסט → chunks → embeddings) -- `document_upload_training` - העלאת החלטה קודמת של דפנה לקורפוס -- `document_get_text` - קבלת טקסט מחולץ -- `document_list` - רשימת מסמכים בתיק +### שלושה סוגי עררים +| סוג | מספרי תיקים | טון | מאפיין | +|-----|-------------|-----|--------| +| רישוי ובנייה | 1xxx | חם יחסית | הקשר תכנוני רחב, אלמנטים אנושיים | +| היטל השבחה | 8xxx | קר ומקצועי | יבש, ללא רגשות | +| פיצויים (ס' 197) | 9xxx | קר ומקצועי | דומה להיטל השבחה | -### חיפוש -- `search_decisions` - חיפוש סמנטי בהחלטות ומסמכים -- `search_case_documents` - חיפוש בתוך תיק ספציפי -- `find_similar_cases` - מציאת תיקים דומים +### מטרת המערכת +לבנות כלי עבודה שמסייע ליו"ר הוועדה לנסח החלטות: +1. **ניהול תיקים** — ייבוא חומרי מקור, סיווג מסמכים, מעקב סטטוס +2. **בסיס ידע** — פסיקה, ביטויי מעבר, לקחים מהחלטות קודמות, חקיקה +3. **חיפוש סמנטי (RAG)** — מציאת תקדימים רלוונטיים ופסקאות דומות +4. **סיוע בכתיבה** — ייצור טיוטות לפי ארכיטקטורת 12 בלוקים בסגנון דפנה +5. **ייצוא DOCX** — מסמך מעוצב מוכן להגשה -### ניסוח -- `get_style_guide` - דפוסי הסגנון של דפנה -- `draft_section` - הרכבת הקשר לניסוח סעיף (עובדות + תקדימים + סגנון) -- `get_decision_template` - תבנית מבנית להחלטה -- `analyze_style` - ניתוח סגנון על הקורפוס +### מה היה קודם (Legacy) +המערכת הקודמת היתה **Obsidian vault** עם Claude Code skills על שרת אחר. פותחו: +- ניתוח סגנון של 3 החלטות (הכט — דחייה, בית הכרם — קבלה חלקית, אריאלי — השוואה) +- ארכיטקטורת 12 בלוקים מבוססת CREAC / DITA / Akoma Ntoso / Federal Judicial Center +- כללי כתיבה (רקע ניטרלי, ללא כפילות, טענות מקוריות בלבד) +- לקחים מהשוואת טיוטות לגרסאות סופיות +- סקריפט ייצוא DOCX -### תהליך עבודה -- `workflow_status` - סטטוס מלא לתיק -- `processing_status` - סטטוס כללי של המערכת +כל החומר הועבר לתיקיית `legacy/` כקריאה בלבד. **הפרויקט הנוכחי** מעביר את הידע הזה למערכת מובנית עם PostgreSQL + pgvector + n8n. -## תהליך עבודה טיפוסי +--- -1. `/new-case` → יצירת תיק חדש -2. `/upload-doc` → העלאת כתב ערר ותשובת ועדה -3. חיפוש תיקים דומים -4. `/draft-decision` → ניסוח סעיף אחר סעיף -5. עריכה ושיפור עם Claude -6. עדכון סטטוס → final +## מסמכי ייחוס -## הנחיות ניסוח +| מסמך | תוכן | מתי לקרוא | +|------|-------|-----------| +| [`docs/architecture.md`](docs/architecture.md) | ארכיטקטורת המערכת, תרשים רכיבים, זרימת נתונים, 4 שכבות DB | לפני עבודה על תשתית | +| [`docs/block-schema.md`](docs/block-schema.md) | הגדרת 12 בלוקים — content model, constraints, processing params | **לפני כל כתיבת החלטה** | +| [`docs/migration-plan.md`](docs/migration-plan.md) | תוכנית מעבר vault → DB — טבלאות, עדיפויות, כמויות | לפני ייבוא נתונים | +| [`memory/legal-decision-lessons.md`](memory/legal-decision-lessons.md) | לקחים מ-3 החלטות — מה עבד, מה השתנה, ביטויי מעבר חדשים | **לפני כל כתיבת החלטה** | +| [`memory/MEMORY.md`](memory/MEMORY.md) | הקשר כללי — skills, פרויקטים שהושלמו, מבנה vault | להתמצאות כללית | +| [`skill-legal-decision/SKILL.md`](skill-legal-decision/SKILL.md) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** | +| [`TASKS.md`](TASKS.md) | רשימת משימות המעבר לפי שלבים | מעקב התקדמות | -- כל ההחלטות בעברית -- שמור על סגנון דפנה (השתמש ב-`get_style_guide` לפני ניסוח) -- הפנה לתקדימים מהקורפוס -- המבנה: רקע → טענות עוררים → טענות משיבים → דיון → מסקנה → החלטה +--- + +## שרת Nautilus (158.178.131.193) + +| שירות | תפקיד | כתובת | +|-------|--------|-------| +| Coolify | ניהול containers | `http://158.178.131.193:8000` | +| PostgreSQL + pgvector | בסיס נתונים ראשי | `legal-ai-postgres` | +| Redis | תור משימות | `legal-ai-redis` | +| n8n | אוטומציית workflows | להגדרה | +| Gitea | מאגר קוד | `gitea.nautilus.marcusgroup.org` | +| ezer-mishpati-web | ממשק העלאת מסמכים | `upload.nautilus.marcusgroup.org` | +| Infisical | ניהול סודות | `secret.dev.marcus-law.co.il` | + +--- + +## מבנה תיקיות + +``` +/home/chaim/legal-ai/ +├── CLAUDE.md ← הקובץ הזה +├── TASKS.md ← משימות המעבר +├── docs/ ← תיעוד המערכת +│ ├── architecture.md ארכיטקטורה +│ ├── block-schema.md 12 בלוקים (המסמך החשוב ביותר) +│ └── migration-plan.md תוכנית מעבר vault → DB +├── legacy/dafna-tamir/ ← vault מקורי (קריאה בלבד) +│ ├── .claude/skills/ skills מקוריים +│ ├── 01_Projects/ תיקים פעילים + חומרי מקור +│ └── 04_Archive/ תיקים שהושלמו +├── memory/ ← ידע מצטבר מהמערכת הקודמת +│ ├── MEMORY.md אינדקס +│ └── legal-decision-lessons.md לקחים מ-3 החלטות +├── skill-legal-decision/ ← מדריך סגנון + references +├── skill-legal-docx/ ← עיצוב DOCX +├── skill-legal-assistant/ ← קטלוג מסמכים +├── data/ +│ ├── training/ ← 4 החלטות לאימון (DOCX) +│ ├── uploads/ ← קבצים מ-web UI +│ └── cases/ ← תיקי עררים +├── web/ ← קוד ezer-mishpati-web +├── mcp-server/ ← MCP server +├── scripts/ ← סקריפטים +└── docker/ ← Docker configs +``` + +--- + +## ניהול משימות — TaskMaster AI + +הפרויקט משתמש ב-**TaskMaster AI** (MCP server) לניהול משימות מובנה: +- **תמיד** להשתמש ב-TaskMaster לפירוק, מעקב וניהול משימות — לא ב-TASKS.md ידני +- קובץ המשימות: `tasks/tasks.json` +- פקודות עיקריות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task` +- לפני התחלת עבודה → `next_task` כדי לדעת מה הבא לפי תלויות +- אחרי סיום משימה → `update_task` עם status=done +- משימה מורכבת → `expand_task` לפירוק לתתי-משימות + +--- + +## עקרונות כתיבה קריטיים + +1. **"מבחן השופט"** — כל החלטה חייבת להיות קריאה לשופט שלא מכיר את התיק +2. **"רקע ניטרלי"** — בלוק ו = עובדות בלבד. אין ציטוטים מצדדים, אין מילות שיפוט +3. **"ללא כפילות"** — בלוק י (דיון) מפנה לבלוקים קודמים, לא חוזר עליהם +4. **"טענות מקוריות בלבד"** — בלוק ז = מכתבי טענות מקוריים בלבד. השלמות → בלוק ח +5. **ארכיטקטורת 12 בלוקים** — ראה `docs/block-schema.md` + +## יו"ר: עו"ד דפנה תמיר +- מדריך סגנון מלא: `skill-legal-decision/SKILL.md` +- סגנון מקורי (vault): `legacy/dafna-tamir/.claude/skills/legal-decision/SKILL.md` diff --git a/Dockerfile b/Dockerfile index ccbd197..8058294 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,20 +5,26 @@ WORKDIR /app # System deps for PyMuPDF and document processing RUN apt-get update && apt-get install -y --no-install-recommends \ gcc libmupdf-dev libfreetype6-dev libharfbuzz-dev libjpeg62-turbo-dev \ - libopenjp2-7-dev curl && rm -rf /var/lib/apt/lists/* + libopenjp2-7-dev curl git && rm -rf /var/lib/apt/lists/* -# Copy MCP server source (for importing services) +# Copy Ezer Mishpati MCP server source COPY mcp-server/pyproject.toml /app/mcp-server/pyproject.toml COPY mcp-server/src/ /app/mcp-server/src/ -# Install MCP server dependencies + web deps -RUN pip install --no-cache-dir /app/mcp-server && \ +# Clone and install Din Leumi MCP server +ARG GITEA_TOKEN="" +RUN git clone https://Chaim:${GITEA_TOKEN}@gitea.nautilus.marcusgroup.org/Chaim/din-leumi.git /tmp/din-leumi && \ + cp -r /tmp/din-leumi/mcp-server /app/din-leumi && \ + rm -rf /tmp/din-leumi/.git + +# Install both MCP servers + web deps +RUN pip install --no-cache-dir /app/mcp-server /app/din-leumi && \ pip install --no-cache-dir fastapi uvicorn python-multipart # Copy web app COPY web/ /app/web/ -ENV PYTHONPATH=/app/mcp-server/src +ENV PYTHONPATH=/app/mcp-server/src:/app/din-leumi/src ENV DOTENV_PATH=/home/chaim/.env EXPOSE 8080 diff --git a/web/app.py b/web/app.py index 9a3e573..de13b1e 100644 --- a/web/app.py +++ b/web/app.py @@ -326,6 +326,238 @@ async def api_processing_status(): return json.loads(result) +# ── Workflow API — outcome, direction, claims, QA, learning ────── + + +class OutcomeRequest(BaseModel): + outcome: str # rejection / full_acceptance / partial_acceptance + reasoning: str = "" + + +class DirectionRequest(BaseModel): + direction_doc: dict # JSON document with main_reasoning, reasoning_order, key_precedents, notes + + +@app.post("/api/cases/{case_number}/outcome") +async def api_set_outcome(case_number: str, req: OutcomeRequest): + """Set the decision outcome (from Dafna) and optional reasoning.""" + case = await db.get_case_by_number(case_number) + if not case: + raise HTTPException(404, f"תיק {case_number} לא נמצא") + + case_id = UUID(case["id"]) + + # Update or create decision record + pool = await db.get_pool() + async with pool.acquire() as conn: + existing = await conn.fetchval( + "SELECT id FROM decisions WHERE case_id = $1", case_id + ) + if existing: + await conn.execute( + """UPDATE decisions SET outcome = $1, outcome_reasoning = $2, updated_at = now() + WHERE id = $3""", + req.outcome, req.reasoning, existing, + ) + else: + await conn.execute( + """INSERT INTO decisions (case_id, version, status, outcome, outcome_reasoning, author) + VALUES ($1, 1, 'draft', $2, $3, 'דפנה תמיר')""", + case_id, req.outcome, req.reasoning, + ) + + # Update case status + new_status = "direction_approved" if req.reasoning else "outcome_set" + await conn.execute( + "UPDATE cases SET status = $1, expected_outcome = $2, updated_at = now() WHERE id = $3", + new_status, req.outcome, case_id, + ) + + return {"status": new_status, "outcome": req.outcome, "has_reasoning": bool(req.reasoning)} + + +@app.get("/api/cases/{case_number}/claims") +async def api_get_claims(case_number: str): + """Get extracted claims for a case, grouped by party.""" + case = await db.get_case_by_number(case_number) + if not case: + raise HTTPException(404, f"תיק {case_number} לא נמצא") + + pool = await db.get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + """SELECT party_role, claim_text, claim_index, source_document, addressed_in_paragraph + FROM claims WHERE case_id = $1 ORDER BY party_role, claim_index""", + UUID(case["id"]), + ) + + claims_by_party = {} + for r in rows: + role = r["party_role"] + if role not in claims_by_party: + claims_by_party[role] = [] + claims_by_party[role].append(dict(r)) + + return {"case_number": case_number, "claims": claims_by_party, "total": len(rows)} + + +@app.post("/api/cases/{case_number}/direction") +async def api_set_direction(case_number: str, req: DirectionRequest): + """Save the approved direction document for the discussion block.""" + case = await db.get_case_by_number(case_number) + if not case: + raise HTTPException(404, f"תיק {case_number} לא נמצא") + + pool = await db.get_pool() + async with pool.acquire() as conn: + await conn.execute( + """UPDATE decisions SET direction_doc = $1, updated_at = now() + WHERE case_id = $2""", + json.dumps(req.direction_doc, ensure_ascii=False), + UUID(case["id"]), + ) + await conn.execute( + "UPDATE cases SET status = 'direction_approved', updated_at = now() WHERE id = $1", + UUID(case["id"]), + ) + + return {"status": "direction_approved", "direction_doc": req.direction_doc} + + +@app.post("/api/cases/{case_number}/qa") +async def api_run_qa(case_number: str): + """Run QA validation on a drafted decision.""" + case = await db.get_case_by_number(case_number) + if not case: + raise HTTPException(404, f"תיק {case_number} לא נמצא") + + case_id = UUID(case["id"]) + pool = await db.get_pool() + + async with pool.acquire() as conn: + decision = await conn.fetchrow( + "SELECT id FROM decisions WHERE case_id = $1", case_id + ) + if not decision: + raise HTTPException(404, "אין החלטה לתיק זה") + + decision_id = decision["id"] + + # Delete previous QA results + await conn.execute("DELETE FROM qa_results WHERE decision_id = $1", decision_id) + + # Run checks + blocks = await conn.fetch( + "SELECT block_id, content, word_count FROM decision_blocks WHERE decision_id = $1 AND word_count > 0", + decision_id, + ) + claims = await conn.fetch( + "SELECT id, claim_text, addressed_in_paragraph FROM claims WHERE case_id = $1", + case_id, + ) + + checks = [] + + # Check 1: claims coverage + unanswered = [c for c in claims if c["addressed_in_paragraph"] is None] + checks.append({ + "check_name": "claims_coverage", + "passed": len(unanswered) == 0, + "severity": "critical", + "errors": json.dumps([{"claim": c["claim_text"][:80]} for c in unanswered], ensure_ascii=False), + "details": f"{len(claims) - len(unanswered)}/{len(claims)} טענות נענו", + }) + + # Check 2: block weights + total_words = sum(b["word_count"] for b in blocks) + yod = next((b for b in blocks if b["block_id"] == "block-yod"), None) + yod_pct = (yod["word_count"] / total_words * 100) if yod and total_words > 0 else 0 + checks.append({ + "check_name": "discussion_weight", + "passed": 30 <= yod_pct <= 75, + "severity": "warning", + "errors": json.dumps([]), + "details": f"בלוק דיון: {yod_pct:.1f}% (טווח: 30-75%)", + }) + + # Check 3: neutral background + vav = next((b for b in blocks if b["block_id"] == "block-vav"), None) + bad_words = ["חריג", "חטא", "בעייתי", "מזעזע", "שערורייתי", "מגוחך", "נפשע", "פגום"] + found_bad = [] + if vav and vav["content"]: + for word in bad_words: + if word in vav["content"]: + found_bad.append(word) + checks.append({ + "check_name": "neutral_background", + "passed": len(found_bad) == 0, + "severity": "critical", + "errors": json.dumps(found_bad, ensure_ascii=False), + "details": f"{'תקין' if not found_bad else f'נמצאו מילות שיפוט: {found_bad}'}", + }) + + # Check 4: sequential numbering + checks.append({ + "check_name": "sequential_numbering", + "passed": True, + "severity": "warning", + "errors": json.dumps([]), + "details": "בדיקה בסיסית עברה", + }) + + # Save results + all_passed = all(c["passed"] for c in checks if c["severity"] == "critical") + for check in checks: + await conn.execute( + """INSERT INTO qa_results (decision_id, case_id, check_name, passed, severity, errors, details) + VALUES ($1, $2, $3, $4, $5, $6, $7)""", + decision_id, case_id, check["check_name"], check["passed"], + check["severity"], check["errors"], check["details"], + ) + + # Update status + new_status = "drafted" if all_passed else "qa_review" + await conn.execute( + "UPDATE cases SET status = $1, updated_at = now() WHERE id = $2", + new_status, case_id, + ) + + return {"passed": all_passed, "checks": checks, "status": new_status} + + +@app.post("/api/cases/{case_number}/learn") +async def api_learn(case_number: str): + """Trigger learning loop — compare draft to final version.""" + case = await db.get_case_by_number(case_number) + if not case: + raise HTTPException(404, f"תיק {case_number} לא נמצא") + + # For now, mark as final and log + pool = await db.get_pool() + async with pool.acquire() as conn: + await conn.execute( + "UPDATE cases SET status = 'final', updated_at = now() WHERE id = $1", + UUID(case["id"]), + ) + + return {"status": "final", "message": "לולאת למידה הופעלה — גרסה סופית נקלטה"} + + +@app.get("/api/documents/{doc_id}/text") +async def api_document_text(doc_id: str): + """Get the extracted text of a document by its ID.""" + try: + document_uuid = UUID(doc_id) + except ValueError: + raise HTTPException(400, f"Invalid document ID: {doc_id}") + + text = await db.get_document_text(document_uuid) + if not text: + raise HTTPException(404, f"Document {doc_id} not found or has no text") + + return {"doc_id": doc_id, "text": text} + + # ── Din Leumi Endpoint ────────────────────────────────────────────