25 Commits

Author SHA1 Message Date
dde44b8095 docs(lessons): עדכון התזה המשפטית לפי הסופי של דפנה ב-8125-09-24
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 32s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
מ-8125-09-24 (שובל/רמות אלון) דפנה הפכה את התזה שלנו: אישרה "הסבת שטחים"
מחיר-למשתכן→שוק-חופשי כהשבחה תכנונית בת-חיוב, וקבעה ששומה מכרעת סמוכה
משכנעת-לא-מחייבת (ריסון דוקטרינת השוויון). מקודד ב-legal-decision-lessons.md
(#47–#50) + מצביע ב-SKILL §7.3 (תת-מסלול שמאי-מייעץ).

Invariants: G11 (תוכן משפטי — סמכות היו"ר; ה-PR הוא שער-האישור, INV-LRN1).
INV-LRN5 מקוים — מהות במסמך-הלקחים/SKILL, לא בשכבת-הקול. חבירה לבית-אב
canonical_halachot נדחתה למשימת-תכנון #225.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

tsc --noEmit + eslint clean.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:31:21 +00:00
36 changed files with 1935 additions and 251 deletions

View File

@@ -222,18 +222,22 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
---
## §7. סטטוסי תיק תקפים (case status flow)
## §7. סטטוסי תיק (case status flow)
הסטטוסים שאתה עשוי לראות ב-`case.status` (לפי `legal-ceo.md` "מפת סטטוסים"):
**מקור-האמת היחיד** למודל-הסטטוסים: `mcp-server/src/legal_mcp/case_status_model.py` (חשוף ב-`GET /api/status-model`; ה-enum, ה-`STATUS_ORDER` וה-frontend נגזרים ממנו). 12 הסטטוסים הקנוניים, לפי 5 השלבים:
```
new → proofread → documents_ready → analyst_verified → research_complete*
קליטה : new · processing
הכנה : documents_ready
ניתוח וכיוון : analyst_verified · research_complete · outcome_set · direction_approved
→ [שער שטן-מליץ: red-team אוטומטי → עצירת-אישור לידים ע"י היו"ר]
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
→ drafted → qa_passed / qa_failed → exported
כתיבת טיוטה : qa_review · drafted
סגירה : exported · reviewed · final
```
`research_complete` **valid status** (לא legacy מחוסר תוקף). מנותב ע"י `legal-researcher.md` שלב 5 כשמחקר תקדימים רץ בנפרד מהמנתח (תרחיש מתקדם). ה-CEO יודע לטפל בו כאילו זה `analyst_verified` (ראה `legal-ceo.md` "מפת סטטוסים").
`analyst_verified` ו-`research_complete` הם **סטטוסים קנוניים מהמעלה הראשונה** (לא legacy) — המנתח/חוקר מציבים אותם, ומקומם בשלב "ניתוח וכיוון". `research_complete` מנותב ע"י `legal-researcher.md` שלב 5 כשמחקר תקדימים רץ בנפרד מהמנתח; ה-CEO מטפל בו כמו `analyst_verified`.
> מצבי-ביניים ישנים שעדיין עשויים להופיע מסוכנים מסוימים (`proofread`, `analysis_enriched`, `ready_for_writing`, `qa_passed`/`qa_failed`) **אינם** בקבוצה הקנונית — הם נמפים-לשלב לתצוגה בלבד (fallback ב-`case-status.ts`) ואינם ניתנים-לבחירה ידנית. אם נדרש לקבע אחד מהם — להוסיף ל-`case_status_model.py` (המקור-היחיד).
> **שער שטן-מליץ (red-team) — שער-קבע אחרי הניתוח (ל-CEO).** ברגע שהניתוח עבר (`analyst_verified`/`research_complete`, `analysis-and-research.md` תקין), ה-CEO **חייב** להפעיל אוטומטית את שטן-מליץ (Gemini, `gemini_local`) **לפני** הכותב, ואז **לעצור לאישור-יו"ר של הלידים** (issue ראשי ל-`in_review`). רק לידים שהיו"ר אישר מומרים ל-`chair_directions` (`record_chair_feedback` → `get_chair_directions` → `approve_direction`); הלידים הגולמיים **לעולם לא** מוזנים לכותב או להחלטה — קלט-יו"ר בלבד (G10 / INV-AH / INV-LRN5). זרימה מלאה: `legal-ceo.md` "שלב A2"/"שלב A3". זה שער-קבע, לא on-demand.

View File

@@ -806,7 +806,7 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
| `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) |
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified`, ואז **שלב A2 (שער שטן-מליץ, אוטומטי)** — אל תקפוץ לשלב B לפני שהשער רץ והיו"ר אישר לידים (A3) |
| `analyst_verified` | CEO (אחרי שלב A) | → **קודם שער שטן-מליץ:** אם אין `critique-gemini.md` → שלב A2 (הפעל שטן-מליץ אוטומטית). אם יש `critique-gemini.md` שטרם הוצג ליו"ר → שלב A3 (עצירת-אישור לידים). **רק אחרי שהיו"ר אישר/דחה לידים** → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
| `research_complete` | מנתח / חוקר תקדימים (valid status — legacy + תרחישים מתקדמים) | → **קודם שער שטן-מליץ (A2/A3)** כמו ב-`analyst_verified`, ורק אחרי אישור-יו"ר ללידים → שלב B. **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך לשער שטן-מליץ ואז §B. |
| `research_complete` | מנתח / חוקר תקדימים (**סטטוס קנוני**, שלב "ניתוח וכיוון" — מקור-אמת: `case_status_model.py`) | → **קודם שער שטן-מליץ (A2/A3)** כמו ב-`analyst_verified`, ורק אחרי אישור-יו"ר ללידים → שלב B. **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך לשער שטן-מליץ ואז §B. |
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |

View File

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

View File

@@ -531,3 +531,33 @@ Two 1200-25 entries recorded a case-specific issue order (threshold → plan int
traffic) with no generalizable rule. They are case artifacts, captured in that case's
analysis-and-research.md — no general lesson folded.
---
## Doctrinal Lessons — Chair Final vs. Our Draft, 8125-09-24 (July 5, 2026)
### Source
- Case: 8125-09-24 (שובל טוב רמות / רמות אלון — ערר על שומה מכרעת, היטל השבחה; קבלה
חלקית + מינוי שמאי מייעץ חדש). Dafna's signed final vs. our full draft (block י).
- These are **substantive doctrine**, not style. Recorded verbatim from the signed
decision; also captured as `chair_feedback` ד1ד4 on the case (category `other`),
pending a proper canonical-principle home (TaskMaster #225).
-**#47 and #48 SUPERSEDE our prior draft's legal thesis.** Our draft parked the
chargeability question as an "open interpretive question" and over-weighted the
equality doctrine toward the appellant; Dafna decided the opposite. Follow the chair.
### 47. "Space Conversion" מחיר-למשתכן→שוק-חופשי IS a Chargeable Planning Betterment (SUPERSEDES prior thesis)
- **Lesson:** כאשר הקלה מוסיפה יחידות דיור (בלי תוספת זכויות/שטח עיקרי) ומשנה את תמהיל הדירות כך ששטחים שבמצב הקודם היו כפופים למגבלת "מחיר למשתכן" משתחררים לשיווק בשוק חופשי — זו **השבחה תכנונית בת-חיוב**, ומתודת ה"הסבה" (הפרש השווי מחיר-למשתכן↔שוק-חופשי על השטחים שהוסבו) לגיטימית ומנומקת: "לא ייתכן כי יזם 'ייהנה' משווי נמוך יותר לצורך חיוב בהיטל לאור מגבלות מחיר למשתכן כשזה משרת אותו". **אין** לגדֵר זאת כשאלה משפטית-פרשנית פתוחה בדבר "גדרי השבחה תכנונית" ולהטיל ספק בחיוב מכוח דרישת הקשר-הסיבתי (בית הכרם/פמיני) — זו הייתה גישת הטיוטה שלנו, והיא נדחתה.
- **Rule:** In מחיר-למשתכן projects, treat the free-market "space conversion" enabled by a unit-count relief as a chargeable planning betterment and uphold the difference-in-value method. Do NOT frame it as an open interpretive question or cast causal-nexus doubt on chargeability.
### 48. A Neighboring Final Assessment Does Not Compel Alignment; Equality Does Not Perpetuate Error
- **Lesson:** קיום שומה מכרעת חלוטה בנכס סמוך (אף באותו מכרז ותנאים) **אינו מחייב** את השמאי המכריע/הוועדה ליישר קו עמה; שומה מנומקת היטב רשאית לסטות ממנה — "לא נוכל להיאחז בשומה מכרעת אחרת רק מכיוון והיא קיימת". עקרון השוויון בגביית מס אינו מנציח טעות. אין להעמיס את דוקטרינת השוויון (דיבון שלום/גנדליס) כ"חובת הנמקה מוגברת" המטה לקבלת הערר — כפי שעשתה הטיוטה שלנו. עדיין: פער ניכר מול שומה סמוכה מצדיק בחינה והנמקה, לא אימוץ אוטומטי. **הערת-לוואי (ד3):** ועדה מקומית החולקת על שומה מכרעת ובוחרת שלא לערור עליה — ראוי שתיידע את הציבור; שינוי-עמדה שלה עם הזמן אפשרי אך צריך להיות החריג ומחייב הנמקה מוגברת.
- **Rule:** A final neighboring assessment is persuasive, not binding; a well-reasoned assessment may depart from it. Do not over-weight the equality doctrine to favor the appellant — flag the gap, require reasoning, but do not auto-align.
### 49. When Remand-to-the-Original-Assessor Is Blocked, Appoint a New Advisory Assessor
- **Lesson:** כשדרך-המלך (השבת השומה לשמאי המכריע לתיקון/הבהרה) חסומה מפני שהשמאי המכריע פרש/סיים כהונתו — מינוי **שמאי מייעץ חדש** לבחינה מחודשת של הרכיב שבמחלוקת הוא הפתרון, מכוח סמכות הוועדה הרחבה (אשקלוני) והיגיון גנדליס.
- **Rule:** If the deciding assessor has left office and cannot receive the file back, appoint a new advisory assessor to re-examine the disputed component under the committee's guidance.
### 50. Prefer a Reasoned Holding Over a Parked "Open Question" (methodology)
- **Lesson:** במקום לגדר סוגיה מהותית כ"שאלה משפטית פתוחה לשתי גישות לגיטימיות" ולהעבירה כמות-שהיא להשבה, דפנה **הכריעה** בה כשהחומר תמך בכך ("אנו סבורים כי השמאי המכריע פעל כדין ונסביר… בנקודה זו צדק לדעתנו השמאי המכריע"), והשיבה רק את הרכיב השמאי-היישומי הצר. הכרעה מנומקת עדיפה על היסוס אקדמי.
- **Rule:** Decide the interpretive question when the record supports a holding; reserve remand for the narrow appraisal-application residue. Don't park a decidable מהות issue as an "open question."

View File

@@ -85,6 +85,11 @@ knowledge נגזר; G2: מקור-האמת הוא הפרוטוקול + `legal_arg
היו"ר כבר מילא תאריך — לא דורסים קלט-יו"ר).
- **ייצור:** קריאת-ה-LLM ההשוואתית עוברת `claude_session` (מקומי בלבד), מעוגנת
`model="claude-opus-4-8"` + `effort="high"` (ראה `reference_claude_generation_path`).
**חלון-הקשר (#216):** הבנייה הסטנדרטית רצה 200K-טוקן (~400K תווים עברית). פרומפט גדול
(בעיקר בלוק-י, שנושא את התיק המלא כ-source-context) מוסלם אוטומטית לבניית-ה-1M של *אותו*
מודל — `claude-opus-4-8[1m]` (1M-טוקן, מחיר רגיל; `claude -p` חושף אותה ישירות כ-model-id,
בלי beta-header). **עידון של אותו pin, לא מסלול-מודל מקביל (G2).** הסף ב-`block_writer`:
פרומפט > ~350K תווים → `[1m]`; אחרת הבנייה הסטנדרטית. תקרת-קשיחה: 1.5M תווים.
### 1.4 סיכום-מנהלים של טענות הצדדים (מסמך-הכנה לדיון, WS3/#202)

View File

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

View File

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

View File

@@ -759,13 +759,15 @@ async def get_appraiser_facts(case_number: str) -> str:
# ── Protocol comparative analysis (WS4 / #203) — ניתוח פרוטוקול ────
@mcp.tool()
async def analyze_protocol(case_number: str) -> str:
async def analyze_protocol(case_number: str, document_id: str = "") -> str:
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות: אילו טענות ירדו/חוזקו/עלו-חדשות + חידוד-שאלות + חילוץ א–ד.
מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול doc_type='protocol' +
טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high; re-run מחליף (idempotent).
בוחר אוטומטית פרוטוקול ועדת-ערר (scope שאינו 'lower'); document_id מכוון לדיון
מדויק כשיש כמה פרוטוקולים. מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול
doc_type='protocol' + טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high;
re-run מחליף (idempotent).
"""
return await drafting.analyze_protocol(case_number)
return await drafting.analyze_protocol(case_number, document_id=document_id)
@mcp.tool()

View File

@@ -31,6 +31,10 @@ logger = logging.getLogger(__name__)
# Allowed enum values mirror the DB CHECK constraints.
ALLOWED_PARTIES = {"appellant", "respondent", "committee", "permit_applicant", "unknown"}
# Sides that may comprise multiple distinct litigants with potentially opposing
# positions — aggregated per source pleading (party_name) rather than collapsed
# into one bucket (#224). Appellant/committee speak with a single voice.
SPLIT_PARTIES = {"respondent", "permit_applicant"}
ALLOWED_PRIORITIES = {"threshold", "substantive", "procedural", "relief"}
# Hebrew labels for the prompt (Claude needs context in the same
@@ -81,7 +85,7 @@ AGGREGATE_PROMPT_TEMPLATE = """אתה מנתח כתבי טענות בתחום ת
"""
def _build_prompt(party: str, propositions: list[dict]) -> str:
def _build_prompt(party: str, propositions: list[dict], party_name: str = "") -> str:
"""Compose the per-party aggregation prompt."""
n = len(propositions)
# Conservative target: ~1 argument per 2-3 propositions, clamped 4-12.
@@ -89,6 +93,11 @@ def _build_prompt(party: str, propositions: list[dict]) -> str:
target_max = max(target_min + 1, min(12, n // 2 + 1))
party_he = PARTY_LABELS_HE.get(party, party)
# For a split side (e.g. a specific respondent brief), name the brief so
# Claude aggregates only that litigant's position and does not conflate it
# with a co-respondent's separate — possibly opposing — pleading (#224).
if party_name:
party_he = f"{party_he}{party_name}"
# Strip noise from propositions for the prompt — Claude only needs
# the id and the text to do the grouping.
compact = [
@@ -139,12 +148,17 @@ def _normalize_argument(raw: dict, fallback_topic: str = "") -> dict | None:
async def _aggregate_party(
party: str, propositions: list[dict],
party: str, propositions: list[dict], party_name: str = "",
) -> list[dict]:
"""Ask Claude to group one party's propositions; return normalized rows."""
"""Ask Claude to group one party's propositions; return normalized rows.
``party_name`` names the specific pleading when this is a split side
(respondent / permit_applicant brief), so the prompt scopes to that
litigant's position only (#224).
"""
if not propositions:
return []
prompt = _build_prompt(party, propositions)
prompt = _build_prompt(party, propositions, party_name=party_name)
try:
raw_result = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
@@ -221,14 +235,25 @@ async def aggregate_claims_to_arguments(
"total": 0,
}
# Group propositions by party.
by_party: dict[str, list[dict]] = {}
# Group propositions by party — and, for the multi-party sides (respondent /
# permit_applicant), further by their source pleading so opposing joint
# briefs (e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6") aggregate into
# SEPARATE argument sets instead of collapsing into one "respondent" bucket
# (#224). Appellant/committee stay single-group (party_name=""). Splitting
# the large respondent set per-brief also keeps each Claude call small enough
# to succeed — a single 100+ proposition call previously returned non-JSON
# and silently dropped the whole side.
by_group: dict[tuple[str, str], list[dict]] = {}
for r in rows:
party = r["party_role"]
# Map deprecated 'appraiser' or unknown labels to 'unknown'.
if party not in ALLOWED_PARTIES:
party = "unknown"
by_party.setdefault(party, []).append(dict(r))
party_name = (
(r["source_document"] or "").strip()
if party in SPLIT_PARTIES else ""
)
by_group.setdefault((party, party_name), []).append(dict(r))
# Valid claim_ids for this case == the ids of the claims we just fetched.
# The LLM is asked to echo back supporting claim_ids, but it may hallucinate
@@ -243,9 +268,11 @@ async def aggregate_claims_to_arguments(
inserted = 0
errors: list[str] = []
for party, props in by_party.items():
for (party, party_name), props in by_group.items():
# Display key for the per-side summary: keep opposing briefs distinct.
group_key = f"{party}·{party_name}" if party_name else party
try:
arguments = await _aggregate_party(party, props)
arguments = await _aggregate_party(party, props, party_name=party_name)
except RuntimeError as e:
# Most likely cause: Claude CLI not installed (running from
# the container). Don't crash — record the gap and continue.
@@ -259,11 +286,11 @@ async def aggregate_claims_to_arguments(
),
"total": 0,
}
errors.append(f"{party}: {msg}")
errors.append(f"{group_key}: {msg}")
continue
if not arguments:
party_counts[party] = 0
party_counts[group_key] = 0
continue
async with pool.acquire() as conn:
@@ -271,12 +298,13 @@ async def aggregate_claims_to_arguments(
for idx, arg in enumerate(arguments):
arg_id = await conn.fetchval(
"""INSERT INTO legal_arguments
(case_id, party, argument_index, argument_title,
argument_body, legal_topic, priority)
VALUES ($1, $2, $3, $4, $5, $6, $7)
(case_id, party, party_name, argument_index,
argument_title, argument_body, legal_topic, priority)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id""",
case_id,
party,
party_name,
idx + 1,
arg["title"],
arg["body"],
@@ -313,7 +341,7 @@ async def aggregate_claims_to_arguments(
cid, arg_id, e,
)
inserted += 1
party_counts[party] = len(arguments)
party_counts[group_key] = len(arguments)
result: dict = {
"status": "completed",
@@ -338,9 +366,9 @@ async def get_legal_arguments(
async with pool.acquire() as conn:
if party and party in ALLOWED_PARTIES:
rows = await conn.fetch(
"""SELECT id, case_id, party, argument_index, argument_title,
argument_body, legal_topic, priority, cited_precedents,
created_at, updated_at
"""SELECT id, case_id, party, party_name, argument_index,
argument_title, argument_body, legal_topic, priority,
cited_precedents, created_at, updated_at
FROM legal_arguments
WHERE case_id = $1 AND party = $2
ORDER BY priority, argument_index""",
@@ -348,12 +376,12 @@ async def get_legal_arguments(
)
else:
rows = await conn.fetch(
"""SELECT id, case_id, party, argument_index, argument_title,
argument_body, legal_topic, priority, cited_precedents,
created_at, updated_at
"""SELECT id, case_id, party, party_name, argument_index,
argument_title, argument_body, legal_topic, priority,
cited_precedents, created_at, updated_at
FROM legal_arguments
WHERE case_id = $1
ORDER BY party, priority, argument_index""",
ORDER BY party, party_name, priority, argument_index""",
case_id,
)

View File

@@ -58,6 +58,18 @@ logger = logging.getLogger(__name__)
# Output token note (Anthropic): Opus 4.8 supports large outputs; streaming is
# handled by the CLI. `max_tokens` is advisory context for callers, not sent.
GENERATION_MODEL = "claude-opus-4-8" # single pinned model for every AI block (#204)
# 1M-context build of the SAME pinned model (#216). The default build runs a
# 200K-token context (~400K Hebrew chars at ~2 chars/token); large prompts —
# notably block-yod, which carries the full case as source-context — overflow it.
# Opus 4.8 offers a 1M-token window at standard pricing; `claude -p` exposes it
# via the `[1m]` model id directly (no beta header — verified CLI 2.1.196). We
# escalate to it only when the prompt is large, so small blocks stay on the
# cheaper/faster 200K build. NOT a parallel model (G2) — same model, wider window.
GENERATION_MODEL_1M = "claude-opus-4-8[1m]"
# Escalate to the 1M build above this prompt size. Set below the 200K-token wall
# (~400K chars) with headroom for output tokens (max_tokens up to 16K) + tokenizer
# variance, so we switch before the standard build can overflow.
_CTX_1M_THRESHOLD_CHARS = 350_000
BLOCK_CONFIG = {
"block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "model": "script"},
@@ -461,17 +473,21 @@ async def write_block(
if not dir_doc.get("approved"):
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
# Guard against context overflow before calling claude -p.
# Sonnet: 200K context → ~800K chars max; Opus: 200K context → same.
# In practice the CLI has crashed on prompts above ~400K chars, so use
# that as a conservative ceiling (well below the token limit).
_MAX_PROMPT_CHARS = 400_000
# Pick the model build by prompt size (#216). Above the 200K-token wall we
# escalate to the 1M-context build (`[1m]`) instead of failing the block —
# block-yod legitimately carries the whole case as source-context. The 400K
# ceiling was an artifact of the old 200K-only build, NOT a model limit.
gen_model = GENERATION_MODEL_1M if len(prompt) > _CTX_1M_THRESHOLD_CHARS else GENERATION_MODEL
# Final guard: even the 1M build is finite (~2M Hebrew chars of input). Cap at
# 1.5M chars (~750K tokens) to leave room for output + a safety margin under 1M.
_MAX_PROMPT_CHARS = 1_500_000
if len(prompt) > _MAX_PROMPT_CHARS:
raise RuntimeError(
f"Prompt too large for {block_id}: {len(prompt):,} chars "
f"(limit {_MAX_PROMPT_CHARS:,}). "
f"(limit {_MAX_PROMPT_CHARS:,}, even on the 1M-context build). "
f"source_context: {len(source_context):,} chars. "
f"Reduce documents or call extract_appraiser_facts first."
f"Reduce source-context (summaries / appraiser_facts / focused RAG)."
)
# Call Claude via Claude Code session (no API). #204: pin the model + per-block
@@ -483,14 +499,14 @@ async def write_block(
content = await claude_session.query(
prompt,
timeout=timeout,
model=GENERATION_MODEL,
model=gen_model,
effort=effort,
tools="", # prose gen — no tool_use → no error_max_turns
)
sources = await _collect_block_sources(case_id, block_id)
sources["case_law_ids"] = _precedent_case_law_ids
result = _build_result(block_id, content, block_cfg)
result = _build_result(block_id, content, block_cfg, model_used=gen_model)
# Record the EFFECTIVE effort (override wins) so the harness can attribute
# the measured distance to the effort that actually produced the text.
if result.get("effort") is not None:
@@ -499,7 +515,8 @@ async def write_block(
return result
def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
def _build_result(block_id: str, content: str, block_cfg: dict,
model_used: str = GENERATION_MODEL) -> dict:
word_count = len(content.split())
is_ai = block_cfg["model"] == "ai"
return {
@@ -509,8 +526,9 @@ def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
"content": content,
"word_count": word_count,
"generation_type": block_cfg["gen_type"],
# AI blocks record the pinned model; template blocks record "script".
"model_used": GENERATION_MODEL if is_ai else block_cfg["model"],
# AI blocks record the model build actually used (200K or [1m], #216);
# template blocks record "script".
"model_used": model_used if is_ai else block_cfg["model"],
# The real generation knob (#204). None for template/script blocks.
"effort": block_cfg.get("effort", DEFAULT_EFFORT) if is_ai else None,
# DEPRECATED: temperature is not a real knob on Opus 4.7/4.8 (sending it

View File

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

View File

@@ -1888,6 +1888,18 @@ CREATE INDEX IF NOT EXISTS idx_documents_claims_pending
"""
# V50 (#224): per-respondent-brief separation. The aggregator groups respondent /
# permit_applicant claims by their source pleading — each joint response brief
# (e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6") is one coherent litigation
# position, so opposing briefs no longer collapse into a single "respondent"
# bucket. ``party_name`` carries the brief label; appellant/committee stay ''.
SCHEMA_V50_SQL = """
ALTER TABLE legal_arguments ADD COLUMN IF NOT EXISTS party_name TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_legal_arguments_party_name
ON legal_arguments(case_id, party, party_name);
"""
# Stable, arbitrary key for the session-level advisory lock that serialises
# schema DDL across processes. Every short-lived process (cron drains, services)
# re-runs the idempotent migrations on startup; without this lock two processes
@@ -1959,6 +1971,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
await conn.execute(SCHEMA_V47_SQL)
await conn.execute(SCHEMA_V48_SQL)
await conn.execute(SCHEMA_V49_SQL)
await conn.execute(SCHEMA_V50_SQL)
async def init_schema() -> None:

View File

@@ -120,15 +120,47 @@ HEADER_PROMPT = """אתה מחלץ נתוני-כותרת מפרוטוקול די
MAX_PROTOCOL_CHARS = 120_000
def _find_protocol(docs: list[dict]) -> dict | None:
"""The protocol document for the case, if present (doc_type or title)."""
for d in docs:
if d.get("doc_type") == "protocol":
return d
for d in docs:
if "פרוטוקול" in (d.get("title") or ""):
return d
return None
def _protocol_scope(doc: dict) -> str:
"""Protocol scope from ``metadata.protocol_scope``.
Absent/'' defaults to ``'appeal'`` (ועדת הערר), matching the
document-type-editor convention where only the non-default ``'lower'``
(ועדה מקומית/מחוזית) is persisted.
"""
meta = doc.get("metadata") or {}
return (meta.get("protocol_scope") or "").strip() or "appeal"
def _find_protocol(
docs: list[dict], document_id: UUID | None = None,
) -> dict | None:
"""The ועדת-הערר hearing protocol to compare against the pleadings.
- ``document_id`` given → return exactly that document (explicit target),
so a case with several protocols can be pointed at the right hearing.
- Otherwise prefer a protocol scoped to the appeals committee
(``scope != 'lower'``). A ``'lower'`` protocol is the local/district
committee's proceedings — it feeds background (block ו) only and must NOT
drive the hearing-vs-pleadings comparison (#223). If every protocol is
lower-scoped there is no ערר-hearing to compare → return None.
- Among appeal-scoped protocols the most recent (by ``created_at``) wins —
the operative hearing; ties fall back to list order.
"""
if document_id is not None:
return next(
(d for d in docs if str(d.get("id")) == str(document_id)), None,
)
protocols = [d for d in docs if d.get("doc_type") == "protocol"]
if not protocols:
# Legacy fallback: untyped docs whose title says "פרוטוקול".
protocols = [d for d in docs if "פרוטוקול" in (d.get("title") or "")]
appeal_scoped = [d for d in protocols if _protocol_scope(d) != "lower"]
if not appeal_scoped:
return None
appeal_scoped.sort(key=lambda d: d.get("created_at") or "", reverse=True)
return appeal_scoped[0]
def _compact_arguments(arguments: list[dict]) -> list[dict]:
@@ -239,9 +271,15 @@ async def _extract_header(protocol_text: str, case_id: UUID) -> dict:
}
async def analyze_protocol(case_id: UUID) -> dict:
async def analyze_protocol(
case_id: UUID, target_document_id: UUID | None = None,
) -> dict:
"""Comparative analysis of the case's hearing protocol vs. its pleadings.
``target_document_id`` pins the analysis to a specific protocol document —
required when a case holds several protocols (e.g. a lower-committee annex
plus the ועדת-הערר hearing) and the auto-pick would be ambiguous (#223).
1. Locates the protocol document.
2. Pulls the aggregated legal_arguments (the written-pleadings baseline).
3. Asks Claude (Opus 4.8, effort=high) to classify each as dropped /
@@ -252,11 +290,20 @@ async def analyze_protocol(case_id: UUID) -> dict:
Returns a serializable summary dict.
"""
docs = await db.list_documents(case_id)
protocol = _find_protocol(docs)
protocol = _find_protocol(docs, document_id=target_document_id)
if not protocol:
if target_document_id is not None:
return {
"status": "no_protocol",
"message": f"מסמך {target_document_id} לא נמצא בתיק.",
}
return {
"status": "no_protocol",
"message": "לא נמצא פרוטוקול דיון בתיק (doc_type='protocol'). העלה פרוטוקול והרץ שוב.",
"message": (
"לא נמצא פרוטוקול ועדת-ערר בתיק (doc_type='protocol' עם "
"protocol_scope שאינו 'lower'). העלה פרוטוקול-דיון, או תייג את "
"הפרוטוקול הקיים כ-appeal, והרץ שוב."
),
}
document_id = UUID(protocol["id"])

View File

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

View File

@@ -433,17 +433,24 @@ ALLOWED_DOC_TYPES = {
# Allowed appraiser_side values; '' (empty) clears the tag.
ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding", ""}
# Allowed protocol_scope values (protocol docs only); mirrors web/app.py.
# appeal = ועדת הערר (default when absent) · lower = ועדה מקומית/מחוזית.
ALLOWED_PROTOCOL_SCOPES = {"appeal", "lower"}
async def document_update(
case_number: str,
doc_id: str,
doc_type: str = "",
appraiser_side: str = "",
is_post_hearing: bool | None = None,
protocol_scope: str = "",
) -> str:
"""עדכון תיוג מסמך — doc_type ו/או appraiser_side. ריק = אין שינוי.
"""עדכון תיוג מסמך — doc_type / appraiser_side / is_post_hearing / protocol_scope.
הולידציה זהה ל-PATCH endpoint ב-web/app.py. appraiser_side נשמר ב-
documents.metadata JSONB (מתפרסם משם ע"י extract_appraiser_facts).
הולידציה זהה ל-PATCH endpoint ב-web/app.py. הדגלים נשמרים ב-
documents.metadata JSONB (appraiser_side מתפרסם משם ע"י extract_appraiser_facts;
is_post_hearing נצרך ע"י כותב בלוק-ח; protocol_scope מסווג פרוטוקול בטאב-הסקירה).
Args:
case_number: מספר תיק הערר (לאישור שייכות)
@@ -452,6 +459,9 @@ async def document_update(
permit/appraisal/exhibit/objection/reference). ריק = אין שינוי.
appraiser_side: ערך חדש (committee/appellant/deciding). ריק = אין שינוי;
העבר במפורש מחרוזת ריקה לא-default אם רוצים לנקות.
is_post_hearing: True = התקבל אחרי הדיון · False = מנקה את הדגל · None = אין שינוי.
protocol_scope: היקף פרוטוקול (appeal=ועדת הערר / lower=ועדה מקומית-מחוזית).
ריק = אין שינוי. רלוונטי רק כשהסוג protocol.
"""
case = await db.get_case_by_number(case_number)
if not case:
@@ -477,17 +487,37 @@ async def document_update(
data={"allowed": sorted(ALLOWED_DOC_TYPES)})
updates["doc_type"] = doc_type
# appraiser_side is optional. The MCP tool can't distinguish "skip" from
# "set to empty string", so we use the convention: only update if non-empty.
# To clear, the operator must edit metadata directly (rare).
# appraiser_side / protocol_scope are optional. The MCP tool can't
# distinguish "skip" from "set to empty string", so we use the convention:
# only update if non-empty. To clear, edit metadata directly (rare).
# is_post_hearing is a tri-state bool: None skips, True/False set/clear.
metadata = doc.get("metadata") or {}
if isinstance(metadata, str):
metadata = json.loads(metadata)
metadata_dirty = False
if appraiser_side:
if appraiser_side not in ALLOWED_APPRAISER_SIDES:
return err(f"appraiser_side לא תקין: {appraiser_side}",
data={"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s)})
metadata = doc.get("metadata") or {}
if isinstance(metadata, str):
metadata = json.loads(metadata)
metadata["appraiser_side"] = appraiser_side
metadata_dirty = True
if is_post_hearing is not None:
if is_post_hearing:
metadata["is_post_hearing"] = True
else:
metadata.pop("is_post_hearing", None)
metadata_dirty = True
if protocol_scope:
if protocol_scope not in ALLOWED_PROTOCOL_SCOPES:
return err(f"protocol_scope לא תקין: {protocol_scope}",
data={"allowed": sorted(ALLOWED_PROTOCOL_SCOPES)})
metadata["protocol_scope"] = protocol_scope
metadata_dirty = True
if metadata_dirty:
updates["metadata"] = metadata
if not updates:

View File

@@ -580,7 +580,7 @@ async def get_appraiser_facts(case_number: str) -> str:
return err(str(e))
async def analyze_protocol(case_number: str) -> str:
async def analyze_protocol(case_number: str, document_id: str = "") -> str:
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות (WS4 / #203).
מזהה אילו טענות **ירדו** (נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות**,
@@ -588,12 +588,17 @@ async def analyze_protocol(case_number: str) -> str:
ל"ידע-התיק" (טבלת protocol_analysis), זמינה לסוכני הניתוח והכתיבה; תאריך-הדיון
מוזן חזרה לעמודה הקנונית cases.hearing_date.
בוחר אוטומטית את פרוטוקול **ועדת-הערר** (protocol_scope שאינו 'lower');
פרוטוקול ועדה-מקומית ('lower') אינו נכנס להשוואה. כשיש כמה פרוטוקולי-ערר —
ציין document_id כדי לכוון לדיון המדויק (#223).
דורש פרוטוקול מתויג doc_type='protocol' + טיעונים מאוגדים
(aggregate_claims_to_arguments). רץ עם Claude מקומי (Opus 4.8, effort=high);
re-run מחליף את הניתוח הקודם לאותו פרוטוקול (idempotent).
Args:
case_number: מספר תיק הערר
document_id: מזהה מסמך-הפרוטוקול לכיוון מדויק (ריק = בחירה אוטומטית)
"""
from legal_mcp.services import protocol_analyzer
@@ -601,8 +606,16 @@ async def analyze_protocol(case_number: str) -> str:
if not case:
return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"])
target_doc_id: UUID | None = None
if document_id.strip():
try:
target_doc_id = UUID(document_id.strip())
except ValueError:
return err(f"document_id לא תקין: {document_id}")
try:
result = await protocol_analyzer.analyze_protocol(case_id)
result = await protocol_analyzer.analyze_protocol(
case_id, target_document_id=target_doc_id,
)
await audit.log_action_safe(
"analyze_protocol", case_id=case_id,
details={"status": result.get("status"), "total": result.get("total", 0)},

View File

@@ -49,6 +49,65 @@ def test_find_protocol_none():
assert pa._find_protocol(docs) is None
def test_find_protocol_skips_lower_scope():
# A local/district-committee protocol (scope='lower') must NOT be picked for
# the hearing-vs-pleadings comparison; the ועדת-ערר one wins (#223).
docs = [
{"id": "lower", "doc_type": "protocol", "title": "נספח 18 — פרוטוקולי ועדה מקומית",
"metadata": {"protocol_scope": "lower"}, "created_at": "2026-01-01"},
{"id": "appeal", "doc_type": "protocol", "title": "פרוטוקול דיון 23.6.26",
"metadata": {}, "created_at": "2026-06-23"},
]
assert pa._find_protocol(docs)["id"] == "appeal"
def test_find_protocol_all_lower_returns_none():
# If every protocol is lower-scoped there is no ערר-hearing to compare.
docs = [
{"id": "1", "doc_type": "protocol", "title": "פרוטוקול מקומי",
"metadata": {"protocol_scope": "lower"}},
]
assert pa._find_protocol(docs) is None
def test_find_protocol_prefers_most_recent_appeal():
docs = [
{"id": "old", "doc_type": "protocol", "title": "פרוטוקול א",
"metadata": {}, "created_at": "2026-03-01"},
{"id": "new", "doc_type": "protocol", "title": "פרוטוקול ב",
"metadata": {}, "created_at": "2026-06-23"},
]
assert pa._find_protocol(docs)["id"] == "new"
def test_find_protocol_explicit_document_id_overrides():
# Explicit target wins even over scope/recency heuristics — including the
# ability to point at a lower-scoped doc if the caller insists.
docs = [
{"id": "aaaaaaaa-0000-0000-0000-000000000001", "doc_type": "protocol",
"title": "פרוטוקול ערר", "metadata": {}, "created_at": "2026-06-23"},
{"id": "aaaaaaaa-0000-0000-0000-000000000002", "doc_type": "protocol",
"title": "פרוטוקול מקומי", "metadata": {"protocol_scope": "lower"},
"created_at": "2026-01-01"},
]
from uuid import UUID
picked = pa._find_protocol(
docs, document_id=UUID("aaaaaaaa-0000-0000-0000-000000000002"),
)
assert picked["id"] == "aaaaaaaa-0000-0000-0000-000000000002"
def test_find_protocol_unknown_document_id_returns_none():
from uuid import UUID
docs = [
{"id": "aaaaaaaa-0000-0000-0000-000000000001", "doc_type": "protocol",
"title": "פרוטוקול", "metadata": {}},
]
assert pa._find_protocol(
docs, document_id=UUID("bbbbbbbb-0000-0000-0000-000000000009"),
) is None
# ── _normalize_change — anti-hallucination gate (INV-AH) ───────────────────
_AID = "11111111-1111-1111-1111-111111111111"

View File

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

View File

@@ -287,6 +287,7 @@ description: This skill should be used when writing legal decisions (החלטו
**ערר היטל השבחה — הכרעה מפוצלת (שמאי מייעץ):**
תת-מסלול חוזר: הוועדה מאשרת את עצם החבות אך אינה קובעת את גובה ההיטל — ממנה שמאי מייעץ. פתיחה: זהה למסלול הכללי — "עניינו של ערר זה בדרישת תשלום היטל השבחה...". ללא סיפור תכנוני רחב. דיון — שלב משפטי: הכרעה בשאלת עצם החבות. ניתוח הטענה המרכזית (בד"כ "זכות מוקנית") מול ההקלה שהתבקשה. ציטוטי פסיקה inline — הפניה להחלטות ועדת ערר קודמות ללא בלוק ציטוט מלא. ביטויי מפתח: "אנו סבורים כי...", "בניגוד לעמדת העורר...", "לא ניתן לטעון כי...". מעבר לשלב השמאי — 3 ביטויי גישור: (א) "בכל הנוגע לטענות לגבי מקדמים... וכל טענה בעלת אופי שמאי **ניתן יהיה לעלות בפני השמאי המייעץ**." (ב) "על כן, לאור האמור **אנו ממנים שמאי מייעץ** אשר יערוך שומה להערכת ההשבחה במקרקעין כתוצאה מאישור ההקלה..." (ג) "השמאי המייעץ ינהל את הדיון **בהתאם לתקנות התכנון והבניה (סדרי דין בבקשה להכרעה לפני שמאי מכריע או שמאי מייעץ), התשס"ט-2008**." — נוסחה קבועה. הוראות המשך: "לאחר קבלת השומה המייעצת יהיו רשאים הצדדים להגיש את השגותיהם בתוך 30 יום לוועדת הערר ולאחר מכן תתקבל החלטה באשר לאופן קידום ההליך." סיום: **ללא** כותרת "סיכום" / "סוף דבר" — זורם ישירות מהוראות המינוי לחתימה "ניתנה פה אחד היום...". הוצאות: לא מוזכרות (ההליך טרם הסתיים). ראה: נווה יעקב 8070/25.
*הכרעות-מהות בתת-מסלול זה (מחיר-למשתכן / שומה סמוכה — 8125-09-24, ראה `docs/legal-decision-lessons.md` #47#50):* (א) "הסבת שטחים" ממחיר-למשתכן לשוק-חופשי בעקבות הקלת תוספת יח"ד = **השבחה תכנונית בת-חיוב** — להכריע בחיוב, לא לגדֵר כשאלה פתוחה או להטיל ספק מכוח קשר-סיבתי (בית הכרם/פמיני). (ב) שומה מכרעת חלוטה סמוכה = **משכנעת, לא מחייבת**; שומה מנומקת רשאית לסטות ("לא נוכל להיאחז בשומה מכרעת אחרת רק מכיוון והיא קיימת") — לא להעמיס דוקטרינת שוויון לטובת העוררת. (ג) שמאי מכריע פרש → מינוי **שמאי מייעץ חדש** (סמכות אשקלוני/גנדליס).
**ערר היטל השבחה — מסגרת תלת-שכבתית לניתוח "תכנית צל":**
כשעורר טוען ש"תכנית צל" מאושרת הופכת זכויות להקלה לזכויות מוקנות, הניתוח מתבצע בשלוש שכבות נפרדות:

View File

@@ -104,12 +104,6 @@ export default function CaseDetailPage({
<>
{data && <CaseEditDialog data={data} />}
<UploadSheet caseNumber={caseNumber} />
<Button
className="bg-gold text-white hover:bg-gold-deep border-transparent"
onClick={() => setTab("decision")}
>
פתח עורך החלטה
</Button>
{canStartWorkflow && (
<Button
className="bg-gold-deep hover:bg-gold-deep/90 text-parchment"

View File

@@ -5,11 +5,11 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Markdown } from "@/components/ui/markdown";
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
import {
useAgentActivity,
useSendComment,
useSubmitInteraction,
useDismissInteraction,
} from "@/lib/api/agents";
import type {
Interaction,
@@ -17,6 +17,8 @@ import type {
InteractionQuestion,
InteractionTask,
PaperclipComment,
PaperclipAgentStatus,
PaperclipIssue,
} from "@/lib/api/agents";
import { formatRelative } from "@/lib/format-date";
import { toast } from "sonner";
@@ -30,6 +32,8 @@ import {
CheckCircle2,
XCircle,
HelpCircle,
ChevronDown,
Ban,
} from "lucide-react";
/* ── Role → color mapping ────────────────────────────────────── */
@@ -82,6 +86,79 @@ function issueStatusLabel(status: string) {
return ISSUE_STATUS_LABELS[status] ?? status;
}
/* ── Agent roster (mockup 18i) — who's doing what right now ────── */
const AGENT_STATUS_LABEL: Record<string, string> = {
active: "פעיל",
running: "פעיל",
idle: "ממתין",
error: "שגיאה",
};
function agentIsActive(s: string) {
return s === "active" || s === "running";
}
function agentStatusDot(s: string) {
return agentIsActive(s)
? "bg-success shadow-[0_0_6px_rgba(74,124,89,0.6)]"
: s === "error"
? "bg-danger"
: "bg-rule";
}
function agentStatusTone(s: string) {
return agentIsActive(s)
? "bg-success-bg text-success"
: s === "error"
? "bg-danger-bg text-danger"
: "bg-rule-soft text-ink-muted";
}
function AgentRoster({ agents }: { agents: PaperclipAgentStatus[] }) {
if (!agents.length) return null;
const activeCount = agents.filter((a) => agentIsActive(a.status)).length;
return (
<div className="mb-3 rounded-lg border border-rule bg-parchment/40 p-3">
<div className="flex items-center gap-2 mb-2.5">
<Bot className="w-4 h-4 text-gold-deep shrink-0" />
<h3 className="text-[0.82rem] font-bold text-navy">סוכני התיק</h3>
<span className="text-[0.72rem] text-ink-muted">
<b className="text-success font-bold">{activeCount} פעילים</b> מתוך {agents.length}
</span>
</div>
{/* Compact roster — all agents on one row (mockup 18i v2): status · name ·
role only. The per-agent "current activity" line was removed: it
matched comments by role-fallback, so agents sharing a role showed the
same text. */}
<div className="grid gap-1.5 grid-cols-3 sm:grid-cols-5 lg:grid-cols-9">
{agents.map((a) => {
const active = agentIsActive(a.status);
return (
<div
key={a.id}
className={`rounded-lg border p-2 min-w-0 ${active ? "border-success/40 bg-success-bg/40" : "border-rule bg-surface"}`}
>
<div className="flex items-center gap-1.5 min-w-0">
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${agentStatusDot(a.status)}`} />
<span className="text-[0.72rem] font-bold text-navy truncate">{a.name}</span>
</div>
<div className="text-[0.6rem] text-ink-muted mt-0.5 truncate">{roleLabel(a.role)}</div>
<span
className={`inline-block mt-1 text-[0.55rem] font-bold rounded-full px-1.5 py-0.5 whitespace-nowrap ${agentStatusTone(a.status)}`}
>
{AGENT_STATUS_LABEL[a.status] ?? a.status}
</span>
</div>
);
})}
</div>
</div>
);
}
/* ── Time formatting ─────────────────────────────────────────── */
function timeAgo(iso: string | null): string {
@@ -507,15 +584,21 @@ function InteractionCard({
interaction,
caseNumber,
issueMap,
defaultOpen = true,
}: {
interaction: Interaction;
caseNumber: string;
issueMap: Map<string, string>;
defaultOpen?: boolean;
}) {
const submit = useSubmitInteraction(caseNumber);
const dismiss = useDismissInteraction(caseNumber);
const identifier = issueMap.get(interaction.issue_id) ?? "";
const isPending = interaction.status === "pending";
const summary = summaryAnswer(interaction);
// Pending interactions collapse into an accordion (mockup 18i v2); resolved
// ones always render their one-line summary.
const [open, setOpen] = useState(defaultOpen);
const send = (action: "respond" | "accept" | "reject", payload: InteractionPayload | Record<string, unknown>) => {
submit.mutate(
@@ -532,6 +615,16 @@ function InteractionCard({
);
};
const handleDismiss = () => {
dismiss.mutate(
{ issue_id: interaction.issue_id, interaction_id: interaction.id },
{
onSuccess: () => toast.success("הבקשה בוטלה"),
onError: () => toast.error("שגיאה בביטול הבקשה"),
},
);
};
return (
<div
className={`group relative flex gap-3 py-3 px-2 rounded-lg border transition-colors ${
@@ -554,15 +647,30 @@ function InteractionCard({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-sm font-semibold text-navy">
{interaction.title || "שאלה לסוכן"}
</span>
{isPending ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-100 text-amber-800">
ממתין לתשובה
</span>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex items-center gap-2 flex-wrap text-start min-w-0"
>
<ChevronDown
className={`w-4 h-4 text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
/>
<span className="text-sm font-semibold text-navy">
{interaction.title || "שאלה לסוכן"}
</span>
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-100 text-amber-800">
ממתין לתשובה
</span>
</button>
) : (
<ResolvedBadge status={interaction.status} />
<>
<span className="text-sm font-semibold text-navy">
{interaction.title || "שאלה לסוכן"}
</span>
<ResolvedBadge status={interaction.status} />
</>
)}
{identifier && (
<Badge variant="outline" className="text-[10px] font-mono">
@@ -573,39 +681,58 @@ function InteractionCard({
<Clock className="w-3 h-3" />
{timeAgo(interaction.resolved_at ?? interaction.created_at)}
</span>
{isPending && (
<Button
size="sm"
variant="ghost"
onClick={handleDismiss}
disabled={dismiss.isPending}
className="h-7 px-2 text-ink-muted hover:text-danger"
title="בטל בקשה כפולה/מיושנת — ללא העברה לסוכן"
>
{dismiss.isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Ban className="w-3.5 h-3.5 me-1" />
)}
התעלם
</Button>
)}
</div>
{interaction.summary && (
{interaction.summary && (!isPending || open) && (
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
)}
{isPending ? (
interaction.kind === "ask_user_questions" ? (
<AskUserQuestionsForm
interaction={interaction}
onSubmit={(answers) => send("respond", { answers })}
pending={submit.isPending}
/>
) : interaction.kind === "request_confirmation" ? (
<RequestConfirmationForm
interaction={interaction}
onAccept={() => send("accept", {})}
onReject={(reason) =>
send("reject", reason ? { reason } : {})
}
pending={submit.isPending}
/>
) : interaction.kind === "suggest_tasks" ? (
<SuggestTasksForm
interaction={interaction}
onAccept={(keys) =>
send("accept", keys.length ? { selectedClientKeys: keys } : {})
}
onReject={(reason) =>
send("reject", reason ? { reason } : {})
}
pending={submit.isPending}
/>
open ? (
interaction.kind === "ask_user_questions" ? (
<AskUserQuestionsForm
interaction={interaction}
onSubmit={(answers) => send("respond", { answers })}
pending={submit.isPending}
/>
) : interaction.kind === "request_confirmation" ? (
<RequestConfirmationForm
interaction={interaction}
onAccept={() => send("accept", {})}
onReject={(reason) =>
send("reject", reason ? { reason } : {})
}
pending={submit.isPending}
/>
) : interaction.kind === "suggest_tasks" ? (
<SuggestTasksForm
interaction={interaction}
onAccept={(keys) =>
send("accept", keys.length ? { selectedClientKeys: keys } : {})
}
onReject={(reason) =>
send("reject", reason ? { reason } : {})
}
pending={submit.isPending}
/>
) : null
) : null
) : summary ? (
<div className="text-sm">
@@ -617,6 +744,80 @@ function InteractionCard({
);
}
/* ── Issue group (timeline grouped by issue, accordion) ──────── */
type FeedItem =
| { kind: "comment"; at: number; comment: PaperclipComment }
| { kind: "interaction"; at: number; interaction: Interaction };
/** Drop the "[ערר 1043-02-26] " prefix Paperclip puts on every issue title. */
function shortIssueTitle(title: string): string {
return title.replace(/^\s*\[[^\]]*\]\s*/, "").trim() || title;
}
function IssueGroup({
issue,
items,
caseNumber,
issueMap,
defaultOpen,
}: {
issue: PaperclipIssue;
items: FeedItem[];
caseNumber: string;
issueMap: Map<string, string>;
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
const closed = issue.status === "done" || issue.status === "cancelled";
return (
<div className="border-b border-rule-soft last:border-b-0">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50 text-start"
>
<Badge
variant={closed ? "secondary" : "default"}
className="text-[10px] font-mono shrink-0"
>
{issue.identifier}
</Badge>
<span className="text-[0.78rem] font-semibold text-navy truncate">
{shortIssueTitle(issue.title)}
</span>
<span className="text-[0.7rem] text-ink-muted whitespace-nowrap">
{issueStatusLabel(issue.status)} · {items.length}
</span>
<ChevronDown
className={`w-4 h-4 ms-auto text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
{open && (
<div className="pb-1">
{items.map((item) =>
item.kind === "comment" ? (
<CommentCard
key={`c-${item.comment.id}`}
comment={item.comment}
issueMap={issueMap}
/>
) : (
<InteractionCard
key={`i-${item.interaction.id}`}
interaction={item.interaction}
caseNumber={caseNumber}
issueMap={issueMap}
/>
),
)}
</div>
)}
</div>
);
}
/* ── Main Feed ───────────────────────────────────────────────── */
export function AgentActivityFeed({
@@ -690,25 +891,46 @@ export function AgentActivityFeed({
const comments = data.comments ?? [];
const interactions = data.interactions ?? [];
const pendingCount = interactions.filter((i) => i.status === "pending").length;
// Pending interactions are surfaced at the top (awaiting-you, mockup 18i) and
// NOT repeated in the timeline below.
const pending = interactions.filter((i) => i.status === "pending");
// Unified, time-sorted feed: comments + interactions interleaved.
type FeedItem =
| { kind: "comment"; at: number; comment: PaperclipComment }
| { kind: "interaction"; at: number; interaction: Interaction };
const feed: FeedItem[] = [
...comments.map<FeedItem>((c) => ({
// Group the timeline by issue, each an accordion (mockup 18i v2). Comments +
// resolved interactions; pending ones are surfaced above and not repeated.
const itemsByIssue = new Map<string, FeedItem[]>();
const pushItem = (issueId: string, item: FeedItem) => {
const arr = itemsByIssue.get(issueId);
if (arr) arr.push(item);
else itemsByIssue.set(issueId, [item]);
};
for (const c of comments) {
pushItem(c.issue_id, {
kind: "comment",
at: c.created_at ? new Date(c.created_at).getTime() : 0,
comment: c,
})),
...interactions.map<FeedItem>((i) => ({
});
}
for (const i of interactions) {
if (i.status === "pending") continue;
pushItem(i.issue_id, {
kind: "interaction",
at: i.created_at ? new Date(i.created_at).getTime() : 0,
interaction: i,
})),
].sort((a, b) => a.at - b.at);
});
}
for (const arr of itemsByIssue.values()) arr.sort((a, b) => a.at - b.at);
// Order groups by most-recent activity so live work floats to the top.
const issueGroups = data.issues
.map((iss) => {
const items = itemsByIssue.get(iss.id) ?? [];
const maxAt = items.reduce((m, it) => Math.max(m, it.at), 0);
return { iss, items, maxAt };
})
.filter((g) => g.items.length > 0)
.sort((a, b) => b.maxAt - a.maxAt);
const totalFeedItems = issueGroups.reduce((n, g) => n + g.items.length, 0);
// An issue is "active" if it's not done/cancelled. When everything is closed
// we should NOT show the "agents are working, waiting for report" spinner.
@@ -718,35 +940,43 @@ export function AgentActivityFeed({
return (
<div className="flex flex-col h-full">
{/* agent roster — who's doing what right now (mockup 18i) */}
<div className="mb-3 rounded-lg border border-rule bg-parchment/40 px-3 py-2.5">
<AgentStatusWidget caseNumber={caseNumber} />
</div>
{/* agent roster — who's who, compact one-row (mockup 18i v2) */}
<AgentRoster agents={data.agents ?? []} />
{/* pending interactions surfaced to the top (mockup 18i — awaiting you) */}
{pendingCount > 0 && (
<div className="mb-3 rounded-lg border border-amber-300 bg-amber-50 px-4 py-2.5 text-[0.82rem] font-semibold text-amber-800 flex items-center gap-2">
<Clock className="w-4 h-4" />
ממתין לתשובתך {pendingCount} {pendingCount === 1 ? "בקשה" : "בקשות"} בהמשך הזרם
{/* pending interactions surfaced to the top — each an accordion, first
open (mockup 18i v2 — awaiting you). Excluded from the timeline. */}
{pending.length > 0 && (
<div className="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 space-y-2">
<div className="flex items-center gap-2 text-[0.82rem] font-bold text-amber-800">
<Clock className="w-4 h-4" />
ממתין לתשובתך
<span className="text-[0.7rem] font-semibold bg-amber-100 rounded-full px-2 py-0.5">
{pending.length}
</span>
</div>
{pending.map((i, idx) => (
<InteractionCard
key={`pending-${i.id}`}
interaction={i}
caseNumber={caseNumber}
issueMap={issueMap}
defaultOpen={idx === 0}
/>
))}
</div>
)}
{/* Issue summary bar */}
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-2 flex-wrap">
{data.issues.map((iss) => (
<Badge
key={iss.id}
variant={iss.status === "done" ? "secondary" : "default"}
className="text-[11px] font-mono"
>
{iss.identifier} {issueStatusLabel(iss.status)}
</Badge>
))}
{/* Timeline header */}
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-1">
<span className="text-[0.82rem] font-bold text-navy">פעילות לפי משימה</span>
<span className="text-[0.72rem] text-ink-muted">
{data.issues.length} משימות · {totalFeedItems} הודעות
</span>
</div>
{/* Comments + interactions stream */}
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
{feed.length === 0 ? (
{/* Per-issue accordions */}
<div className="flex-1 overflow-y-auto max-h-[500px]">
{totalFeedItems === 0 ? (
hasActiveIssue ? (
<div className="text-center py-8 text-ink-faint text-sm">
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
@@ -759,22 +989,19 @@ export function AgentActivityFeed({
</div>
)
) : (
feed.map((item) =>
item.kind === "comment" ? (
<CommentCard
key={`c-${item.comment.id}`}
comment={item.comment}
issueMap={issueMap}
/>
) : (
<InteractionCard
key={`i-${item.interaction.id}`}
interaction={item.interaction}
caseNumber={caseNumber}
issueMap={issueMap}
/>
),
)
issueGroups.map(({ iss, items }, idx) => (
<IssueGroup
key={iss.id}
issue={iss}
items={items}
caseNumber={caseNumber}
issueMap={issueMap}
defaultOpen={
idx === 0 ||
(iss.status !== "done" && iss.status !== "cancelled")
}
/>
))
)}
<div ref={endRef} />
</div>

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import { CheckCircle2, Loader2, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Popover,
PopoverContent,
@@ -20,6 +21,7 @@ import {
APPRAISER_SIDE_LABELS,
APPRAISER_SIDE_OPTIONS,
DOC_TYPE_OPTIONS,
PROTOCOL_SCOPE_OPTIONS,
appraiserSideLabel,
doctypeLabel,
doctypeTone,
@@ -29,14 +31,17 @@ import {
import {
useExtractAppraiserFacts,
usePatchDocument,
type DocumentPatch,
type ExtractAppraiserFactsResponse,
} from "@/lib/api/documents";
/*
* Inline editor for a document's tags. Renders a colored Badge that opens a
* Popover with two Selects:
* Popover with:
* 1. doc_type (always shown)
* 2. appraiser_side (only when doc_type === "appraisal")
* 2. appraiser_side (only when doc_type === "appraisal")
* 3. protocol_scope (only when doc_type === "protocol") — ועדת הערר / מקומית
* 4. is_post_hearing toggle (always shown) — moves the doc to overview group 4
*
* After a successful save we swap the Popover body to a confirmation view
* with a "חלץ עובדות שמאיות עכשיו" button — extraction is expensive so we
@@ -49,15 +54,25 @@ export function DocumentTypeEditor({
docId,
docType,
appraiserSide,
isPostHearing,
protocolScope,
}: {
caseNumber: string;
docId: string;
docType: string;
appraiserSide?: string;
isPostHearing?: boolean;
protocolScope?: string;
}) {
// Effective stored scope: absent === "appeal" (ועדת הערר) by convention.
const storedScope = protocolScope === "lower" ? "lower" : "appeal";
const storedPost = isPostHearing === true;
const [open, setOpen] = useState(false);
const [draftType, setDraftType] = useState<string>(docType || "");
const [draftSide, setDraftSide] = useState<string>(appraiserSide || "");
const [draftScope, setDraftScope] = useState<string>(storedScope);
const [draftPost, setDraftPost] = useState<boolean>(storedPost);
const [saved, setSaved] = useState(false);
const [extractResult, setExtractResult] =
useState<ExtractAppraiserFactsResponse | null>(null);
@@ -67,6 +82,8 @@ export function DocumentTypeEditor({
function reset() {
setDraftType(docType || "");
setDraftSide(appraiserSide || "");
setDraftScope(storedScope);
setDraftPost(storedPost);
setSaved(false);
setExtractResult(null);
patch.reset();
@@ -74,14 +91,17 @@ export function DocumentTypeEditor({
}
const isAppraisal = draftType === "appraisal";
const isProtocol = draftType === "protocol";
const sideMissing = isAppraisal && !draftSide;
const dirty =
draftType !== docType ||
(isAppraisal && draftSide !== (appraiserSide || ""));
(isAppraisal && draftSide !== (appraiserSide || "")) ||
(isProtocol && draftScope !== storedScope) ||
draftPost !== storedPost;
async function handleSave() {
if (sideMissing || !dirty) return;
const body: { doc_type?: string; appraiser_side?: string } = {};
const body: DocumentPatch = {};
if (draftType !== docType) body.doc_type = draftType;
if (isAppraisal && draftSide !== (appraiserSide || "")) {
body.appraiser_side = draftSide;
@@ -90,6 +110,16 @@ export function DocumentTypeEditor({
// clear it so it doesn't dangle confusingly in metadata.
if (!isAppraisal && appraiserSide) body.appraiser_side = "";
// protocol_scope: store only the non-default "lower"; anything else clears
// back to the implicit ועדת-הערר default. Also clear when leaving protocol.
if (isProtocol && draftScope !== storedScope) {
body.protocol_scope = draftScope === "lower" ? "lower" : "";
} else if (!isProtocol && protocolScope) {
body.protocol_scope = "";
}
if (draftPost !== storedPost) body.is_post_hearing = draftPost;
// Swallow the rejection — errors surface via `patch.isError`; an unhandled
// rejection from this async click handler would otherwise leak.
try {
@@ -190,6 +220,46 @@ export function DocumentTypeEditor({
</div>
)}
{isProtocol && (
<div className="space-y-1.5">
<label className="text-xs text-ink-muted">פרוטוקול של</label>
<Select value={draftScope} onValueChange={setDraftScope} dir="rtl">
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROTOCOL_SCOPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[0.65rem] text-ink-muted leading-tight">
ועדת הערר קבוצת הפרוטוקול · מקומית/מחוזית מסמכים נלווים
</p>
</div>
)}
<div className="flex items-start justify-between gap-3 rounded-md border border-rule bg-parchment/40 px-3 py-2.5">
<div className="space-y-0.5">
<label
htmlFor={`post-${docId}`}
className="text-xs font-medium text-ink block"
>
התקבל אחרי הדיון
</label>
<p className="text-[0.65rem] text-ink-muted leading-tight">
מעביר את המסמך לקבוצת המסמכים שלאחר הדיון ומזין את בלוק-ח
</p>
</div>
<Switch
id={`post-${docId}`}
checked={draftPost}
onCheckedChange={setDraftPost}
/>
</div>
{patch.isError && (
<p className="text-[0.7rem] text-danger">
שמירה נכשלה. נסה שוב.

View File

@@ -25,6 +25,13 @@ import { casesKeys } from "@/lib/api/cases";
import type { CaseDetail, CaseDocument } from "@/lib/api/cases";
import { DocumentTypeEditor } from "@/components/cases/document-type-editor";
import { formatDateShort as formatDate } from "@/lib/format-date";
import {
documentGroup,
DOC_GROUP_ORDER,
DOC_GROUP_LABELS,
DOC_GROUP_HINTS,
type DocGroup,
} from "@/lib/doc-types";
/*
* Document list for the case detail "מסמכים" tab. Uses the real document
@@ -224,6 +231,13 @@ function DocumentRow({
const canPreview =
doc.extraction_status === "completed" || doc.extraction_status === "proofread";
const meta = doc.metadata as
| { appraiser_side?: string; is_post_hearing?: boolean; protocol_scope?: string }
| undefined;
const isPostHearing = meta?.is_post_hearing === true;
const protocolScope = meta?.protocol_scope;
const isProtocol = doc.doc_type === "protocol";
return (
<>
<li className="py-3 flex items-start gap-3 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded group">
@@ -246,15 +260,24 @@ function DocumentRow({
{doc.created_at && <span>{formatDate(doc.created_at)}</span>}
</div>
</button>
{isProtocol && (
<span className="shrink-0 self-center rounded-full border border-rule bg-parchment px-2 py-0.5 text-[0.62rem] font-medium text-navy whitespace-nowrap">
{protocolScope === "lower" ? "ועדה מקומית" : "ועדת הערר"}
</span>
)}
{isPostHearing && (
<span className="shrink-0 self-center rounded-full border border-info/40 bg-info-bg px-2 py-0.5 text-[0.62rem] font-medium text-info whitespace-nowrap">
לאחר הדיון
</span>
)}
{doc.doc_type && (
<DocumentTypeEditor
caseNumber={caseNumber}
docId={doc.id}
docType={doc.doc_type}
appraiserSide={
(doc.metadata as { appraiser_side?: string } | undefined)
?.appraiser_side
}
appraiserSide={meta?.appraiser_side}
isPostHearing={isPostHearing}
protocolScope={protocolScope}
/>
)}
<button
@@ -306,6 +329,30 @@ function DocumentsShell({ count, children }: { count: number; children: ReactNod
);
}
/* ── Group section header ──────────────────────────────────────────
* The list is split into four convenience buckets (see documentGroup).
* The number is the fixed 1-4 position in DOC_GROUP_ORDER so a group keeps
* its identity even when earlier groups are empty and hidden. */
function GroupHeader({ group, count }: { group: DocGroup; count: number }) {
const index = DOC_GROUP_ORDER.indexOf(group) + 1;
return (
<div className="flex items-center gap-2.5 px-2 -mx-2 py-2 border-b border-rule-soft bg-parchment/50">
<span className="w-[19px] h-[19px] shrink-0 rounded-full bg-navy text-cream text-[0.66rem] font-bold flex items-center justify-center tabular-nums">
{index}
</span>
<span className="text-[0.8rem] font-bold text-navy shrink-0">
{DOC_GROUP_LABELS[group]}
</span>
<span className="shrink-0 text-[0.62rem] font-bold text-gold-deep bg-gold-wash border border-rule rounded-full px-1.5 tabular-nums">
{count}
</span>
<span className="text-[0.66rem] text-ink-muted font-medium truncate">
{DOC_GROUP_HINTS[group]}
</span>
</div>
);
}
export function DocumentsPanel({
data,
}: {
@@ -329,6 +376,16 @@ export function DocumentsPanel({
(a, b) => statusOrder(a.extraction_status) - statusOrder(b.extraction_status),
);
// Bucket into the four overview groups. Iterating `sorted` preserves the
// within-group status ordering. Grouping is display-only — see documentGroup.
const grouped = new Map<DocGroup, CaseDocument[]>();
for (const doc of sorted) {
const g = documentGroup(doc.doc_type, doc.metadata);
const bucket = grouped.get(g);
if (bucket) bucket.push(doc);
else grouped.set(g, [doc]);
}
const done = docs.filter(
(d) => d.extraction_status === "completed" || d.extraction_status === "proofread",
).length;
@@ -378,12 +435,21 @@ export function DocumentsPanel({
</div>
)}
<div className="max-h-[70vh] overflow-y-auto overflow-x-hidden" dir="rtl">
<ul className="divide-y divide-rule" dir="rtl">
{sorted.map((doc) => (
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
))}
</ul>
<div className="max-h-[70vh] overflow-y-auto overflow-x-hidden space-y-2" dir="rtl">
{DOC_GROUP_ORDER.map((group) => {
const groupDocs = grouped.get(group);
if (!groupDocs || groupDocs.length === 0) return null;
return (
<section key={group}>
<GroupHeader group={group} count={groupDocs.length} />
<ul className="divide-y divide-rule" dir="rtl">
{groupDocs.map((doc) => (
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
))}
</ul>
</section>
);
})}
</div>
</div>
</DocumentsShell>

View File

@@ -25,7 +25,13 @@ import {
useUploadFinalDecision,
useRunFinalLearning,
useRunFinalHalacha,
type ExportFile,
} from "@/lib/api/exports";
import {
usePartyClaimsSummary,
useGeneratePartyClaimsSummary,
useGenerateInterimDraft,
} from "@/lib/api/generate";
import {
useCaseFeedback,
useCreateFeedback,
@@ -55,6 +61,10 @@ import {
Stamp,
Link2,
AlertTriangle,
Zap,
ClipboardList,
Files,
ExternalLink,
} from "lucide-react";
/* Statuses at which a draft is considered ready */
@@ -76,6 +86,28 @@ function formatDate(epoch: number): string {
return formatDateTime(epoch * 1000);
}
/**
* Pick the newest export file whose name starts with `prefix`, preferring the
* highest v-number and falling back to the latest created_at. Used by the
* "הפקת מסמכים" card to surface the most recent טיוטת-ביניים-* file (#214).
*/
function pickLatestVersioned(
files: ExportFile[] | undefined,
prefix: string,
): (ExportFile & { version: number | null }) | null {
const matches = (files ?? []).filter((f) => f.filename.startsWith(prefix));
if (!matches.length) return null;
const withVer = matches.map((f) => {
const m = f.filename.match(/v(\d+)/);
return { ...f, version: m ? parseInt(m[1], 10) : null };
});
withVer.sort((a, b) => {
if (a.version != null && b.version != null) return b.version - a.version;
return b.created_at - a.created_at;
});
return withVer[0];
}
/* ── Main component ─────────────────────────────────── */
export function DraftsPanel({
@@ -97,6 +129,10 @@ export function DraftsPanel({
const uploadFinal = useUploadFinalDecision(caseNumber);
const runLearning = useRunFinalLearning(caseNumber);
const runHalacha = useRunFinalHalacha(caseNumber);
// ── Document generation (#214) — the "הפקת מסמכים" card ──
const { data: summary } = usePartyClaimsSummary(caseNumber);
const genSummary = useGeneratePartyClaimsSummary(caseNumber);
const genInterim = useGenerateInterimDraft(caseNumber);
const qc = useQueryClient();
const fileRef = useRef<HTMLInputElement>(null);
@@ -129,6 +165,38 @@ export function DraftsPanel({
return `טיוטה v${maxVer}${suffix} מוכנה לעיון`;
})();
// ── "הפקת מסמכים" derived state (#214) ──
// Latest interim ("party-claims") partial draft in the exports table — the poll
// target for button 2. The CEO writes it as טיוטת-ביניים-{case}-vN.docx.
const latestInterim = pickLatestVersioned(exports, "טיוטת-ביניים-");
const summaryReady = Boolean(summary?.markdown);
function handleGenerateSummary() {
genSummary.mutate(undefined, {
onSuccess: (d) => {
if (d.status === "ok") {
toast.success("הופעלה הפקת סיכום-המנהלים — רצה ברקע (אופוס)");
} else {
toast.warning(`לא הופעלה ההפקה: ${d.reason ?? d.error ?? d.status}`);
}
},
onError: () => toast.error("שגיאה בהפעלת הפקת סיכום-המנהלים"),
});
}
function handleGenerateInterim() {
genInterim.mutate(undefined, {
onSuccess: (d) => {
if (d.status === "ok") {
toast.success("הופעלה הפקת טיוטת טענות-הצדדים — רצה ברקע (אופוס)");
} else {
toast.warning(`לא הופעלה ההפקה: ${d.reason ?? d.error ?? d.status}`);
}
},
onError: () => toast.error("שגיאה בהפעלת הפקת הטיוטה החלקית"),
});
}
function handleUpload(file: File) {
uploadDraft.mutate(file, {
onSuccess: (data) => {
@@ -233,34 +301,184 @@ export function DraftsPanel({
}
return (
<div className="space-y-6">
{/* ── Banner ── */}
// flex + per-card `order` so the visual order matches mockup 26:
// generate-card → banner → active-draft → drafts → final+learning → feedback.
<div className="flex flex-col gap-6">
{/* ── Card: document generation (mockup 26) — 3 deterministic "הפק" buttons ── */}
<section className="order-0 rounded-lg border border-rule bg-surface overflow-hidden">
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-rule-soft bg-parchment">
<Zap className="w-[18px] h-[18px] text-gold-deep shrink-0" />
<h3 className="text-navy text-base font-semibold">הפקת מסמכים</h3>
</div>
<div className="p-4">
<p className="text-xs text-ink-muted leading-relaxed mb-1">
הפקה דטרמיניסטית בלחיצה בלי בקשת-טקסט. כל כפתור מריץ פעולה מדויקת,
והקובץ מופיע בטבלת-הטיוטות / לקריאה מקומית.
</p>
{/* 1 — executive summary of party claims */}
<div className="flex items-start gap-3 py-3 border-t border-rule-soft first-of-type:border-t-0">
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
<ClipboardList className="w-[18px] h-[18px]" />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-navy">הפק סיכום מנהלים</div>
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
תמצית מזוקקת של טענות הצדדים לקראת הדיון מסמך נפרד מהטיוטה.
</p>
<div className="flex flex-wrap items-center gap-2.5 mt-2">
<Button
size="sm"
onClick={handleGenerateSummary}
disabled={genSummary.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{genSummary.isPending ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
מייצר
</>
) : (
"הפק"
)}
</Button>
{summaryReady && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-gold-deep"
onClick={() =>
window.open(
`/api/cases/${caseNumber}/research/party-claims-summary/download`,
"_blank",
)
}
>
<ExternalLink className="w-3.5 h-3.5 me-1" />
פתח
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-gold-deep"
onClick={() =>
window.open(
`/api/cases/${caseNumber}/research/party-claims-summary/export-docx`,
"_blank",
)
}
>
<Download className="w-3.5 h-3.5 me-1" />
DOCX
</Button>
</>
)}
</div>
</div>
</div>
{/* 2 — partial draft (parties' claims, blocks ה–ט) */}
<div className="flex items-start gap-3 py-3 border-t border-rule-soft">
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
<FileText className="w-[18px] h-[18px]" />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-navy">
הפק טיוטה של טענות הצדדים{" "}
<span className="font-medium text-ink-muted text-xs">
(טיוטה חלקית)
</span>
</div>
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
בלוקים הט (רקע, טענות, הליכים, תכניות) עד הדיון, בלי דיון
והכרעה. נשמרת בטבלת-הטיוטות.
</p>
<div className="flex flex-wrap items-center gap-2.5 mt-2">
<Button
size="sm"
onClick={handleGenerateInterim}
disabled={genInterim.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{genInterim.isPending ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
מייצר
</>
) : (
"הפק"
)}
</Button>
{latestInterim && (
<span className="text-[0.7rem] text-ink-muted">
{latestInterim.version
? `v${latestInterim.version} · `
: ""}
{formatDate(latestInterim.created_at)}
</span>
)}
</div>
</div>
</div>
{/* 3 — full decision draft (REUSES useExportDocx; moved from the banner) */}
<div className="flex items-start gap-3 py-3 border-t border-rule-soft">
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
<Files className="w-[18px] h-[18px]" />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-navy">
הפק טיוטת החלטה מלאה
</div>
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
ההחלטה המלאה כל 12 הבלוקים, כולל דיון והכרעה. נקבעת
כמקור-האמת ונשמרת בטבלת-הטיוטות.
</p>
<div className="flex flex-wrap items-center gap-2.5 mt-2">
<Button
size="sm"
onClick={handleExport}
disabled={exportDocx.isPending || !isDraftReady}
title={!isDraftReady ? "זמין כשיש בלוקים כתובים" : undefined}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{exportDocx.isPending ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
מייצר
</>
) : (
<>
<FileOutput className="w-3.5 h-3.5 me-1.5" />
הפק
</>
)}
</Button>
{!isDraftReady && (
<span className="rounded bg-warn-bg px-2 py-0.5 text-[0.65rem] text-warn">
זמין כשיש בלוקים כתובים
</span>
)}
</div>
</div>
</div>
</div>
</section>
{/* ── Banner — draft-ready indicator (export button moved to "הפקת מסמכים") ── */}
{isDraftReady && (
<div className="flex items-center gap-3 rounded-lg border border-gold/40 bg-gold-wash px-4 py-3">
<div className="order-1 flex items-center gap-3 rounded-lg border border-gold/40 bg-gold-wash px-4 py-3">
<FileText className="w-5 h-5 text-gold-deep shrink-0" />
<span className="text-sm font-medium text-gold-deep">
{draftLabel}
</span>
<div className="me-auto" />
<Button
size="sm"
onClick={handleExport}
disabled={exportDocx.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{exportDocx.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
) : (
<FileOutput className="w-4 h-4 me-1.5" />
)}
הפק DOCX
</Button>
</div>
)}
{/* ── Active-draft badge — the DOCX that is the current source of truth ── */}
{activeDraft?.filename && (
<div className="flex items-center gap-2 text-xs text-ink-muted">
<div className="order-2 flex items-center gap-2 text-xs text-ink-muted">
<span>מקור האמת:</span>
<Badge variant="outline" className="bg-surface">
{activeDraft.filename}
@@ -269,7 +487,7 @@ export function DraftsPanel({
)}
{/* ── Chair's signed final decision — clean upload + staged learning pipeline ── */}
<section className="rounded-lg border border-gold/40 bg-gold-wash/40 p-4 space-y-3">
<section className="order-4 rounded-lg border border-gold/40 bg-gold-wash/40 p-4 space-y-3">
<input
ref={finalFileRef}
type="file"
@@ -353,7 +571,7 @@ export function DraftsPanel({
</section>
{/* ── Card: draft files (mockup 18h) ── */}
<section className="rounded-lg border border-rule bg-surface p-4">
<section className="order-3 rounded-lg border border-rule bg-surface p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-navy text-base">קבצי טיוטה</h3>
<div className="flex items-center gap-2">
@@ -522,7 +740,7 @@ export function DraftsPanel({
</section>
{/* ── Card: chair feedback (mockup 18h) ── */}
<section className="rounded-lg border border-rule bg-surface p-4">
<section className="order-5 rounded-lg border border-rule bg-surface p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-navy text-base">
הערות יו״ר

View File

@@ -76,11 +76,50 @@ type PartySectionProps = {
};
/**
* Inner body of a single party — the priority-grouping + per-argument inner
* accordion. The party header (name / sub / count) now lives on the enclosing
* party-accordion trigger (mockup 25b), so this renders the groups only.
* Inner body of a single party. For a multi-litigant side (respondent /
* permit_applicant) whose arguments carry distinct ``party_name`` brief labels,
* we sub-group by brief first — so opposing joint briefs (e.g. "משיבות 2-3" vs
* "משיבים 4-6") read as separate positions rather than one merged list (#224).
* Single-voice sides (one empty brief) render the priority groups directly.
*/
function PartySection({ args }: PartySectionProps) {
const briefs = useMemo(() => {
const byBrief = new Map<string, LegalArgument[]>();
for (const a of args) {
const key = a.party_name?.trim() ?? "";
const list = byBrief.get(key);
if (list) list.push(a);
else byBrief.set(key, [a]);
}
return [...byBrief.entries()];
}, [args]);
// No real per-brief split (single, unnamed group) → render groups directly.
if (briefs.length <= 1) {
return <PriorityGroups args={briefs[0]?.[1] ?? args} />;
}
return (
<div className="space-y-5">
{briefs.map(([brief, briefArgs]) => (
<div key={brief || "—"} className="space-y-2">
<div className="flex items-center gap-2 border-s-2 border-gold-deep ps-2">
<span className="text-navy text-sm font-semibold">
{brief || "ללא שיוך לכתב"}
</span>
<span className="text-ink-muted text-xs">
{briefArgs.length} טיעונים
</span>
</div>
<PriorityGroups args={briefArgs} />
</div>
))}
</div>
);
}
/** Priority-grouped argument accordion for one coherent set of arguments. */
function PriorityGroups({ args }: PartySectionProps) {
const grouped = useMemo(() => groupByPriority(args), [args]);
return (
<div className="space-y-4">

View File

@@ -17,6 +17,8 @@ const STATUS_ICONS: Record<CaseStatus, LucideIcon> = {
new: FilePlus2,
processing: Loader2,
documents_ready: FileCheck,
analyst_verified: SearchCheck,
research_complete: Compass,
outcome_set: Target,
direction_approved: Compass,
qa_review: SearchCheck,
@@ -36,6 +38,8 @@ const STATUS_TONE: Record<CaseStatus, string> = {
new: "bg-rule-soft text-ink-muted border-rule",
processing: "bg-info-bg text-info border-info/30",
documents_ready: "bg-info-bg text-info border-info/40",
analyst_verified: "bg-gold-wash text-gold-deep border-gold/40",
research_complete: "bg-gold-wash text-gold-deep border-gold/40",
outcome_set: "bg-gold-wash text-gold-deep border-gold/40",
direction_approved:"bg-gold-wash text-gold-deep border-gold/50",
qa_review: "bg-warn-bg text-warn border-warn/40",

View File

@@ -180,6 +180,26 @@ export function useSubmitInteraction(caseNumber: string | undefined) {
});
}
/** Dismiss a pending interaction WITHOUT waking the issue assignee — used for
* stale/duplicate questions. Backend marks it `cancelled` directly (the
* `wake_assignee` continuation policy means a normal resolve would wake the
* agent; dismiss must not). */
export function useDismissInteraction(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: (vars: { issue_id: string; interaction_id: string }) =>
apiRequest<{ ok: boolean; cancelled: boolean }>(
`/api/cases/${caseNumber}/agents/interaction-dismiss`,
{ method: "POST", body: vars },
),
onSuccess: () => {
if (caseNumber) {
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
}
},
});
}
export type AgentResetResult = {
ok: boolean;
reassigned_issues: { id: string; identifier: string }[];

View File

@@ -1,16 +1,19 @@
/**
* Single source of truth for the case-status lifecycle (UI-B1 / G2).
* Frontend mirror of the canonical case-status lifecycle.
*
* The 17-status manual menu was trimmed to the **10 core statuses** that the
* pipeline actually sets or that gate real logic. Decorative mid-stage markers
* (uploading, analyst_verified, research_complete, brainstorming,
* analysis_enriched, ready_for_writing, drafting) plus the legacy/dead
* `in_progress` and `qa_failed` were removed — no pipeline code ever set them.
* The ONE source of truth is the backend status model
* (`mcp-server/src/legal_mcp/case_status_model.py`), exposed at
* `GET /api/status-model`; this file mirrors it (order / labels / phases) so the
* UI keeps compile-time `CaseStatus` typing. Keep the two in sync — the backend
* registry is authoritative (it also declares per-status `on_enter` actions).
*
* Every status consumer (badge, changer, timeline, guide, donut, KPI cards,
* compose chip) imports the list / labels / phases from here instead of
* re-declaring its own array. Keep this file in sync with the backend
* STATUS_ORDER in `mcp-server/src/legal_mcp/tools/cases.py`.
* The analyst/research intermediate states (`analyst_verified`,
* `research_complete`) are first-class canonical statuses — the agents set them,
* so the chip, stepper and manual changer all agree instead of the status
* falling between the canonical set and a legacy bucket.
*
* Every status consumer (badge, changer, timeline, guide, donut, KPI cards)
* imports the list / labels / phases from here instead of re-declaring its own.
*/
/** Ordered lifecycle — also the order shown in the manual status dropdown. */
@@ -18,6 +21,8 @@ export const CASE_STATUSES = [
"new",
"processing",
"documents_ready",
"analyst_verified",
"research_complete",
"outcome_set",
"direction_approved",
"qa_review",
@@ -35,22 +40,18 @@ export type PhaseKey = "intake" | "prep" | "thinking" | "writing" | "done";
export const PHASES: { key: PhaseKey; label: string; statuses: CaseStatus[] }[] = [
{ key: "intake", label: "קליטה ועיבוד", statuses: ["new", "processing"] },
{ key: "prep", label: "הכנת תיק", statuses: ["documents_ready"] },
{ key: "thinking", label: "ניתוח וכיוון", statuses: ["outcome_set", "direction_approved"] },
{ key: "thinking", label: "ניתוח וכיוון", statuses: ["analyst_verified", "research_complete", "outcome_set", "direction_approved"] },
{ key: "writing", label: "כתיבת טיוטה", statuses: ["qa_review", "drafted"] },
{ key: "done", label: "סגירה", statuses: ["exported", "reviewed", "final"] },
];
/**
* Legacy/intermediate statuses that the trimmed PHASES list dropped but the
* Paperclip agents still set (e.g. the analyst writes `analyst_verified`, the
* researcher writes `research_complete`). Without this map a case parked on one
* of them renders with NO active phase — the pipeline stepper goes blank and the
* chair can't see where the case stands. Map each to the phase it belongs to for
* display purposes only (these are NOT selectable statuses).
* Truly-legacy statuses that are NOT in the canonical set but might still arrive
* from an old client / un-migrated row — mapped to a phase for display only, so
* the stepper never goes blank. (The analyst/research intermediates are NO
* longer here — they are canonical statuses in CASE_STATUSES above.)
*/
const LEGACY_STATUS_PHASE: Record<string, PhaseKey> = {
analyst_verified: "thinking",
research_complete: "thinking",
analysis_enriched: "thinking",
ready_for_writing: "writing",
drafting: "writing",
@@ -80,6 +81,8 @@ export const STATUS_LABELS: Record<CaseStatus, string> = {
new: "חדש",
processing: "בעיבוד",
documents_ready: "מסמכים מוכנים",
analyst_verified: "ניתוח אומת",
research_complete: "מחקר הושלם",
outcome_set: "תוצאה נקבעה",
direction_approved: "כיוון אושר",
qa_review: "בדיקת איכות",
@@ -97,8 +100,6 @@ export const STATUS_LABELS: Record<CaseStatus, string> = {
const LEGACY_STATUS_LABELS: Record<string, string> = {
in_progress: "בעבודה",
uploading: "מעלה",
analyst_verified: "ניתוח אומת",
research_complete: "מחקר הושלם",
brainstorming: "סיעור מוחות",
analysis_enriched: "ניתוח הועמק",
ready_for_writing: "מוכן לכתיבה",
@@ -124,6 +125,8 @@ export const STATUS_DESCRIPTIONS: Record<CaseStatus, string> = {
new: "התיק נוצר וממתין להעלאת מסמכים",
processing: "המערכת מעבדת ומנתחת את המסמכים",
documents_ready: "כל המסמכים עובדו ומוכנים לעבודה",
analyst_verified: "המנתח סיים ואימת את הניתוח — ממתין להכרעת תוצאה",
research_complete: "חקר התקדימים הושלם (מסלול נפרד מהמנתח)",
outcome_set: "נקבעה תוצאה צפויה לערר",
direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה",
qa_review: "הטיוטה בבדיקת איכות אוטומטית",

View File

@@ -92,6 +92,8 @@ export function useUploadDocument(caseNumber: string) {
export type DocumentPatch = {
doc_type?: string;
appraiser_side?: string; // "" clears; "committee" | "appellant" | "deciding" sets
is_post_hearing?: boolean; // true = received after the hearing (→ group 4); false clears
protocol_scope?: string; // "" clears; "appeal" | "lower" (protocol docs only)
};
export type PatchDocumentResponse = {

View File

@@ -0,0 +1,92 @@
/**
* Document-generation domain hooks (TaskMaster #214).
*
* The "הפקת מסמכים" card in the case "טיוטות והערות" tab fires two deterministic,
* host-side generation actions through the CEO (structured-action wakeup):
* • party_claims_summary → an executive summary of the parties' claims
* • interim_draft → a partial draft (blocks ה–ט, up to the discussion)
*
* Generation runs on the host (claude_session → claude -p), NOT in the container,
* so the trigger endpoints return 202-accepted and the UI POLLS for completion:
* • summary → GET /api/cases/{n}/research/party-claims-summary (200 = ready)
* • interim draft → the exports list grows a "טיוטת-ביניים-{n}-vN.docx" file
* (polled via useExports — see exports.ts).
*
* The full-decision export ("הפק טיוטת החלטה מלאה") is NOT here — it reuses the
* existing in-container useExportDocx (exports.ts), no LLM, no wakeup.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, apiRequest } from "./client";
/** Shape returned by the structured-action wakeup endpoints (#214). */
export type GenerateTriggerResult = {
status: "ok" | "skipped" | "error";
action?: string;
issue_id?: string;
ceo_id?: string;
reason?: string;
error?: string;
};
export const generateKeys = {
all: ["generate"] as const,
partyClaimsSummary: (caseNumber: string) =>
[...generateKeys.all, "party-claims-summary", caseNumber] as const,
};
/* ── Read: party-claims executive summary (poll target for button 1) ──
404 (not generated yet) is a normal "not ready" state, not an error — we
coerce it to null so the panel renders the empty/idle state cleanly. */
export type PartyClaimsSummary = { markdown: string } | null;
export function usePartyClaimsSummary(caseNumber: string | undefined) {
return useQuery<PartyClaimsSummary>({
queryKey: generateKeys.partyClaimsSummary(caseNumber ?? ""),
queryFn: async ({ signal }) => {
try {
return await apiRequest<{ markdown: string }>(
`/api/cases/${caseNumber}/research/party-claims-summary`,
{ signal },
);
} catch (e) {
if (e instanceof ApiError && e.status === 404) return null;
throw e;
}
},
enabled: Boolean(caseNumber),
staleTime: 5_000,
retry: false,
});
}
/* ── Trigger: party-claims executive summary (button 1) ── */
export function useGeneratePartyClaimsSummary(caseNumber: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () =>
apiRequest<GenerateTriggerResult>(
`/api/cases/${caseNumber}/generate/party-claims-summary`,
{ method: "POST" },
),
onSuccess: () => {
// Start polling fresh — the file will appear after the host run completes.
qc.invalidateQueries({
queryKey: generateKeys.partyClaimsSummary(caseNumber),
});
},
});
}
/* ── Trigger: interim ("party-claims") partial draft (button 2) ── */
export function useGenerateInterimDraft(caseNumber: string) {
return useMutation({
mutationFn: () =>
apiRequest<GenerateTriggerResult>(
`/api/cases/${caseNumber}/generate/interim-draft`,
{ method: "POST" },
),
// The result lands in the exports list (טיוטת-ביניים-*.docx); useExports
// already polls on a 5s interval, so no extra invalidation is needed here.
});
}

View File

@@ -32,6 +32,12 @@ export type LegalArgument = {
id: string;
case_id: string;
party: LegalArgumentParty;
/**
* The specific pleading this argument belongs to, for sides that may comprise
* several litigants with opposing positions (respondent / permit_applicant
* briefs). Empty for single-voice sides (appellant / committee). (#224)
*/
party_name?: string;
argument_index: number;
argument_title: string;
argument_body: string;

View File

@@ -692,6 +692,28 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/status-model": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Api Status Model
* @description Canonical case-status model — ordered statuses (key/label/description/
* phase/selectable/terminal/on_enter) + the 5 phases. Single source of truth
* (legal_mcp/case_status_model.py); the frontend case-status.ts mirrors it.
*/
get: operations["api_status_model_api_status_model_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/cases": {
parameters: {
query?: never;
@@ -1353,6 +1375,50 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/cases/{case_number}/generate/party-claims-summary": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Api Generate Party Claims Summary
* @description Fire-and-accept: wake the CEO to generate the party-claims executive summary
* (שלב H2 → summarize_party_claims). Runs host-side; poll
* GET /api/cases/{n}/research/party-claims-summary for completion.
*/
post: operations["api_generate_party_claims_summary_api_cases__case_number__generate_party_claims_summary_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/cases/{case_number}/generate/interim-draft": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Api Generate Interim Draft
* @description Fire-and-accept: wake the CEO to generate the partial "party-claims" draft
* (שלב H → write_interim_draft + export_interim_draft). Runs host-side; poll the
* exports list for the new טיוטת-ביניים-{case}-vN.docx.
*/
post: operations["api_generate_interim_draft_api_cases__case_number__generate_interim_draft_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/cases/{case_number}/research/party-claims-summary/download": {
parameters: {
query?: never;
@@ -2061,6 +2127,30 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/cases/{case_number}/agents/interaction-dismiss": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Api Dismiss Interaction
* @description Dismiss a pending interaction WITHOUT waking the issue assignee.
*
* For stale/duplicate questions (TaskMaster #215). Unlike interaction-response,
* this does not resolve via Paperclip's wake_assignee path — it cancels the row
* so the chair can clear noise without triggering an agent run.
*/
post: operations["api_dismiss_interaction_api_cases__case_number__agents_interaction_dismiss_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/cases/{case_number}/agents/reset": {
parameters: {
query?: never;
@@ -4587,13 +4677,19 @@ export interface components {
};
/**
* DocumentPatchRequest
* @description Patch payload for a single document. Both fields are optional.
* @description Patch payload for a single document. All fields are optional; only the
* ones present are applied. The metadata.* flags are display/processing hints
* that never change the stored doc_type.
*/
DocumentPatchRequest: {
/** Doc Type */
doc_type?: string | null;
/** Appraiser Side */
appraiser_side?: string | null;
/** Is Post Hearing */
is_post_hearing?: boolean | null;
/** Protocol Scope */
protocol_scope?: string | null;
};
/** EquivalentLinkRequest */
EquivalentLinkRequest: {
@@ -4758,6 +4854,13 @@ export interface components {
/** Canonical Statement */
canonical_statement?: string | null;
};
/** InteractionDismissRequest */
InteractionDismissRequest: {
/** Issue Id */
issue_id: string;
/** Interaction Id */
interaction_id: string;
};
/** InteractionResponseRequest */
InteractionResponseRequest: {
/** Issue Id */
@@ -6147,6 +6250,26 @@ export interface operations {
};
};
};
api_status_model_api_status_model_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
list_cases_api_cases_get: {
parameters: {
query?: {
@@ -7151,6 +7274,68 @@ export interface operations {
};
};
};
api_generate_party_claims_summary_api_cases__case_number__generate_party_claims_summary_post: {
parameters: {
query?: never;
header?: never;
path: {
case_number: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
api_generate_interim_draft_api_cases__case_number__generate_interim_draft_post: {
parameters: {
query?: never;
header?: never;
path: {
case_number: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
api_party_claims_summary_download_api_cases__case_number__research_party_claims_summary_download_get: {
parameters: {
query?: never;
@@ -8224,6 +8409,41 @@ export interface operations {
};
};
};
api_dismiss_interaction_api_cases__case_number__agents_interaction_dismiss_post: {
parameters: {
query?: never;
header?: never;
path: {
case_number: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["InteractionDismissRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
api_reset_case_agents_api_cases__case_number__agents_reset_post: {
parameters: {
query?: never;

View File

@@ -64,6 +64,79 @@ export function doctypeTone(value: string): string {
}
}
// ── Overview groups (display-only convenience buckets) ─────────────
//
// The case-overview document list is split into four sections purely for
// the chair's convenience — grouping NEVER changes a document's stored
// doc_type. Classification is derived on the client from doc_type plus two
// metadata flags (is_post_hearing, protocol_scope).
export type DocGroup = "primary" | "ancillary" | "hearing_protocol" | "post_hearing";
export const DOC_GROUP_LABELS: Record<DocGroup, string> = {
primary: "מסמכים עיקריים",
ancillary: "מסמכים נלווים",
hearing_protocol: "פרוטוקול דיון ועדת הערר",
post_hearing: "מסמכים לאחר הדיון",
};
export const DOC_GROUP_HINTS: Record<DocGroup, string> = {
primary: "כתבי ערר, תשובות ושומות שהוגשו לפני הדיון",
ancillary: "פרוטוקולי/החלטות ועדה מקומית-מחוזית, תכניות, וכל השאר",
hearing_protocol: "הדיון בפני הוועדה",
post_hearing: "השלמות ותגובות שהתקבלו לאחר הדיון",
};
/** Fixed render order of the groups in the overview panel. */
export const DOC_GROUP_ORDER: DocGroup[] = [
"primary",
"ancillary",
"hearing_protocol",
"post_hearing",
];
/** doc_types that count as "primary" pre-hearing filings (group 1). */
const PRIMARY_DOC_TYPES = new Set<DocType>(["appeal", "response", "appraisal"]);
/**
* Classify a document into one of the four overview groups.
*
* Priority order matters:
* 1. is_post_hearing wins over everything — a post-hearing כתב תשובה
* belongs in group 4, not group 1.
* 2. A protocol goes to group 3 (ועדת הערר) by default, or group 2 when
* explicitly tagged as a lower-committee protocol (protocol_scope==="lower").
* 3. appeal/response/appraisal → group 1; everything else → group 2.
*/
export function documentGroup(
docType: string,
metadata?: Record<string, unknown> | null,
): DocGroup {
if (metadata?.is_post_hearing === true) return "post_hearing";
if (docType === "protocol") {
return metadata?.protocol_scope === "lower" ? "ancillary" : "hearing_protocol";
}
if (PRIMARY_DOC_TYPES.has(docType as DocType)) return "primary";
return "ancillary";
}
// ── Protocol scope (only relevant when doc_type === "protocol") ────
// Distinguishes the appeal-committee hearing protocol (group 3) from a
// lower (local/district) committee protocol (group 2). Stored in
// documents.metadata.protocol_scope; absent === "appeal" (the default).
export type ProtocolScope = "appeal" | "lower";
export const PROTOCOL_SCOPE_LABELS: Record<ProtocolScope, string> = {
appeal: "ועדת הערר",
lower: "ועדה מקומית/מחוזית",
};
export const PROTOCOL_SCOPE_OPTIONS: { value: ProtocolScope; label: string }[] = [
{ value: "appeal", label: PROTOCOL_SCOPE_LABELS.appeal },
{ value: "lower", label: PROTOCOL_SCOPE_LABELS.lower },
];
// ── Appraiser sides (only relevant when doc_type === "appraisal") ──
export type AppraiserSide = "committee" | "appellant" | "deciding";

View File

@@ -37,6 +37,7 @@ from web.paperclip_client import (
COMPANIES as PAPERCLIP_COMPANIES,
accept_interaction as pc_accept_interaction,
archive_project as pc_archive_project,
cancel_interaction as pc_cancel_interaction,
cancel_run as pc_cancel_run,
create_project as pc_create_project,
create_workflow_issue as pc_create_workflow_issue,
@@ -50,6 +51,7 @@ from web.paperclip_client import (
get_run_log as pc_get_run_log,
list_live_runs as pc_list_live_runs,
post_comment as pc_post_comment,
reap_stale_interactions as pc_reap_stale_interactions,
reject_interaction as pc_reject_interaction,
reset_agent_session as pc_reset_agent_session,
reset_case_agents as pc_reset_case_agents,
@@ -59,6 +61,7 @@ from web.paperclip_client import (
wake_analyst_for_appraiser_facts as pc_wake_analyst_for_appraiser_facts,
wake_analyst_for_argument_aggregation as pc_wake_analyst_for_argument_aggregation,
wake_ceo_agent as pc_wake_ceo,
wake_ceo_for_action as pc_wake_ceo_for_action,
wake_ceo_for_feedback_fold as pc_wake_ceo_for_feedback_fold,
wake_curator_for_final as pc_wake_curator_for_final,
wake_for_precedent_extraction as pc_wake_for_precedent_extraction,
@@ -97,6 +100,7 @@ __all__ = [
"pc_get_agents_for_case",
"pc_get_agents",
"pc_wake_ceo",
"pc_wake_ceo_for_action",
"pc_wake_ceo_for_feedback_fold",
"pc_wake_curator_for_final",
"pc_wake_for_precedent_extraction",
@@ -109,6 +113,8 @@ __all__ = [
"pc_accept_interaction",
"pc_reject_interaction",
"pc_respond_to_interaction",
"pc_cancel_interaction",
"pc_reap_stale_interactions",
# agent-run observability + control (live view + smart management)
"pc_list_live_runs",
"pc_get_run_log",

View File

@@ -57,6 +57,7 @@ from web.agent_platform_port import (
get_project_url,
pc_accept_interaction,
pc_archive_project,
pc_cancel_interaction,
pc_cancel_run,
pc_create_project,
pc_create_workflow_issue,
@@ -69,6 +70,7 @@ from web.agent_platform_port import (
pc_get_run_log,
pc_list_live_runs,
pc_post_comment,
pc_reap_stale_interactions,
pc_reject_interaction,
pc_request,
pc_reset_agent_session,
@@ -79,6 +81,7 @@ from web.agent_platform_port import (
pc_wake_analyst_for_argument_aggregation,
rename_case_project,
pc_wake_ceo,
pc_wake_ceo_for_action,
pc_wake_ceo_for_feedback_fold,
pc_wake_curator_for_final,
pc_wake_for_precedent_extraction,
@@ -100,19 +103,38 @@ PROGRESS_TTL_SECONDS = 300
_progress = ProgressStore(config.REDIS_URL, ttl_seconds=PROGRESS_TTL_SECONDS)
async def _interaction_reaper_loop():
"""Safety-net reaper for stranded pending interactions (TaskMaster #215).
Every 15 min, cancel pending interactions on closed (done/cancelled) issues
so the chair's "awaiting you" count never accumulates dead questions.
"""
while True:
try:
await asyncio.sleep(900)
await pc_reap_stale_interactions()
except asyncio.CancelledError:
raise
except Exception:
logger.warning("interaction reaper sweep failed", exc_info=True)
@asynccontextmanager
async def lifespan(app: FastAPI):
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
await db.init_schema()
sync_task = asyncio.create_task(git_sync.sweep_loop())
reaper_task = asyncio.create_task(_interaction_reaper_loop())
try:
yield
finally:
sync_task.cancel()
try:
await sync_task
except asyncio.CancelledError:
pass
reaper_task.cancel()
for _t in (sync_task, reaper_task):
try:
await _t
except asyncio.CancelledError:
pass
await db.close_pool()
await _progress.close()
@@ -1828,6 +1850,16 @@ async def health():
return {"status": "ok"}
@app.get("/api/status-model")
async def api_status_model():
"""Canonical case-status model — ordered statuses (key/label/description/
phase/selectable/terminal/on_enter) + the 5 phases. Single source of truth
(legal_mcp/case_status_model.py); the frontend case-status.ts mirrors it."""
from legal_mcp.case_status_model import to_dict
return to_dict()
@app.get("/api/cases")
async def list_cases(
detail: bool = False,
@@ -3080,6 +3112,51 @@ async def api_party_claims_summary(case_number: str):
return {"markdown": path.read_text(encoding="utf-8")}
# ── Deterministic document generation triggers (TaskMaster #214) ─────
# The "הפקת מסמכים" buttons in the case "טיוטות והערות" tab. Generation for the
# executive summary + interim draft runs HOST-SIDE (claude_session → claude -p,
# not in the container), so these endpoints fire a *structured-action* CEO wakeup
# (the CEO reads payload.action and routes to שלב H2 / שלב H — legal-ceo.md).
# Wakeup goes through the Paperclip API helper (pc_wake_ceo_for_action → the
# platform Port → POST /api/agents/{id}/wakeup), NEVER a direct DB insert.
# Polling for completion uses the existing read endpoints (research/party-claims-
# summary for the summary; the exports list for the טיוטת-ביניים-*.docx file).
async def _wake_ceo_action(case_number: str, action: str) -> dict:
"""Resolve the case's company + fire a structured-action CEO wakeup (#214)."""
case = await db.get_case_by_number(case_number)
if not case:
raise HTTPException(404, f"תיק {case_number} לא נמצא")
prefix = case_number[:1]
company_id = (
PAPERCLIP_COMPANIES["licensing"] if prefix == "1"
else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9")
else ""
)
try:
return await pc_wake_ceo_for_action(case_number, action, company_id=company_id)
except Exception as e:
logger.warning("CEO action %s wakeup failed for %s: %s", action, case_number, e)
return {"status": "error", "error": str(e)}
@app.post("/api/cases/{case_number}/generate/party-claims-summary", status_code=202)
async def api_generate_party_claims_summary(case_number: str):
"""Fire-and-accept: wake the CEO to generate the party-claims executive summary
(שלב H2 → summarize_party_claims). Runs host-side; poll
GET /api/cases/{n}/research/party-claims-summary for completion."""
return await _wake_ceo_action(case_number, "party_claims_summary")
@app.post("/api/cases/{case_number}/generate/interim-draft", status_code=202)
async def api_generate_interim_draft(case_number: str):
"""Fire-and-accept: wake the CEO to generate the partial "party-claims" draft
(שלב H → write_interim_draft + export_interim_draft). Runs host-side; poll the
exports list for the new טיוטת-ביניים-{case}-vN.docx."""
return await _wake_ceo_action(case_number, "interim_draft")
@app.get("/api/cases/{case_number}/research/party-claims-summary/download")
async def api_party_claims_summary_download(case_number: str):
"""Download the raw party-claims-summary.md file."""
@@ -4355,6 +4432,30 @@ async def api_post_interaction_response(
raise HTTPException(502, f"שגיאת Paperclip: {e}")
class InteractionDismissRequest(BaseModel):
issue_id: str
interaction_id: str
@app.post("/api/cases/{case_number}/agents/interaction-dismiss")
async def api_dismiss_interaction(
case_number: str, req: InteractionDismissRequest,
):
"""Dismiss a pending interaction WITHOUT waking the issue assignee.
For stale/duplicate questions (TaskMaster #215). Unlike interaction-response,
this does not resolve via Paperclip's wake_assignee path — it cancels the row
so the chair can clear noise without triggering an agent run.
"""
issues = await pc_get_case_issues(case_number)
if not any(i["id"] == req.issue_id for i in issues):
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
result = await pc_cancel_interaction(req.issue_id, req.interaction_id)
if not result.get("ok"):
raise HTTPException(404, result.get("error", "ביטול הבקשה נכשל"))
return result
@app.post("/api/cases/{case_number}/agents/reset")
async def api_reset_case_agents(case_number: str):
"""Reset stuck agents for a case.
@@ -5621,13 +5722,21 @@ async def api_reprocess_document(case_number: str, doc_id: str):
_ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding"}
# metadata.protocol_scope — only meaningful when doc_type == "protocol".
# "appeal" = ועדת הערר (default when absent), "lower" = ועדה מקומית/מחוזית.
# Used by the overview panel to sort a protocol into group 3 vs group 2.
_ALLOWED_PROTOCOL_SCOPES = {"appeal", "lower"}
class DocumentPatchRequest(BaseModel):
"""Patch payload for a single document. Both fields are optional."""
"""Patch payload for a single document. All fields are optional; only the
ones present are applied. The metadata.* flags are display/processing hints
that never change the stored doc_type."""
doc_type: str | None = None
appraiser_side: str | None = None # committee | appellant | deciding | "" to clear
is_post_hearing: bool | None = None # material submitted after the hearing (→ group 4, block-chet)
protocol_scope: str | None = None # appeal | lower | "" to clear (protocol docs only)
@app.patch("/api/cases/{case_number}/documents/{doc_id}")
@@ -5652,6 +5761,13 @@ async def api_patch_document(case_number: str, doc_id: str, req: DocumentPatchRe
updates: dict = {}
# Load metadata once — appraiser_side, is_post_hearing and protocol_scope
# all live in the same JSONB blob, so a single patch may touch several.
metadata = doc.get("metadata") or {}
if isinstance(metadata, str):
metadata = json.loads(metadata)
metadata_dirty = False
if req.doc_type is not None:
if req.doc_type not in DOC_TYPE_NAMES:
raise HTTPException(
@@ -5668,13 +5784,34 @@ async def api_patch_document(case_number: str, doc_id: str, req: DocumentPatchRe
f"appraiser_side לא תקין: {req.appraiser_side}. ערכים מותרים: "
f"{', '.join(sorted(_ALLOWED_APPRAISER_SIDES))}",
)
metadata = doc.get("metadata") or {}
if isinstance(metadata, str):
metadata = json.loads(metadata)
if req.appraiser_side:
metadata["appraiser_side"] = req.appraiser_side
else:
metadata.pop("appraiser_side", None)
metadata_dirty = True
if req.is_post_hearing is not None:
# Store True; drop the key entirely when False so absence == "before".
if req.is_post_hearing:
metadata["is_post_hearing"] = True
else:
metadata.pop("is_post_hearing", None)
metadata_dirty = True
if req.protocol_scope is not None:
if req.protocol_scope and req.protocol_scope not in _ALLOWED_PROTOCOL_SCOPES:
raise HTTPException(
422,
f"protocol_scope לא תקין: {req.protocol_scope}. ערכים מותרים: "
f"{', '.join(sorted(_ALLOWED_PROTOCOL_SCOPES))}",
)
if req.protocol_scope:
metadata["protocol_scope"] = req.protocol_scope
else:
metadata.pop("protocol_scope", None)
metadata_dirty = True
if metadata_dirty:
updates["metadata"] = metadata
if not updates:

View File

@@ -899,6 +899,69 @@ async def reject_interaction(
return resp.json()
async def cancel_interaction(issue_id: str, interaction_id: str) -> dict:
"""Dismiss a pending interaction WITHOUT waking the issue assignee.
For stale/duplicate questions (TaskMaster #215). We flip the row to
``cancelled`` directly rather than calling Paperclip's resolve endpoints:
those carry the interaction's ``wake_assignee`` continuation policy and would
re-wake the agent. A direct status flip has no side effects — the table has
no triggers (verified) and nothing reaps ``cancelled`` rows.
"""
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
row = await conn.fetchrow(
"""UPDATE issue_thread_interactions
SET status='cancelled', resolved_at=now(),
resolved_by_user_id=$3, updated_at=now()
WHERE id=$1::uuid AND issue_id=$2::uuid AND status='pending'
RETURNING id, status""",
interaction_id, issue_id, CHAIM_USER_ID,
)
finally:
await conn.close()
if not row:
return {
"ok": False,
"cancelled": False,
"error": "interaction not found or already resolved",
}
return {"ok": True, "cancelled": True, "id": str(row["id"]), "status": row["status"]}
async def reap_stale_interactions() -> dict:
"""Cancel pending interactions stranded on closed issues (done/cancelled).
Safety-net reaper (TaskMaster #215): a question on a closed issue can never
be answered and only inflates the chair's "awaiting you" count — this was the
dominant source of the pending pile-up. Scoped to the two legal-ai companies.
Direct status flip — no wakeups, no side effects.
"""
company_ids = list(COMPANIES.values())
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
rows = await conn.fetch(
"""UPDATE issue_thread_interactions it
SET status='cancelled', resolved_at=now(),
resolved_by_user_id=$2, updated_at=now()
FROM issues i
WHERE i.id = it.issue_id
AND it.status='pending'
AND it.company_id = ANY($1::uuid[])
AND i.status IN ('done', 'cancelled')
RETURNING it.id""",
company_ids, CHAIM_USER_ID,
)
finally:
await conn.close()
if rows:
logger.info(
"reap_stale_interactions: cancelled %d stranded pending interactions",
len(rows),
)
return {"ok": True, "cancelled": len(rows)}
# Singleton project for the precedent-library extraction queue. One issue per
# uploaded precedent — assigned to the CEO who runs the local-MCP extractor.
_LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ"
@@ -1195,6 +1258,73 @@ async def wake_ceo_agent(issue_id: str, case_number: str, company_id: str = "")
return result
async def wake_ceo_for_action(
case_number: str,
action: str,
company_id: str = "",
) -> dict:
"""Wake the CEO with a deterministic *structured action* on the case's MAIN issue.
Drives the UI "הפקת מסמכים" buttons (TaskMaster #214): the CEO reads
``payload.action`` and routes deterministically to its שלב H / שלב H2
side-quests (legal-ceo.md §"פעולות סטרוקטורליות") — no free-text parsing.
action="party_claims_summary" → שלב H2 (summarize_party_claims)
action="interim_draft" → שלב H (write_interim_draft + export_interim_draft)
These are side-quests on the EXISTING ``[ערר {case_number}]`` issue — no child
issue is created (the CEO must not change ``cases.status`` or spawn sub-agents).
Wakeup goes through the Paperclip API (``POST /api/agents/{id}/wakeup``), never a
direct DB insert (CLAUDE.md hard rule). Returns
``{"status": "ok"|"skipped", ...}``.
"""
if not PAPERCLIP_BOARD_API_KEY:
logger.warning("PAPERCLIP_BOARD_API_KEY not set — skipping CEO action wakeup")
return {"status": "skipped", "reason": "no_api_key"}
issues = await get_case_issues(case_number)
if not issues:
logger.warning("No Paperclip issues found for case %s — skipping CEO action", case_number)
return {"status": "skipped", "reason": "no_issue"}
# The main case issue — prefer the in-progress one, else the canonical
# "[ערר {case_number}]" issue, else the oldest. (Same selection spirit as
# wake_curator_for_final, but we never spawn a child — it's a side-quest.)
main_issue = (
next((i for i in issues if i.get("status") == "in_progress"), None)
or next((i for i in issues if f"[ערר {case_number}]" in (i.get("title") or "")), None)
or issues[0]
)
main_issue_id = main_issue["id"]
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
wake_resp = await pc_request(
"POST",
f"/api/agents/{ceo_id}/wakeup",
json={
"source": "on_demand",
"triggerDetail": "manual",
"reason": f"generate_{action}_{case_number}",
"payload": {
"issueId": main_issue_id,
"action": action,
"case_number": case_number,
},
},
raise_on_error=True,
)
logger.info(
"CEO action wakeup for case %s: action=%s issue=%s ceo=%s status=%s",
case_number, action, main_issue_id, ceo_id, wake_resp.status_code,
)
return {
"status": "ok",
"action": action,
"issue_id": main_issue_id,
"ceo_id": ceo_id,
}
def _curator_task_brief(task: str, case_number: str, final_filename: str) -> tuple[str, str]:
"""Build the (sub-issue title, description) for a staged final-decision task.