48 Commits

Author SHA1 Message Date
726498126d Add Track Changes architecture for draft revisions (CMP + CMPA)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
Fixes critical bug in 1033-25: user-uploaded עריכה-*.docx files were
orphaned on disk while exports kept rebuilding from stale DB blocks.

New architecture:
- User-uploaded DOCX becomes the source of truth (cases.active_draft_path)
- System edits via XML surgery with real Word <w:ins>/<w:del> revisions
- User can Accept/Reject each change from within Word

Components:
- docx_reviser.py: XML surgery for Track Changes (15 tests)
- docx_retrofit.py: retroactive bookmark injection with Hebrew marker
  detection + heading heuristic (9 tests)
- docx_exporter.py: emits bookmarks around each of the 12 blocks
- 3 new MCP tools: apply_user_edit, list_bookmarks, revise_draft
- 4 new/updated endpoints: upload (auto-registers active draft),
  /exports/revise, /exports/bookmarks, /exports/{filename}/retrofit,
  /active-draft
- DB migration: cases.active_draft_path column
- UI: correct banner using real v-numbers, "מקור האמת" badge,
  detailed upload toast with bookmarks_added/missing_blocks
- agents: legal-exporter (3 export modes), legal-ceo (stage G for
  revision handling), legal-writer (revision mode)

Multi-tenancy:
- Works for both CMP (1xxx cases) and CMPA (8xxx/9xxx cases)
- New revise-draft skill added to both companies
- deploy-track-changes.sh syncs skills CMP ↔ CMPA
- retrofit_case.py: one-off retrofit of existing files

Tests: 34 passing (15 reviser + 9 retrofit + 4 exporter bookmarks + 6 e2e)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 18:49:30 +00:00
28daff58be Pre-existing agent updates + analysis DOCX export
Updates accumulated from prior sessions:
- HEARTBEAT: company-based filtering (CMP/CMPA) rules
- legal-qa, legal-researcher: routine updates
- analysis_docx_exporter: new service for analysis DOCX export
- compose page: "הורד כ-DOCX" button for analysis
- decision_template.docx: template for exporter

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 18:49:10 +00:00
3da4d73498 Upgrade agents to Claude Opus 4.7
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s
- legal-analyst: opus 4.6 → opus 4.7
- legal-proofreader: opus 4.6 → opus 4.7
- legal-writer: sonnet 4.6 → opus 4.7 (complex block writing benefits from stronger model)
- block_writer MODEL_MAP: updated opus ID to 4.7

Opus 4.7 brings: high-res images (2576px), better file-based memory,
improved DOCX generation, and task budgets for agentic loops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:10:56 +00:00
7b28549b2b CEO agent: require plugin_state linkage after creating issues
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
New issues created by the CEO via curl were missing plugin_state records,
causing them to be invisible in the legal-ai UI. Added iron rule: after every
POST to create an issue, INSERT into plugin_state with the case number.
Also fixed 8070-25 CMPA issues directly in DB (3 records added).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 14:59:58 +00:00
d7a79cf5ec Show per-case agent status instead of global — fix Hebrew translation of "running"
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m41s
Agent status widget now checks heartbeat_runs + wakeup_requests to determine
if an agent is running on *this* case. Agents running on other cases show as idle.
Added "running" to STATUS_DOT/STATUS_LABEL maps so it displays in Hebrew.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 14:47:42 +00:00
3288624349 Add methodology settings page with golden ratios, discussion rules, and checklists
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
New /methodology page with 3 tabs for viewing and editing decision
writing methodology. Uses DB override pattern: hardcoded Python
constants serve as defaults, edits saved to appeal_type_rules table,
delete restores default.

Backend: 3 generic endpoints (GET/PUT/DELETE /api/methodology/{category}/{key})
with validation per category type.

Frontend: methodology.ts hooks, GoldenRatiosPanel (number inputs per
outcome/section), DiscussionRulesPanel (accordion with textarea per
rule), ContentChecklistsPanel (markdown editor with preview toggle).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:30:39 +00:00
5dd24729e2 Auto-strip Nevo preambles and separate style analysis per appeal subtype
- Add strip_nevo_preamble() to extractor.py — auto-removes Nevo database
  headers (bibliography, legislation, mini-ratio) during training upload
- Add appeal_subtype column to style_patterns table — patterns are now
  stored per subtype instead of globally mixed
- Update clear_style_patterns() to support subtype-scoped deletion
- Pass appeal_subtype through analyze_corpus → store → upsert pipeline

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:03:06 +00:00
ba39707c70 Add CMPA (betterment levy) training support and update methodology
Support ingestion of betterment levy (היטל השבחה) decisions into a
separate training corpus (CMPA). Key changes:

- Add .doc file extraction via LibreOffice conversion in extractor
- Add practice_area/appeal_subtype columns to style_corpus table
- Route training files to cmp/ or cmpa/ subdirs based on appeal subtype
- Fix derive_subtype to handle ARAR-YY-NNNN format (was matching year digit)
- Expose practice_area/appeal_subtype params in MCP upload_training tool
- Add appeal_subtype filter to analyze_style for per-type style analysis
- Update betterment levy methodology in lessons.py: checklist (from generic
  to corpus-based), opening/closing strategies, and discussion rules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:00:35 +00:00
684a4cfd3b Fix 500 error on precedents API — add default=str to json.dumps
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m41s
UUID and datetime objects from PostgreSQL RETURNING * were not
serializable. All other tool files already used default=str.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:11:30 +00:00
c9a8cca35f Link agents to CMPA company, route CEO wakeup per-company
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
Created 7 agents in CMPA (betterment levy) company, mirroring
the CMP agents with same config and hierarchy. CEO_AGENTS dict
maps company_id to the correct CEO for wakeup routing.

wake_ceo_agent and post_comment now resolve the correct CEO
based on company_id. create_workflow_issue returns company_id.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:09:37 +00:00
c9f3fcd012 Translate agent role badges and issue status to Hebrew
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 31s
Role labels: ceo→מנהל, researcher→חוקר, engineer→מהנדס, qa→בודק איכות
Issue status: in_progress→בביצוע, done→הושלם, todo→לביצוע, etc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:55:31 +00:00
fe7cc40d05 Document deploy architecture in CLAUDE.md (Coolify Docker vs pm2)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
Legal-AI runs as Coolify Docker container — code changes require
git push + Coolify deploy. Paperclip runs locally via pm2. Added
explicit warning section to prevent attempting local uvicorn.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:49:45 +00:00
1e4c5c1518 Add Paperclip agent activity mirror to case detail page
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m16s
New "Agents" tab in case detail shows all Paperclip agent comments,
issue status, and agent status for each case — eliminating the need
to switch between Legal-AI and Paperclip UIs.

Backend: 4 new DB query functions in paperclip_client.py (issues,
comments, agents, post_comment) + 2 new API endpoints (GET/POST
/api/cases/{case_number}/agents). Comment posting uses Board API
with DB+wakeup fallback to ensure CEO routing.

Frontend: agents.ts hooks (10s polling), AgentActivityFeed component
(markdown timeline + comment input), AgentStatusWidget (sidebar),
4th tab in case detail page.

Also includes new-company-setup-guide.md documenting the process
for setting up the betterment levy (CMPA) company.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:44:42 +00:00
2e2d2d42b6 Prevent status regression in case_update
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m32s
CEO agent was reverting case status from "processing" to "new" when
updating metadata fields. Added ordered status list — case_update now
silently ignores status changes that would move backwards.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:05:40 +00:00
c71d7b3b9c Schedule daily DB backup (cron 2am) and gitignore backup files
- backup-db.sh tested successfully (19MB, pg_dump 17)
- Scheduled: 0 2 * * * with log to data/backups/backup.log
- Added data/backups/ and data/.auto-sync.log to .gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:03:11 +00:00
33e265e19c Document Garner/FJC methodology files as source material in CLAUDE.md
These are source extractions that fed into decision-methodology.md.
Not read by agents — kept as audit trail for methodology origins.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:46:40 +00:00
3b260a094d Remove legacy vanilla frontend, clarify web/ vs web-ui/ in CLAUDE.md
- Delete web/static/index.html and design-system.css (replaced by Next.js)
- Remove GET / HTML route and StaticFiles import from app.py
- CLAUDE.md: document that web/ = FastAPI API, web-ui/ = Next.js frontend

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:41:02 +00:00
5c9a5d702a Clean up scripts/: archive 17, delete 5, add SCRIPTS.md registry
Active scripts (5): auto-sync-cases.sh, backup-db.sh, restore-db.sh,
notify.py, bidi_table.py

Archived (17): one-time migration/seeding scripts whose functionality
is now in MCP server or web API. Moved to scripts/.archive/

Deleted (5): zero-value scripts (duplicates, hardcoded single-case,
debug scripts)

Added scripts/SCRIPTS.md — registry of all scripts with purpose,
status, and what superseded them. CLAUDE.md updated with rule:
any script change requires SCRIPTS.md update.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:30:19 +00:00
38e79bbf92 Replace duplicate block-schema.md with symlink to docs/
skills/decision/references/block-schema.md was a stale copy that
diverged from docs/block-schema.md. Now a symlink — single source of truth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:18:15 +00:00
891f20dbb9 Clean up legacy references: update CLAUDE.md, remove dead import script
- CLAUDE.md: clarify vault was deleted, knowledge is in docs/+training/
- Remove import-final-decisions.py (migration completed, all decisions in DB)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:16:35 +00:00
43b8106f55 Fix wakeup API source/triggerDetail enum values
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
Paperclip expects source ∈ {timer, assignment, on_demand, automation}
and triggerDetail ∈ {manual, ping, callback, system}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:08:07 +00:00
ad3c2b7117 Remove duplicate paperclip-assets — source of truth is paperclip-config repo
Assets live in ezer-mishpati/paperclip-config (cloned at ~/.paperclip).
Deploy via: ~/.paperclip/hebrew/apply-hebrew.sh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:57:18 +00:00
11c73a7c60 CEO: add email notifications, subtask parentId, and Paperclip UI assets
- CEO agent now sends email via notify.py when awaiting human response
- CEO creates child issues (parentId) instead of flat disconnected issues
- Fix notify.py email address to chaim+paperclip@marcus-law.co.il
- Move Paperclip UI assets (RTL CSS + Hebrew JS) into repo under scripts/
- Add deploy.sh script to push assets to live Paperclip instance
- Fix comment box positioning: newest comment on top, input below it

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:55:55 +00:00
6228846223 Add "Start Workflow" button to trigger CEO agent from web UI
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 49s
New endpoint POST /api/cases/{case_number}/start-workflow creates a
Paperclip issue, wakes the CEO agent via wakeup API, and transitions
case status to "processing". Button appears on case page only when
status is "new" or "documents_ready".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:51:23 +00:00
82ba4663ba Fix case repo sync + auto-create Gitea repos + add sync indicator
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m30s
- auto-sync-cases.sh: fix broken directory scan (was looking for
  status subdirs that don't exist), fix env var word-splitting bug,
  add safe.directory handling and error logging
- cases.py: auto-create Gitea repo on case_create, fix
  documents/original → documents/originals naming mismatch
- app.py: add GET /api/cases/{case_number}/git-status endpoint
- web-ui: add SyncIndicator component in case header showing
  sync status (synced/pending/no remote) with last commit time
- pyproject.toml: add httpx dependency
- CLAUDE.md: update Paperclip wakeup API docs
- settings page: switch tag input from Select to free-text with datalist

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:28:16 +00:00
7509d7e580 CEO: check wake reason first, skip full scan on user_commented
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
The CEO was ignoring the focused wake reason and doing a full heartbeat
scan of all cases/issues before getting to the actual comment. Added
step 0: check $PAPERCLIP_WAKE_REASON first — if user_commented, skip
directly to comment handling. Don't scan other cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:09:13 +00:00
2a7174b15d Add chair feedback tools to CEO + use them for draft annotations
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
CEO was missing get_chair_directions, record_chair_feedback,
list_chair_feedback, and search_case_documents. Without these tools
it couldn't read or update chair directions when processing draft
annotations.

Now the CEO will:
1. Read existing chair_directions via MCP tool
2. Record each draft annotation as chair_feedback
3. Update analysis-and-research.md
4. Post summary for user review before routing to writer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:58:35 +00:00
ce64766f6d CEO: extract draft annotations into chair_directions before routing
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
When the user writes editing instructions inside a draft DOCX, the CEO
must not just forward them as a checklist. Instead:
1. Read analysis-and-research.md + existing chair_directions
2. Translate draft annotations into methodological structure (syllogism)
3. Update chair_directions with the new analysis
4. Post summary to user and WAIT for approval
5. Only after approval → create issue for writer

This gives the user a chance to verify the CEO understood correctly
before the writer starts working.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:55:05 +00:00
2d349cf817 CEO must analyze edit requests through methodology before routing
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
Even when the user asks to edit specific paragraphs in an existing
draft, the CEO must first analyze through the methodology: identify
which legal issue the edit serves, build syllogistic structure,
reference specific source documents, and state the review standard.
Without this, the writer gets a technical checklist instead of
methodological guidance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:48:56 +00:00
598df0dc8c Fix Paperclip API routes and document agent-to-agent wakeup pattern
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
Agent JWT cannot wake other agents directly (returns "Agent can only
invoke itself"). The correct pattern: create an issue + assign to the
target agent → Paperclip triggers wakeup automatically.

Also documented all correct API routes in HEARTBEAT.md:
- POST /api/issues/{id}/comments (not /issues/)
- POST /api/companies/{company-id}/issues (not /api/issues)
- PATCH /api/issues/{id}
- POST /api/agents/{id}/wakeup (self only, with payload.issueId)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:43:54 +00:00
bb6f5e9eff Add mandatory issue template for writer agent with full methodology
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
The CEO was sending empty issues like "הועבר לכתיבה" without any
methodological content. The writer needs: syllogistic structure per
issue, source document references, claim handling table, chair
directions, style guidelines, and draft file path when available.

Added "תבנית issue לכותב ההחלטה" with all 5 required sections.
Updated comment routing to read drafts word-by-word and use the template.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:34:07 +00:00
45d52a74d2 Fix agent wakeup: /wake → /wakeup, remove broken DB fallback
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
The agents used /api/agents/{id}/wake (404) with a fallback of INSERT
INTO agent_wakeup_requests. The DB insert creates only the wakeup
record without a heartbeat_run, so the Paperclip dispatcher never
processes it — agents get stuck in queued forever.

Fix:
- All agents: /wake → /wakeup (correct Paperclip API endpoint)
- Remove all DB INSERT fallbacks, replace with warning
- Document the rule in CLAUDE.md: always API, never DB insert
- Save to memory for future conversations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:18:57 +00:00
1133272e34 Fix Paperclip integration (identifier→issue_prefix) + add settings page
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 34s
- Fix column name mismatch in paperclip_client.py and app.py: Paperclip's
  companies table uses `issue_prefix`, not `identifier`
- Fix _LEGAL_DB_URL to read from POSTGRES_URL env var (used in container)
- Add settings page (/settings) for managing tag → Paperclip company mappings
- Replace "תיק חדש" nav item with "הגדרות" (new case is on home page)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:09:08 +00:00
b755620542 Update CI deploy UUID to new Docker Image app (gyjo0mtw...)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 4m24s
Replaced Dockerfile-based app with Docker Image app in Coolify.
CI builds and pushes image to registry, Coolify pulls it on deploy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:55:37 +00:00
089a8b3a08 Route user comments through CEO agent + add draft/attachment awareness
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m42s
When a user comments on a Paperclip issue, the built-in automation wakes
the assigned agent directly, bypassing the CEO. This meant user instructions
(like "read the uploaded draft and route to the right agent") were ignored.

Changes:
- Plugin: add issue.comment.created event handler that wakes the CEO agent
  with the comment context (plugin-legal-ai, separate repo)
- HEARTBEAT: add steps 2b (read recent user comments) and 2c (check
  attachments) before agents start working
- CEO agent: add comment-routing section — read, check attachments, route
- Writer agent: add step 0 — check for uploaded DOCX drafts before writing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:47:43 +00:00
34fa923a2b Update CI deploy target to unified legal-ai app UUID
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
The old legal-ai-web app (my85gabx...) was deleted — consolidated into
a single ezer-mishpati-web (a99ivjv...) serving both domains.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:46:26 +00:00
d9948045f1 Fix draft label to reflect revision number instead of always showing "first draft"
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m36s
The drafts panel now checks for עריכה-v* files and shows the correct
draft number (e.g. "טיוטה 2 (מתוקנת) מוכנה לעיון") instead of always
displaying "טיוטה ראשונה מוכנה לעיון".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:17:44 +00:00
23f6b5d825 Remove Paperclip Docker references — runs locally via pm2
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m38s
- Deleted from Coolify (was exited:unhealthy since Apr 7)
- Updated CLAUDE.md service table: Paperclip is now pm2/local
- Removed Docker skills path fallback in app.py (always use local)
- Removed old paperclip-bug-report.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:13:26 +00:00
a093944967 Add delete button for draft files in case drafts panel
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 45s
- Add DELETE /api/cases/{case_number}/exports/{filename} endpoint
- Add useDeleteDraft hook in exports API
- Add trash icon + confirmation dialog in drafts panel UI
- Final files (סופי-) cannot be deleted as a safety measure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:05:30 +00:00
e698419faf Fix git not found error crashing document uploads in container
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m13s
Install git in Docker image and wrap all subprocess git calls in
try/except so a missing or failing git binary never kills an upload
that already succeeded.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:38:40 +00:00
5028f677f1 Fix English statuses and labels throughout UI to Hebrew
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
- Complete STATUS_LABELS in case view (added outcome_set, direction_approved,
  drafting, qa_review, reviewed)
- Add DOC_STATUS_LABELS for diagnostics page (failed/stuck documents)
- Add completed/failed/pending/error to global STEP_LABELS
- Translate settings page table headers to Hebrew

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 06:32:03 +00:00
2faae002e7 Add settings page for tag-to-company mappings and auto-create Paperclip projects
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m22s
When a case is created, a Paperclip project is now automatically created in
the correct company based on the appeal_subtype tag. Tag-to-company mappings
are managed via a new Settings page that pulls companies from Paperclip DB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 06:24:23 +00:00
140a2e442d Add drafts & feedback tab to case page, remove global feedback page
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 32s
Move draft management (export DOCX, download, upload revised version, mark
final) and chair feedback into a new "טיוטות והערות" tab on the case detail
page. Remove the standalone /feedback page and its nav link since feedback
is now case-scoped.

Also fix /api/admin/skills 500 error when Paperclip DB is unreachable by
adding a connection timeout and graceful fallback to disk-only skills.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 05:55:46 +00:00
ce61b88438 Add missing pipeline statuses to UI with Hebrew labels
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m26s
Added analyst_verified, research_complete, analysis_enriched, and
ready_for_writing statuses across all UI components: status-badge,
workflow-timeline, status-donut, status-changer, status-guide, and
kpi-cards. Also changed qa_review label from "QA" to "בדיקת איכות".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 03:38:17 +00:00
e5eee596bc Add pass 2 to legal-analyst: deepen analysis after chair directions
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
After Dafna fills her positions in the analysis document, the analyst
now runs a second pass to: verify cited case law against corpus and
case documents, deepen factual findings based on the chosen direction,
close open questions, and strengthen CREAC preparation.

Pipeline flow updated: direction_approved → analyst pass 2 →
analysis_enriched → CEO creates writer issue → ready_for_writing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:27:20 +00:00
bd974f7791 Fix practice_area/appeal_subtype regression in search and case creation
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m55s
The merge of ui-rewrite removed these parameters from db.search_similar()
and db.create_case() but left the callers passing them, causing TypeError
on any corpus search. Restores the parameters and adds schema migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:37:38 +00:00
b248e1414d Add upload endpoint for updated analysis files
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 34s
PUT /api/cases/{n}/research/analysis/upload accepts a markdown file and
validates: UTF-8 encoding, parseable structure, at least one threshold
or issue section, matching case number. Backs up existing file before
replacing. UI adds "העלה ניתוח מעודכן" button with status feedback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:34:06 +00:00
9da8dd2c4f Keep curl in Docker image for healthcheck
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m23s
Curl was installed to download Node.js setup script then purged.
Coolify needs it for HTTP health checks inside the container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:27:41 +00:00
96 changed files with 7686 additions and 7516 deletions

View File

@@ -15,10 +15,25 @@
הרץ את הרשימה הזו בכל heartbeat.
## 1. זיהוי
## 1. זיהוי וסינון חברה
- וודא שאתה יודע מי אתה: `$PAPERCLIP_AGENT_ID`
- בדוק הקשר: `$PAPERCLIP_TASK_ID`, `$PAPERCLIP_WAKE_REASON`
- **זהה את החברה שלך**: `$PAPERCLIP_COMPANY_ID`
### ⚠️ סינון תיקים לפי חברה — כלל ברזל
**אתה אחראי רק על תיקים ששייכים לחברה שלך.** הספרה הראשונה של מספר התיק קובעת:
| חברה | COMPANY_ID | סוגי תיקים | טווח מספרים |
|------|------------|-------------|-------------|
| ועדת ערר רישוי ובניה | `42a7acd0-30c5-4cbd-ac97-7424f65df294` | רישוי ובניה | **1xxx** |
| ועדת ערר היטלי השבחה | `8639e837-4c9d-47fa-a76b-95788d651896` | היטל השבחה + פיצויים ס' 197 | **8xxx, 9xxx** |
- אם `$PAPERCLIP_COMPANY_ID` = `42a7acd0...` → עבוד רק על תיקים שמתחילים ב-**1**
- אם `$PAPERCLIP_COMPANY_ID` = `8639e837...` → עבוד רק על תיקים שמתחילים ב-**8** או **9**
- **לעולם אל תיצור פרויקט, issue, או תוכן לתיק שלא בטווח שלך**
- אם issue שהוקצה לך מכוון לתיק שלא בטווח שלך — סרב בנימוס ודווח ב-comment
## 2. בדוק תיבת דואר
@@ -29,6 +44,37 @@ curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" "$PAPERCLIP_API_URL/api/ag
- תעדוף: `in_progress` קודם, אחר כך `todo`
- אם `PAPERCLIP_TASK_ID` מוגדר — תעדף אותו
## 2b. קרא תגובות אחרונות על ה-issue
לפני שאתה מתחיל לעבוד, בדוק אם יש comments חדשים מחיים:
```bash
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '[.[] | select(.authorUserId != null)] | .[-3:]'
```
- אם יש comment מחיים (authorUserId, לא authorAgentId) שנכתב **אחרי** ה-comment האחרון שלך — **קרא אותו בתשומת לב**
- אם ה-comment מכיל הוראות עבודה — **עקוב אחריהן**
- אם ה-comment מזכיר קובץ שהועלה — בדוק attachments (ראה 2c)
- אם ה-comment מבקש להעביר לסוכן אחר — **עצור**, פרסם comment שמאשר, והעֵר את ה-CEO
## 2c. בדוק קבצים מצורפים
אם comment מחיים מזכיר קובץ או טיוטה:
```bash
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
SELECT a.original_filename, a.content_type, a.object_key, a.byte_size
FROM issue_attachments ia
JOIN assets a ON a.id = ia.asset_id
WHERE ia.issue_id = '{issue-id}'
ORDER BY ia.created_at DESC LIMIT 5;"
```
- נתיב מלא לקובץ: `/home/chaim/.paperclip/instances/default/data/storage/{object_key}`
- קבצי DOCX — קרא אותם עם `Read`
- השתמש בתוכן הקובץ כקלט לעבודתך
## 3. Checkout ועבודה
```bash
@@ -71,27 +117,41 @@ curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
**אסור** לסיים issue כ-"done" אם יש כשל שלא טופל. "done" = הכל הושלם בהצלחה. אם משהו נכשל — "blocked".
### 4ג. העֵר את העוזר המשפטי (CEO) — חובה!
אחרי כל סיום משימה (done או blocked), **העֵר את העוזר המשפטי** כדי שיבדוק תוצאות ויחליט על הצעד הבא:
אחרי כל סיום משימה (done או blocked), **העֵר את העוזר המשפטי של החברה שלך** כדי שיבדוק תוצאות ויחליט על הצעד הבא:
**⚠️ בחר CEO לפי חברה:**
| חברה | COMPANY_ID | CEO Agent ID |
|------|------------|-------------|
| רישוי ובניה (CMP) | `42a7acd0-...` | `752cebdd-6748-4a04-aacd-c7ab0294ef33` |
| היטלי השבחה (CMPA) | `8639e837-...` | `cdbfa8bc-3d61-41a4-a2e7-677ec7d34562` |
```bash
# קבע CEO_ID לפי חברה:
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562"
else
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33"
fi
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wake" \
-d '{"reason": "סוכן [שמך] סיים משימה [issue-id] בסטטוס [done/blocked]. נדרשת בדיקה והחלטה על הצעד הבא."}'
```
אם ה-API הזה לא עובד, השתמש ב-DB ישירות:
```bash
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
INSERT INTO agent_wakeup_requests (company_id, agent_id, source, reason, status, requested_by_actor_type)
VALUES (
(SELECT company_id FROM agents WHERE id = '$PAPERCLIP_AGENT_ID'),
'752cebdd-6748-4a04-aacd-c7ab0294ef33',
'agent_completion',
'סוכן סיים משימה — נדרשת בדיקה והחלטה על הצעד הבא',
'pending',
'agent'
);"
"$PAPERCLIP_API_URL/api/agents/$CEO_ID/wakeup" \
-d '{"source":"automation","triggerDetail":"system","reason":"סוכן [שמך] סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'
```
**⚠️ כללי ברזל — Paperclip API:**
1. **אסור** `INSERT INTO agent_wakeup_requests` — לא יוצר heartbeat_run, הסוכן לא יתעורר לעולם
2. **חובה** `payload.issueId` בכל wakeup — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי cwd)
3. **agent JWT לא יכול להעיר סוכנים אחרים** — רק את עצמו. כדי להעיר סוכן אחר → צור issue + הקצה אליו (Paperclip מפעיל wakeup אוטומטי)
**נתיבי API:**
| פעולה | נתיב |
|-------|-------|
| פרסום comment | `POST /api/issues/{issue-id}/comments` |
| יצירת issue | `POST /api/companies/{company-id}/issues` |
| עדכון issue | `PATCH /api/issues/{issue-id}` |
| wakeup עצמי/CEO | `POST /api/agents/{agent-id}/wakeup` (עם payload!) |
## 5. התראת מייל — כשנדרשת תשובה אנושית
**כשהתוצאה דורשת החלטה או תשובה של חיים**, שלח מייל:

View File

@@ -1,7 +1,7 @@
---
name: "legal-analyst"
description: "מנתח ומחקר משפטי — חילוץ טענות, ניתוח אסטרטגי, זיהוי חוזקות/חולשות, והפקת שאלות מחקר ממוקדות"
model: "claude-opus-4-6"
model: "claude-opus-4-7"
tools:
- Read
- Bash
@@ -36,6 +36,14 @@ tools:
עבוד תמיד בעברית.
## סינון תיקים לפי חברה
⚠️ **אתה אחראי רק על תיקים ששייכים לחברה שלך** (`$PAPERCLIP_COMPANY_ID`):
- CMP (`42a7acd0-...`) → רק תיקים **1xxx** (רישוי ובניה)
- CMPA (`8639e837-...`) → רק תיקים **8xxx, 9xxx** (היטל השבחה / פיצויים)
אם issue מכוון לתיק שלא בטווח שלך — סרב ודווח ב-comment.
## תחומי התמחות
הסוכן ממוקד בתחומים הבאים:
@@ -210,21 +218,11 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
```bash
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wake" \
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \
-d '{"reason": "מנתח משפטי סיים משימה [issue-id] בסטטוס [done/blocked]"}'
```
אם ה-API לא עובד:
```bash
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
INSERT INTO agent_wakeup_requests (company_id, agent_id, source, reason, status, requested_by_actor_type)
VALUES (
(SELECT company_id FROM agents WHERE id = '\$PAPERCLIP_AGENT_ID'),
'752cebdd-6748-4a04-aacd-c7ab0294ef33',
'agent_completion',
'מנתח משפטי סיים משימה — נדרשת בדיקה',
'pending', 'agent'
);"
```
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
**אם בדיקות שלב 6 נכשלו** — סטטוס issue = "blocked", פרסם comment עם פירוט מה נכשל, שלח מייל לחיים.
@@ -329,6 +327,72 @@ X שאלות עומדות להכרעה:
- **הערכה כללית**: לאן נוטה הניתוח ומהם הסיכויים הכלליים של הערר
```
## שלב 8: העמקת ניתוח (pass 2) — אחרי אישור כיוון
שלב זה מופעל כשהמנתח מקבל משימה עם הוראה "pass 2" או כשסטטוס התיק הוא `direction_approved`.
הפעם, מסמך הניתוח חוזר עם עמדות יו"ר מולאות — כלומר יש כיוון מאושר.
**אל תשנה את עמדות היו"ר. תפקידך להעשיר את הניתוח סביבן.**
### 8א. אימות פסיקה
סרוק את עמדות היו"ר וזהה כל אזכור פסיקה (בג"ץ, עע"מ, עת"מ, ע"א, ערר וכו').
לכל פסק דין שמוזכר:
1. חפש בקורפוס הפנימי (`search_decisions`, `find_similar_cases`)
2. חפש במסמכי התיק (`search_case_documents`) — אולי מצוטט בכתבי הטענות
3. **אם נמצא** — חלץ ציטוט מדויק, הקשר, רלוונטיות
4. **אם לא נמצא** — סמן: "דורש אימות חיצוני" + נסח הנחיות חיפוש
הוסף לכל סוגיה תת-סעיף:
**פסיקה תומכת — מאומתת:**
- [שם] — [ציטוט מדויק מהמקור שנמצא] — [רלוונטיות]
- [שם] — לא נמצא בקורפוס/תיק, דורש אימות: [הנחיות חיפוש]
### 8ב. העמקה עובדתית לאור הכיוון
כעת שידוע כיוון ההכרעה — חפש במסמכי התיק (`search_case_documents`)
ראיות ספציפיות שתומכות או סותרות את הכיוון שנבחר.
עדכן "ממצאים עובדתיים" עם ציטוטים ישירים מחומרי המקור.
### 8ג. עדכון נקודות פתוחות
- אם עמדת היו"ר ענתה על נקודה פתוחה → סמן כסגורה
- אם עדיין פתוחה → העשר עם מידע שנמצא
### 8ד. עדכון הכנה ל-CREAC
עדכן עם פסיקה מאומתת וציטוטים מדויקים.
### 8ה. שמירה ודיווח
1. גבה גרסה קודמת: `cp {case_dir}/documents/research/analysis-and-research.md {case_dir}/documents/research/backup/analysis-and-research-pass1.md`
2. שמור מסמך מעודכן: `{case_dir}/documents/research/analysis-and-research.md`
3. עדכן סטטוס: `case_update(status=analysis_enriched)`
4. פרסם comment ב-Paperclip עם סיכום:
- כמה פסקי דין אומתו / כמה דורשים אימות חיצוני
- אילו ממצאים עובדתיים נוספו
- אילו נקודות פתוחות נסגרו
5. שלח מייל:
```bash
python3 /home/chaim/legal-ai/scripts/notify.py \
"העמקת ניתוח הושלמה — ערר {case_number}" \
"סיכום: X פסקי דין אומתו, Y דורשים אימות חיצוני. ממצאים עובדתיים הועשרו."
```
6. **העֵר את ה-CEO — חובה!**
```bash
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \
-d '{"reason": "מנתח משפטי סיים העמקת ניתוח (pass 2) [issue-id] בסטטוס [done/blocked]"}'
```
אם ה-API לא עובד:
```bash
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
INSERT INTO agent_wakeup_requests (company_id, agent_id, source, reason, status, requested_by_actor_type)
VALUES (
(SELECT company_id FROM agents WHERE id = '\$PAPERCLIP_AGENT_ID'),
'752cebdd-6748-4a04-aacd-c7ab0294ef33',
'agent_completion',
'מנתח משפטי סיים העמקת ניתוח (pass 2) — נדרשת בדיקה',
'queued', 'agent'
);"
```
## כללים קריטיים
1. **נאמנות למקור** — כל טענה חייבת לשקף את מה שנכתב, לא לפרש

View File

@@ -13,6 +13,10 @@ tools:
- mcp__legal-ai__case_update
- mcp__legal-ai__document_list
- mcp__legal-ai__get_claims
- mcp__legal-ai__get_chair_directions
- mcp__legal-ai__record_chair_feedback
- mcp__legal-ai__list_chair_feedback
- mcp__legal-ai__search_case_documents
- mcp__legal-ai__workflow_status
- mcp__legal-ai__processing_status
- mcp__legal-ai__get_metrics
@@ -21,6 +25,9 @@ tools:
- mcp__legal-ai__brainstorm_directions
- mcp__legal-ai__validate_decision
- mcp__legal-ai__export_docx
- mcp__legal-ai__apply_user_edit
- mcp__legal-ai__list_bookmarks
- mcp__legal-ai__revise_draft
---
# עוזר משפטי — מנהל תהליך כתיבת החלטות
@@ -56,11 +63,73 @@ tools:
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
## כלל: כל issue חדש = תת-משימה
כשאתה יוצר issue חדש לסוכן, **תמיד** כלול `parentId` עם ה-issue ID הראשי של התיק.
ה-issue הראשי הוא ה-issue שבו אתה עובד — `$PAPERCLIP_TASK_ID`.
```bash
# שלב 1: יצירת issue
ISSUE_ID=$(curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues" \
-d '{"title": "[ערר CASE_NUMBER] ....", "description": "...", "parentId": "'$PAPERCLIP_TASK_ID'", "assigneeAgentId": "..."}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
# שלב 2 (חובה!): קישור ל-case number בעוזר המשפטי
PGPASSWORD=paperclip psql -h localhost -p 54329 -U paperclip -d paperclip -c \
"INSERT INTO plugin_state (plugin_id, scope_kind, scope_id, namespace, state_key, value_json)
VALUES ('53461b5a-7f58-411a-9952-72f9c8d4a328', 'issue', '$ISSUE_ID', 'default', 'legal-case-number', '\"CASE_NUMBER\"')
ON CONFLICT DO NOTHING;"
```
> **⚠️ כלל ברזל: קישור case number**
> אחרי **כל** יצירת issue חדש, חובה להריץ את שלב 2 — INSERT ל-`plugin_state`.
> בלי זה, ה-issue לא יופיע בעוזר המשפטי ובדף התיק.
> החלף `CASE_NUMBER` במספר התיק (למשל `8070-25`).
**אם** ה-issue שלך הוא בעצמו תת-משימה (יש לו parent), השתמש ב-parent של ה-parent — כלומר ה-issue הראשי של התיק. לקבלת ה-parent:
```bash
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_URL/api/issues/$PAPERCLIP_TASK_ID" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('parentId') or d['id'])"
```
---
## התראת מייל — חובה
**בכל פעם שאתה מפרסם comment שמצפה לתשובה מחיים**, שלח מייל:
```bash
python3 /home/chaim/legal-ai/scripts/notify.py \
"נדרשת תשובתך — [תיאור קצר]" \
"[סיכום: מה בוצע, מה נדרש ממך, קישור ל-issue]"
```
**מתי לשלוח — תמיד:**
- סיום כל שלב (B, C, D, F) — עם סיכום מה בוצע
- כל comment שמבקש בחירה (תוצאה, כיוון, טיפול בטענות)
- שגיאה שדורשת התערבות
- החלטה מוכנה לביקורת דפנה
**מתי לא לשלוח:**
- עדכוני סטטוס ביניים (רק בסיום שלב)
- שגיאות טכניות שאפשר לפתור לבד
---
## תהליך אינטראקטיבי — שלב אחר שלב
### שלב 0: בדוק למה התעוררת
**לפני כל דבר אחר** — בדוק את סיבת ההתעוררות (`$PAPERCLIP_WAKE_REASON`):
- אם ה-reason מכיל `user_commented`**דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"**. אל תסרוק תיקים אחרים, אל תבדוק issues, אל תעשה heartbeat רגיל. **טפל רק בתגובה.**
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
- אחרת → המשך לשלב A (heartbeat רגיל)
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
בכל heartbeat:
בכל heartbeat **רגיל** (לא comment routing):
1. בדוק תיקים פעילים (`case_list`)
2. בדוק אם יש issues ב-"blocked" — אם כן, טפל בהם קודם
3. בדוק comments מחיים שממתינים לתגובה
@@ -253,14 +322,29 @@ tools:
- [ ] תקן ביקורת מצוין
- אם חסר פריט כלשהו — **שאל את חיים** לפני שממשיכים
4. הרץ `approve_direction(case_number, direction_index, additional_notes)`
5. צור issue חדש ב-Paperclip:
- כותרת: `[ערר {case_number}] כתיבת החלטה`
- הקצה ל: **כותב החלטה** (7ed8686f-24bc-49a3-bc02-67ca15b895a9)
6. פרסם comment: "כיוון אושר. הועבר לכותב החלטה."
7. עדכן סטטוס: `case_update(status=direction_approved)`
5. עדכן סטטוס: `case_update(status=direction_approved)`
6. צור issue חדש ב-Paperclip:
- כותרת: `[ערר {case_number}] העמקת ניתוח (pass 2)`
- הקצה ל: **מנתח משפטי** (c26e9439-a88a-49dc-9e67-2262c95db65c)
- תיאור: "כיוון אושר. בצע pass 2: אמת פסיקה מעמדות היו"ר, העמק עובדות לאור הכיוון שנבחר."
7. פרסם comment: "כיוון אושר. הועבר למנתח להעמקת ניתוח לפני כתיבה."
**מתי לחזור אחורה:** אם חיים שינה דעתו לגבי התוצאה או הכיוון, או אם חסר מידע — חזור לשלב B או C בהתאם.
### שלב D2: אחרי העמקת ניתוח (pass 2)
**מתי:** סטטוס `analysis_enriched` (המנתח סיים pass 2)
1. קרא comment של המנתח — כמה פסקי דין אומתו, מה נוסף, מה דורש אימות חיצוני
2. **בנה תיאור issue מלא לכותב** — ראה "תבנית issue לכותב ההחלטה" למטה
3. צור issue חדש עם התיאור המלא:
- כותרת: `[ערר {case_number}] כתיבת החלטה`
- הקצה ל: **כותב החלטה** (7ed8686f-24bc-49a3-bc02-67ca15b895a9)
4. פרסם comment עם סיכום מה הועבר
5. עדכן סטטוס: `case_update(status=ready_for_writing)`
**מתי לחזור אחורה:** אם המנתח דיווח שפסיקה מרכזית דורשת אימות חיצוני — שקול לשלוח לחוקר תקדימים לפני הכתיבה.
### שלב E: מעקב כתיבה
**מתי:** כותב החלטה עובד
@@ -282,6 +366,47 @@ tools:
**מתי לחזור אחורה:** אם דוח QA מצביע על בעיה מתודולוגית (סילוגיזם חסר, כיוון לא תואם chair_directions) — חזור לשלב C/D ולא רק לכותב.
### שלב G: טיפול בעריכה מהמשתמש (אחרי ייצוא)
**מתי:** המשתמש העלה `עריכה-v*.docx` (אחרי שייצאנו `טיוטה-v*.docx` קודמת) וכתב תגובה בקומנט.
**מטרה:** המשתמש ערך את הטיוטה ב-Word ושמר כ-`עריכה-v*.docx`. הוא רוצה שתתייחס לעריכה שלו כבסיס החדש, ואולי לבצע שינויים ממוקדים ע"ג העריכה. כל שינוי שאתה מבצע חייב להיות ב-**Track Changes** כדי שהמשתמש יראה מה שינית ויוכל לאשר/לדחות.
**תהליך:**
1. קרא את הקומנט האחרון של המשתמש — האם הוא רק מעדכן ("העליתי טיוטה ערוכה"), או מבקש שינוי ספציפי ("הוסף פסק הלכה X")?
2. הרץ `apply_user_edit(case_number, "עריכה-v{N}.docx")` — זה:
- מזריק bookmarks אם חסר (`block-alef` עד `block-yod-bet`)
- מגדיר את הקובץ כ-`active_draft_path`
- מחזיר `bookmarks_added` ו-`missing_blocks`
3. אם המשתמש רק עדכן (לא ביקש שינוי):
- דווח בקומנט: "העריכה נקלטה. זיהיתי N בלוקים. אם יש שינויים שתרצה שאבצע — שלח אותם כהוראה."
- **אל תייצר `טיוטה-v{N+1}.docx` חדשה**
4. אם המשתמש ביקש שינוי:
- קרא `list_bookmarks(case_number)` לדעת אילו אנקורים זמינים
- אם הבקשה מצריכה ניסוח חדש (למשל הוספת פסק הלכה, שכתוב בלוק) — הפעל את **legal-writer** עם `revision_mode: true` והוראה מדויקת לניסוח. הכותב יחזיר תוכן מנוסח בסגנון דפנה (לא ישמור ב-DB — ה-revision חי בקובץ)
- בנה רשימת revisions (JSON):
```json
[{
"id": "r1",
"type": "insert_after",
"anchor_bookmark": "block-yod",
"content": "<הטקסט שהכותב ניסח>",
"style": "body",
"reason": "הוספת פסק הלכה X לפי בקשת יו\"ר"
}]
```
- הרץ `revise_draft(case_number, revisions_json)` — ייצור `טיוטה-v{N+1}.docx` עם Track Changes
- פרסם comment: "טיוטה מעודכנת: `טיוטה-v{N+1}.docx`. השינויים מסומנים כ-Track Changes — פתח ב-Word ואשר/דחה."
**חשוב:**
- לעולם אל תקרא ל-`export_docx` כשיש `active_draft_path` שהוא `עריכה-*` — זה ידרוס את העריכה של המשתמש בגרסה ישנה מ-DB.
- השתמש ב-`revise_draft` בלבד במצב ג'.
- אם המשתמש ביקש שינוי מאסיבי (שכתוב מלא של בלוק) — עדיף להציע לו לעבוד על זה בעריכה נוספת מצדו ולא לייצר revisions ארוכים.
## מפת סטטוסים
**סטטוסים של התיק (`cases.status`) — כל סטטוס מתאים לפעולה אחת בדיוק:**
@@ -294,7 +419,9 @@ tools:
| `analyst_verified` | CEO (אחרי שלב A) | → האם יש מחקר תקדימים? אם לא → צור issue לחוקר (35022af0). אם כן → שלב B |
| `research_complete` | חוקר | → שלב B (סיכום + סיווג + שאלת תוצאה לחיים) |
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
| `direction_approved` | CEO (אחרי שחיים אישר) | → בדוק chair_directions שלם? אם כן → צור issue לכותב (7ed8686f). אם חסר → חזור לחיים |
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |
| `ready_for_writing` | CEO (אחרי D2) | → כותב עובד |
| `drafted` | כותב | → צור issue לבודק איכות (1a5b229e) |
| `qa_passed` | QA | → צור issue למייצא (d0dc703b) |
| `qa_failed` | QA | → בעיה טכנית → issue תיקון לכותב. בעיה מתודולוגית → חזור לשלב C/D |
@@ -304,6 +431,48 @@ tools:
---
**תבנית issue לכותב ההחלטה — חובה בכל issue שמוקצה לכותב:**
כל issue לכותב חייב לכלול את **כל** הסעיפים הבאים. אסור לשלוח issue עם משפט כמו "הועבר לכתיבה" — זה חסר תועלת. הכותב צריך הכל מוכן מראש.
```markdown
## הנחיות כתיבה — ערר {case_number}
### 1. תוצאה ומצב
- **תוצאה:** {דחייה / קבלה חלקית / קבלה מלאה}
- **טיוטה קיימת:** {כן/לא}. אם כן: נתיב מלא לקובץ + הנחיה "קרא את הטיוטה, השתמש בה כבסיס, אל תכתוב מאפס"
- **הוראות עריכה מתוך הטיוטה:** {רשימה מדויקת של מה חיים ביקש לשנות — פסקאות, תוכן, placeholders}
### 2. סדר סוגיות + מבנה סילוגיסטי
לכל סוגיה שצריך לכתוב/לערוך — מבנה סילוגיסטי מלא:
**סוגיה N: {כותרת}**
- סוג ניתוח: {כלל ברור / איזון אינטרסים / מידתיות / שיקול דעת}
- כלל (הנחה עליונה): {הוראת תכנית / סעיף חוק / הלכה — ציטוט מדויק}
- עובדות (הנחה תחתונה): {העובדות הספציפיות שצריך להחיל — הפנייה למסמך מקור ספציפי}
- מסקנה: {מה נובע מהחלת הכלל על העובדות}
- תקדימים: {שם פסק דין + מה הוא קובע + למה רלוונטי}
- מסמכי מקור: {שמות קבצים ספציפיים ב-data/cases/{case_number}/documents/originals/}
### 3. טיפול בטענות
| # | טענה | טיפול | סוגיה |
|---|------|-------|-------|
| 1 | {טענה} | דיון מלא / קיבוץ / דילוג | {באיזו סוגיה} |
...
### 4. chair directions
- העתק מלא של עמדות הוועדה מ-analysis-and-research.md (או הפנייה: "קרא get_chair_directions")
### 5. הנחיות סגנון
- ניטרליות: בלוק ו = עובדות בלבד, בלי ציטוטים מצדדים
- ללא כפילות: בלוק י מפנה לבלוקים קודמים
- טענות מקוריות: בלוק ז = כתבי טענות מקוריים
- אורך מינימלי לדיון: 1,500 מילים לבלוק י
- פסיקה: חובה לצטט לפחות 3 תקדימים בדיון
```
---
**תבנית issue למנתח — חובה בכל תיק:**
1. **טבלת מיפוי מסמכים** — לכל מסמך: שם, claim_type, party_role. בנה מ-`document_list`.
2. **רשימת מסמכים שלא לחלץ מהם** (reference, plan, decision, court_decision)
@@ -311,6 +480,31 @@ tools:
4. **הנחיה לשלוח wakeup ל-CEO בסיום**
5. **הנחיה לסיים כ-blocked אם מסמך נכשל**
## סינון תיקים לפי חברה — חובה!
⚠️ **כלל קריטי: אתה אחראי רק על תיקים ששייכים לחברה שלך.**
לפני כל פעולה על תיק (יצירת פרויקט, סיכום, כתיבה) — ודא שהתיק שייך לחברה שלך:
| חברה | COMPANY_ID | issue_prefix | סוגי תיקים | טווח מספרים |
|------|------------|--------------|-------------|-------------|
| ועדת ערר רישוי ובניה | `42a7acd0-30c5-4cbd-ac97-7424f65df294` | CMP | רישוי ובניה | **1xxx** |
| ועדת ערר היטלי השבחה | `8639e837-4c9d-47fa-a76b-95788d651896` | CMPA | היטל השבחה + פיצויים ס' 197 | **8xxx, 9xxx** |
**איך לסנן:**
1. בדוק `$PAPERCLIP_COMPANY_ID` — זה מזהה את החברה שלך
2. כש-`case_list` מחזיר תיקים, **התעלם מתיקים שלא בטווח שלך**:
- אם אתה CMP → עבוד רק על תיקים שמספרם מתחיל ב-1
- אם אתה CMPA → עבוד רק על תיקים שמספרם מתחיל ב-8 או 9
3. **לעולם אל תיצור פרויקט או issue לתיק שלא שייך לחברה שלך**
**בדיקה מהירה:**
```bash
# מספר התיק (למשל 1033-25) → הספרה הראשונה קובעת
case_prefix="${case_number:0:1}"
# CMP: prefix=1, CMPA: prefix=8 או 9
```
## כללים
- **לא לקבוע תוצאה בעצמך** — רק חיים מחליט
@@ -319,16 +513,104 @@ tools:
- **תמיד לדווח** — כל פעולה = comment ב-Paperclip
- **לשאול כשלא בטוח** — אם משהו לא ברור, שאל את חיים
- **ודא עקביות מתודולוגית** — כיוונים סילוגיסטיים (כלל + עובדות + מסקנה), chair_directions שלם (טיפול בטענות + כיוון + סדר סוגיות + תקן ביקורת), התאמה ל-`decision-methodology.md`
- **סינון תיקים** — עבוד רק על תיקים בטווח המספרים של החברה שלך (ראה טבלה למעלה)
## איך לקרוא comments של חיים
## טיפול בתגובות חדשות מחיים (comment routing)
כשאתה מתעורר בגלל תגובה חדשה (reason מכיל "user_commented"):
1. **קרא את ה-comments האחרונים** על ה-issue שצוין ב-prompt:
```bash
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '[.[] | select(.authorUserId != null)] | .[-3:]'
```
2. **בדוק attachments** — אם חיים ציין קובץ שהועלה:
```bash
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
SELECT a.original_filename, a.content_type, a.object_key
FROM issue_attachments ia
JOIN assets a ON a.id = ia.asset_id
WHERE ia.issue_id = '{issue-id}'
ORDER BY ia.created_at DESC LIMIT 5;"
```
נתיב מלא לקובץ: `/home/chaim/.paperclip/instances/default/data/storage/{object_key}`
3. **אם יש טיוטה/קובץ — קרא אותו מילה במילה.** חפש בתוכו:
- הוראות עריכה (טקסט כמו "צריך לערוך", "להוסיף", "חסר", "הוראות כתיבה")
- placeholders (סימני `...`, `בשנת..`, `[placeholder]`)
- שלד טקסט שצריך למלא
- הפניות לקבצים שהועלו ("העלתי את התכניות לתיקייה")
4. **⚠️ לפני שאתה יוצר issue — נתח את הבקשה דרך המתודולוגיה ועדכן chair_directions:**
גם בקשת עריכה של פסקאות בודדות היא עדיין כתיבה בתוך החלטה מעין-שיפוטית. **אל תעביר לכותב לפני שעדכנת chair_directions וחיים אישר.**
א. **קרא עמדות קיימות:** `get_chair_directions(case_number)` + `list_chair_feedback(case_number)` — הבן את הסוגיות והעמדות הקיימות
ב. **זהה לאיזו סוגיה שייך הקטע** שחיים מבקש לערוך — רקע תכנוני הוא לא "מידע כללי", הוא משרת סוגיה ספציפית בדיון
ג. **תרגם את ההערות מהטיוטה למבנה מתודולוגי:**
- לכל קטע שצריך לכתוב/לערוך, בנה סילוגיזם:
- כלל: מה הוראת התכנית/החוק/ההלכה הרלוונטית?
- עובדות: מה העובדות שצריך להציג (ומאיזה מסמך מקור ספציפי — עמוד, פסקה)
- מסקנה: מה נובע מהחלת הכלל על העובדות
- ציין סוג ניתוח: כלל ברור / איזון / מידתיות / שיקול דעת
- ציין תקן ביקורת
ד. **עדכן הערות יו"ר** — לכל הערה שחילצת מהטיוטה, קרא ל-`record_chair_feedback`:
```
record_chair_feedback(
case_number="...",
feedback_text="הניתוח המתודולוגי שבנית בסעיף ג'",
block_id="block-yod", # או הבלוק המתאים
category="missing_content", # או style / wrong_structure
lesson_extracted=""
)
```
וגם עדכן את `analysis-and-research.md` (בסוגיה המתאימה, תחת "עמדת ועדת הערר") עם הניתוח מסעיף ג'
ה. **פרסם comment לחיים** עם סיכום של מה שהבנת + הפניה ל-chair_directions המעודכנים:
```
## הבנת ההערות מהטיוטה — ערר {case_number}
קראתי את ההערות בפסקאות {X-Y}. הבנתי שהן משרתות את סוגיית {שם הסוגיה}.
עדכנתי chair_directions:
- {סיכום מה נוסף / שונה}
אנא בדוק ואשר לפני שמעביר לכותב.
```
ו. **המתן לאישור חיים** — לא ליצור issue לכותב עד שחיים מאשר שהוא הבין נכון
5. **אחרי אישור חיים** → צור issue לכותב לפי "תבנית issue לכותב ההחלטה" למטה — התבנית חייבת לכלול את הניתוח המתודולוגי מסעיף 4
6. **דווח** — פרסם comment שמאשר שהועבר לכותב
## נתיבי API — חובה!
```bash
# קרא comments על issue
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '.[-1].body'
# פרסם comment
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" \
-d '{"body": "..."}'
# צור issue חדש (עם הקצאה לסוכן → מפעיל wakeup אוטומטי!)
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/companies/42a7acd0-30c5-4cbd-ac97-7424f65df294/issues" \
-d '{"title":"...","projectId":"25c1b4a1-2c0e-4a2d-9938-8ae56ccda6f1","assigneeAgentId":"{agent-id}","description":"...","status":"todo"}'
# עדכן issue
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
-d '{"status": "done"}'
```
חפש ב-comment:
**⚠️ agent JWT לא יכול להעיר סוכנים אחרים ישירות.** כדי להעיר סוכן → **צור issue חדש + הקצה אליו** (Paperclip מפעיל wakeup אוטומטי על assignment).
חפש ב-comment של חיים:
- מספר (1/2/3) → בחירה
- "כיוון" + מספר → אישור כיוון
- טבלת טיפול בטענות → סימון claim_handling

View File

@@ -14,6 +14,9 @@ tools:
- mcp__legal-ai__get_block_context
- mcp__legal-ai__workflow_status
- mcp__legal-ai__export_docx
- mcp__legal-ai__apply_user_edit
- mcp__legal-ai__list_bookmarks
- mcp__legal-ai__revise_draft
- mcp__legal-ai__get_style_guide
- mcp__legal-ai__validate_decision
---
@@ -26,6 +29,14 @@ tools:
עבוד תמיד בעברית.
## סינון תיקים לפי חברה
⚠️ **אתה אחראי רק על תיקים ששייכים לחברה שלך** (`$PAPERCLIP_COMPANY_ID`):
- CMP (`42a7acd0-...`) → רק תיקים **1xxx** (רישוי ובניה)
- CMPA (`8639e837-...`) → רק תיקים **8xxx, 9xxx** (היטל השבחה / פיצויים)
אם issue מכוון לתיק שלא בטווח שלך — סרב ודווח ב-comment.
## סקייל ייצוא
**חובה לקרוא לפני כל ייצוא:**
@@ -45,6 +56,16 @@ tools:
2. קרא פרטי תיק (`case_get`)
3. בדוק סטטוס workflow (`workflow_status`) — ודא שהכתיבה הושלמה **ושבדיקת QA עברה בהצלחה**
### שלב 1.5: זיהוי active_draft ועריכות ממתינות
1. בדוק אם ב-`data/cases/{case_number}/exports/` יש קבצי `עריכה-v*.docx` (עלו ע"י המשתמש)
2. אם כן — הפעל `apply_user_edit` עם שם הקובץ האחרון; הכלי יזריק bookmarks ויגדיר את הקובץ כמקור האמת
3. אם במצב הזה המשתמש לא ביקש revisions מפורשים — **אל תייצא מחדש** (הקובץ שהועלה *הוא* הטיוטה העדכנית). דווח למשתמש ששמרת את העריכה כמקור האמת, והצע revisions אם נדרש
4. אם המשתמש ביקש שינויים (למשל "הוסף פסק הלכה X" / "תקן את הבלוק"):
- הרץ `list_bookmarks` כדי לראות אילו אנקורים זמינים
- בנה רשימת revisions (ראה פורמט למטה)
- הרץ `revise_draft` — זה ייצור `טיוטה-v{N+1}.docx` חדשה עם Track Changes
### שלב 2: בדיקה סופית מהירה
1. הרץ `validate_decision` — בדוק שאין כשלים קריטיים
2. בדוק שכל 12 הבלוקים (א-יב) קיימים ומלאים
@@ -54,9 +75,30 @@ tools:
6. בדוק שסטטוס ה-QA הוא "passed" — אם ה-QA לא רץ או נכשל, **אל תייצא**
### שלב 3: ייצוא DOCX
**מצב א' — ייצוא ראשוני (אין active_draft):**
1. קרא את סקייל legal-docx (SKILL.md) כדי להבין את דרישות העיצוב
2. השתמש ב-`export_docx` לייצוא ראשוני לקובץ זמני
3. אם הסקריפט `create-legal-doc.js` מתאים יותר (למשל לעיצוב מותאם) — השתמש בו
2. השתמש ב-`export_docx` לייצוא ראשוני
3. ה-tool יוסיף bookmarks ב-12 הבלוקים ויסמן את הקובץ כ-active_draft_path
**מצב ב' — יש active_draft + המשתמש ביקש שינויים:**
1. בנה רשימת revisions ב-JSON. פורמט כל revision:
```json
{
"id": "r1",
"type": "insert_after", // או insert_before, replace, delete
"anchor_bookmark": "block-yod", // מ-list_bookmarks
"content": "וכך נפסק בעניין פלוני. בבג\"ץ 1234/21 קבע השופט...",
"style": "body", // או heading, quote
"reason": "הוספת פסק הלכה שחסר לפי בקשת יו\"ר"
}
```
2. הפעל `revise_draft` — ייצור `טיוטה-v{N+1}.docx` עם `<w:ins>` / `<w:del>` — המשתמש יקבל/ידחה ב-Word
3. דווח למשתמש על הגרסה החדשה ו-applied/failed count
**מצב ג' — יש active_draft אך המשתמש לא ביקש שינוי ספציפי:**
הטיוטה כבר עדכנית (המשתמש ערך ב-Word). אל תייצא מחדש. דווח: "הקובץ העדכני הוא `<active_draft>`. רוצה שאבצע שינויים ממוקדים?"
### שלב 4: שמירה מגורסת
1. צור תיקייה `~/legal-ai/data/cases/{מספר-ערר}/exports/` (אם לא קיימת)
@@ -78,21 +120,11 @@ tools:
```bash
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wake" \
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \
-d '{"reason": "מייצא טיוטה סיים משימה [issue-id] בסטטוס [done/blocked]"}'
```
אם ה-API לא עובד:
```bash
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
INSERT INTO agent_wakeup_requests (company_id, agent_id, source, reason, status, requested_by_actor_type)
VALUES (
(SELECT company_id FROM agents WHERE id = '\$PAPERCLIP_AGENT_ID'),
'752cebdd-6748-4a04-aacd-c7ab0294ef33',
'agent_completion',
'מייצא טיוטה סיים משימה — נדרשת בדיקה',
'pending', 'agent'
);"
```
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
## כללים קריטיים

View File

@@ -1,7 +1,7 @@
---
name: "legal-proofreader"
description: "מגיה מסמכים — תיקון שגיאות OCR בטקסט משפטי עברי לפני ניתוח"
model: "claude-opus-4-6"
model: "claude-opus-4-7"
tools:
- Read
- Write
@@ -22,6 +22,14 @@ tools:
עבוד תמיד בעברית.
## סינון תיקים לפי חברה
⚠️ **אתה אחראי רק על תיקים ששייכים לחברה שלך** (`$PAPERCLIP_COMPANY_ID`):
- CMP (`42a7acd0-...`) → רק תיקים **1xxx** (רישוי ובניה)
- CMPA (`8639e837-...`) → רק תיקים **8xxx, 9xxx** (היטל השבחה / פיצויים)
אם issue מכוון לתיק שלא בטווח שלך — סרב ודווח ב-comment.
## רקע
מסמכים משפטיים (כתבי ערר, תגובות, פרוטוקולים) מגיעים כסריקות PDF. מנוע OCR מחלץ מהם טקסט ושומר אותו כקבצי MD. אבל ה-OCR לא מושלם — במיוחד בעברית משפטית:
@@ -62,60 +70,4 @@ tools:
1. **גיבוי**: העתק את הקובץ המקורי מ-`extracted/` לתיקיית `documents/backup/` עם סיומת `.pre-proofread.txt`
2. **כתוב** את הגרסה המתוקנת לתיקיית `documents/proofread/` (עם אותו שם קובץ כמו ב-`extracted/`)
3. עדכן את מסד הנתונים — שנה `extraction_status` ל-`proofread`:
```bash
PGPASSWORD="${PGPASSWORD:-$(grep DB_PASSWORD /home/chaim/.env | cut -d= -f2)}" \
psql -h localhost -p 5432 -U "${DB_USER:-legal_ai}" -d "${DB_NAME:-legal_ai}" \
-c "UPDATE documents SET extraction_status = 'proofread', extracted_text = pg_read_file('/path/to/file.txt') WHERE id = '{doc_id}';"
```
אם עדכון DB לא אפשרי, עדכן רק את הקובץ ודווח.
### שלב 5: עדכון סטטוס ודיווח
1. **עדכן סטטוס**: `case_update(case_number, status='proofread')`
2. פרסם comment ב-Paperclip עם:
```
## דוח הגהת מסמכים — תיק {case_number}
### סיכום
- **מסמכים שנבדקו:** {count}
- **מסמכים שתוקנו:** {fixed_count}
- **סה"כ תיקונים:** {total_fixes}
### פירוט לכל מסמך
| מסמך | ראשי תיבות | שגיאות OCR | הערות |
|------|------------|-----------|-------|
| {title} | {abbr_count} | {ocr_count} | {notes} |
### מקומות לא ברורים
- {document}: סעיף {n} — [?] "{problematic_text}"
```
## כללים קריטיים
1. **אל תשנה תוכן משפטי** — רק תיקוני OCR. אם מילה נראית מוזרה אבל היא מונח משפטי — אל תגע
2. **אל תדרוס בלי גיבוי** — תמיד העתק ל-`backup/` לפני שינוי
3. **ראשי תיבות ארוכים קודם**`נתבייע` (5 תווים) לפני `עייד` (3 תווים)
4. **דווח מקומות מסופקים** — סמן `[?]` ותן לאדם להחליט
5. **אל תמציא טקסט** — אם חסר משהו, סמן `[...]` ואל תנחש
6. **קרא את כל המסמך** — לפעמים הקשר ממסמך שלם עוזר להבין מילה שבורה
### העֵר את העוזר המשפטי (CEO) — חובה!
```bash
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wake" \
-d '{"reason": "מגיה מסמכים סיים משימה [issue-id] בסטטוס [done/blocked]"}'
```
אם ה-API לא עובד:
```bash
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
INSERT INTO agent_wakeup_requests (company_id, agent_id, source, reason, status, requested_by_actor_type)
VALUES (
(SELECT company_id FROM agents WHERE id = '\$PAPERCLIP_AGENT_ID'),
'752cebdd-6748-4a04-aacd-c7ab0294ef33',
'agent_completion',
'מגיה מסמכים סיים משימה — נדרשת בדיקה',
'pending', 'agent'
);"
```
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**

View File

@@ -24,6 +24,14 @@ tools:
עבוד תמיד בעברית.
## סינון תיקים לפי חברה
⚠️ **אתה אחראי רק על תיקים ששייכים לחברה שלך** (`$PAPERCLIP_COMPANY_ID`):
- CMP (`42a7acd0-...`) → רק תיקים **1xxx** (רישוי ובניה)
- CMPA (`8639e837-...`) → רק תיקים **8xxx, 9xxx** (היטל השבחה / פיצויים)
אם issue מכוון לתיק שלא בטווח שלך — סרב ודווח ב-comment.
## 6 בדיקות
### 1. שלמות מבנית (structural_integrity)
@@ -109,18 +117,8 @@ tools:
```bash
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wake" \
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \
-d '{"reason": "בודק איכות סיים משימה [issue-id] בסטטוס [done/blocked]"}'
```
אם ה-API לא עובד:
```bash
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
INSERT INTO agent_wakeup_requests (company_id, agent_id, source, reason, status, requested_by_actor_type)
VALUES (
(SELECT company_id FROM agents WHERE id = '\$PAPERCLIP_AGENT_ID'),
'752cebdd-6748-4a04-aacd-c7ab0294ef33',
'agent_completion',
'בודק איכות סיים משימה — נדרשת בדיקה',
'pending', 'agent'
);"
```
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**

View File

@@ -27,6 +27,14 @@ tools:
עבוד תמיד בעברית.
## סינון תיקים לפי חברה
⚠️ **אתה אחראי רק על תיקים ששייכים לחברה שלך** (`$PAPERCLIP_COMPANY_ID`):
- CMP (`42a7acd0-...`) → רק תיקים **1xxx** (רישוי ובניה)
- CMPA (`8639e837-...`) → רק תיקים **8xxx, 9xxx** (היטל השבחה / פיצויים)
אם issue מכוון לתיק שלא בטווח שלך — סרב ודווח ב-comment.
## לפני שאתה מתחיל — קרא!
1. **מתודולוגיה אנליטית**: `docs/decision-methodology.md` — במיוחד סעיפים ד.2 (התחל מלשון הטקסט), ד.3 (שלושה מקורות להנחה עליונה), ז (ציטוטים ואזכורי פסיקה)
@@ -98,21 +106,11 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
```bash
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wake" \
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \
-d '{"reason": "חוקר תקדימים סיים משימה [issue-id] בסטטוס [done/blocked]"}'
```
אם ה-API לא עובד:
```bash
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
INSERT INTO agent_wakeup_requests (company_id, agent_id, source, reason, status, requested_by_actor_type)
VALUES (
(SELECT company_id FROM agents WHERE id = '\$PAPERCLIP_AGENT_ID'),
'752cebdd-6748-4a04-aacd-c7ab0294ef33',
'agent_completion',
'חוקר תקדימים סיים משימה — נדרשת בדיקה',
'pending', 'agent'
);"
```
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
## כללים
- **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים

View File

@@ -1,7 +1,7 @@
---
name: "legal-writer"
description: "כותב החלטה — כתיבת בלוקים ה-יא של ההחלטה בסגנון דפנה תמיר"
model: "claude-sonnet-4-6"
model: "claude-opus-4-7"
tools:
- Read
- Bash
@@ -32,6 +32,14 @@ tools:
עבוד תמיד בעברית.
## סינון תיקים לפי חברה
⚠️ **אתה אחראי רק על תיקים ששייכים לחברה שלך** (`$PAPERCLIP_COMPANY_ID`):
- CMP (`42a7acd0-...`) → רק תיקים **1xxx** (רישוי ובניה)
- CMPA (`8639e837-...`) → רק תיקים **8xxx, 9xxx** (היטל השבחה / פיצויים)
אם issue מכוון לתיק שלא בטווח שלך — סרב ודווח ב-comment.
## לפני שאתה מתחיל — קרא!
1. **מתודולוגיה אנליטית: `docs/decision-methodology.md`** — איך לחשוב על החלטה
@@ -70,6 +78,41 @@ tools:
## תהליך עבודה
### מצב revision — תוספת נקודתית לטיוטה קיימת
כש-CEO מבקש **תוספת נקודתית** (לא כתיבה מאפס) — למשל "הוסף פסק הלכה X בבלוק י" — המצב הוא:
- המשתמש העלה `עריכה-v*.docx` והוא ה-`active_draft_path`
- נדרש ניסוח של פסקה/פסקאות בסגנון דפנה להכנסה ב-Track Changes
- **אסור להשתמש ב-`save_block_content`** — ה-revision חי בקובץ, לא ב-DB
**זרימה:**
1. קרא `get_block_context(case_number, block_id)` להקשר
2. קרא `get_style_guide()` לוודא סגנון דפנה
3. נסח את התוספת — טקסט עברי נקי, בלי placeholders (`X`, `...`, `[לציטוט]`), מוכן להכנסה ישירה ל-DOCX
4. החזר את הטקסט ל-CEO (בקומנט או כ-return value) — **לא** שומר ב-DB
5. CEO יקרא ל-`revise_draft` עם הטקסט שלך
**דוגמה לפלט מצופה:**
> בבג"ץ 1234/21 [פלוני נ' הוועדה המחוזית] קבע בית המשפט העליון כי הוועדה המקומית מחויבת לשקול שיקולי Y גם בהיעדר התנגדות מפורשת. הלכה זו חלה ישירות על ענייננו: הוועדה המקומית לא בחנה את Y, ודי בכך כדי להחזיר את הדיון לוועדה.
---
### שלב 0: בדיקת הוראות וטיוטות
לפני שתתחיל לכתוב, בדוק אם יש הנחיות ספציפיות:
1. **קרא comments אחרונים על ה-issue** — חפש הוראות מה-CEO או מחיים:
```bash
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '[.[] | select(.authorUserId != null)] | .[-3:]'
```
2. **בדוק attachments** (ראה HEARTBEAT שלב 2c) — אם יש קובץ DOCX מצורף, קרא אותו
3. **אם יש טיוטת DOCX** — קרא אותה, השתמש בה כבסיס. **אל תכתוב מאפס אם יש טיוטה.**
4. **אם ה-CEO או חיים כתבו הנחיות ב-comment** (למשל "ערוך בהתאם ל...") — **עקוב אחריהן**
### שלב 1: הכנה
1. **קרא את המתודולוגיה**: `Read docs/decision-methodology.md` — חובה לפני כל כתיבה
2. קרא פרטי התיק (`case_get`)
@@ -147,21 +190,11 @@ case_update(case_number, status="drafted")
```bash
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wake" \
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \
-d '{"reason": "כותב החלטה סיים משימה [issue-id] בסטטוס [done/blocked]"}'
```
אם ה-API לא עובד:
```bash
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
INSERT INTO agent_wakeup_requests (company_id, agent_id, source, reason, status, requested_by_actor_type)
VALUES (
(SELECT company_id FROM agents WHERE id = '\$PAPERCLIP_AGENT_ID'),
'752cebdd-6748-4a04-aacd-c7ab0294ef33',
'agent_completion',
'כותב החלטה סיים משימה — נדרשת בדיקה',
'pending', 'agent'
);"
```
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
**אם לא תעדכן סטטוס ל-drafted — בודק האיכות לא יוכל לרוץ!**

View File

@@ -54,5 +54,5 @@ jobs:
- name: Trigger Coolify redeploy
run: |
curl -sf \
"http://coolify:8080/api/v1/deploy?uuid=my85gabx37ele9aouub8t8ju&force=true" \
"http://coolify:8080/api/v1/deploy?uuid=gyjo0mtw2c42ej3xxvbz8zio&force=true" \
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"

2
.gitignore vendored
View File

@@ -2,6 +2,8 @@ data/uploads/
data/cases/
data/training/
data/exports/
data/backups/
data/.auto-sync.log
mcp-server/.venv/
__pycache__/
*.pyc

View File

@@ -30,7 +30,7 @@
- לקחים מהשוואת טיוטות לגרסאות סופיות
- סקריפט ייצוא DOCX
כל החומר הועבר לתיקיית `legacy/` כקריאה בלבד. **הפרויקט הנוכחי** מעביר את הידע הזה למערכת מובנית עם PostgreSQL + pgvector + n8n.
הידע שהופק מה-vault הוטמע במערכת הנוכחית — מסמכי ייחוס (`docs/`), קורפוס אימון (`data/training/`), ומבנה 12 בלוקים. ה-vault המקורי נמחק; הפרויקט הנוכחי עובד עם PostgreSQL + pgvector.
---
@@ -43,6 +43,8 @@
| [`docs/migration-plan.md`](docs/migration-plan.md) | תוכנית מעבר vault → DB — טבלאות, עדיפויות, כמויות | לפני ייבוא נתונים |
| [`docs/legal-decision-lessons.md`](docs/legal-decision-lessons.md) | לקחים מ-3 החלטות — מה עבד, מה השתנה, ביטויי מעבר חדשים | **לפני כל כתיבת החלטה** |
| [`docs/decision-methodology.md`](docs/decision-methodology.md) | **מתודולוגיה אנליטית — איך לחשוב על החלטה מעין-שיפוטית** | **לפני כל כתיבת החלטה** |
| `docs/garner-methodology-extraction.md` | חומר מקור: מיצוי מספרי Garner על כתיבה משפטית | רק לבדיקת מקור |
| `docs/fjc-principles-extraction.md` | חומר מקור: מיצוי מ-Judicial Writing Manual (FJC) | רק לבדיקת מקור |
| [`docs/corpus-analysis.md`](docs/corpus-analysis.md) | ניתוח שיטתי של 24 החלטות — מפת תוכן, דפוסי דיון תכנוני, פערים | **לפני כל כתיבת החלטה** |
| [`docs/memory.md`](docs/memory.md) | הקשר כללי — skills, פרויקטים שהושלמו, מבנה vault | להתמצאות כללית |
| [`skills/decision/SKILL.md`](skills/decision/SKILL.md) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** |
@@ -58,9 +60,27 @@
| Redis | תור משימות | `legal-ai-redis` |
| n8n | אוטומציית workflows | להגדרה |
| Gitea | מאגר קוד | `gitea.nautilus.marcusgroup.org/ezer-mishpati` |
| ezer-mishpati-web | ממשק העלאת מסמכים | `legal-ai.nautilus.marcusgroup.org` |
| ezer-mishpati-web | ממשק העלאת מסמכים (Docker/Coolify) | `legal-ai.nautilus.marcusgroup.org` |
| Paperclip | סוכן AI — מריץ Claude Code agents (pm2, מקומי) | `localhost:3100` |
| Infisical | ניהול סודות | `secret.dev.marcus-law.co.il` |
### ⚠️ ארכיטקטורת Deploy — חובה לקרוא
**עוזר משפטי (Legal-AI)** — רץ כ-**Docker container דרך Coolify**:
- UUID: `gyjo0mtw2c42ej3xxvbz8zio`
- שינוי קוד ב-`web/` או `web-ui/` **לא נכנס לתוקף** עד ש:
1. עושים `git commit` + `git push origin main`
2. מריצים deploy דרך Coolify (`mcp__coolify__deploy`)
3. ממתינים ~2-4 דקות לבנייה
- **אסור** לנסות להריץ uvicorn מקומית — אין סביבת Python על המכונה
- ה-container מריץ Next.js (`:3000`, חשוף) + FastAPI (`:8000`, פנימי)
- בדיקה: `curl https://legal-ai.nautilus.marcusgroup.org/api/...`
**Paperclip** — רץ **מקומית דרך pm2**:
- פורט: `localhost:3100`, DB: `localhost:54329`
- שינויי קוד נכנסים לתוקף אחרי `pm2 restart paperclip`
- **אין צורך ב-Docker או Coolify**
---
## מבנה תיקיות
@@ -81,15 +101,28 @@
│ └── docx/ עיצוב DOCX
├── data/
│ ├── training/ ← 4 החלטות לאימון (DOCX)
│ ├── exports/ ← ייצוא legacy (תיקים ישנים)
│ ├── exports/ ← טיוטות DOCX מיוצאות
│ └── cases/{case-number}/ ← תיקי עררים (מבנה שטוח, סטטוס ב-DB)
├── web/ ← UI + API + integration clients
├── web/ ← FastAPI backend (Python): 75 API endpoints
│ ├── app.py ← API ראשי
│ ├── paperclip_client.py ← אינטגרציית Paperclip
│ └── gitea_client.py ← אינטגרציית Gitea
├── web-ui/ ← Next.js frontend (TypeScript/React): ממשק המשתמש
│ └── next.config.ts ← proxy: /api/* → FastAPI :8000
├── mcp-server/ ← MCP server + services + tools
└── scripts/ ← סקריפטים וכלי עזר
└── scripts/ ← סקריפטים וכלי עזר (ראה scripts/SCRIPTS.md)
└── .archive/ ← סקריפטים שהושלמו (לא להריץ)
```
---
## כלל: עדכון `scripts/SCRIPTS.md`
בכל פעם שנוצר, נמחק, או משתנה סקריפט בתיקיית `scripts/`**חובה לעדכן את `scripts/SCRIPTS.md`** בהתאם.
הקובץ מתעד את התפקיד, הסטטוס, וההחלפה (אם יש) של כל סקריפט.
---
## ניהול משימות — TaskMaster AI
הפרויקט משתמש ב-**TaskMaster AI** (MCP server) לניהול משימות מובנה:
@@ -102,6 +135,26 @@
---
## Paperclip — כללי אינטגרציה קריטיים
### Wakeup API — תמיד דרך API, לעולם לא דרך DB
- **הנתיב הנכון**: `POST /api/agents/{agent-id}/wakeup` (לא `/wake`!)
- **⚠️ אסור**: `INSERT INTO agent_wakeup_requests` ישירות — זה יוצר רק רשומה בלי `heartbeat_run`, והסוכן **לא יתעורר לעולם**
- **⚠️ חובה לשלוח `payload` עם `issueId`** — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי issue, בלי cwd נכון)
- דוגמה נכונה:
```json
{"source": "automation", "triggerDetail": "system", "reason": "...",
"payload": {"issueId": "...", "mutation": "comment", "commentId": "..."}}
```
- **Board API Key**: שמור ב-DB (`board_api_keys`), auth: `Authorization: Bearer pbk_...`
### ניתוב comments דרך CEO
- כשמשתמש כותב תגובה על issue ב-Paperclip, הפלאגין (`plugin-legal-ai`) מעיר את ה-CEO דרך `ctx.agents.invoke()`
- ה-CEO קורא את ה-comment, מחליט על ניתוב, ויוצר issue לסוכן המתאים
- כל הסוכנים חייבים לקרוא comments אחרונים לפני שהם מתחילים לעבוד (HEARTBEAT שלבים 2b-2c)
---
## עקרונות כתיבה קריטיים
1. **"מבחן השופט"** — כל החלטה חייבת להיות קריאה לשופט שלא מכיר את התיק

View File

@@ -34,10 +34,9 @@ WORKDIR /app
# Install Node.js 20.x
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates \
curl ca-certificates git \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& apt-get purge -y curl \
&& rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production

View File

@@ -0,0 +1,403 @@
# מדריך הקמת חברה חדשה — היטלי השבחה (CMPA)
> נוצר: 2026-04-15
> מטרה: תיעוד מפורט של התהליך להקמת קורפוס אימון והגדרת חברה בשתי המערכות
---
## רקע
המערכת שלנו בנויה מ-**2 חברות** (boards) ב-Paperclip, שמייצגות את שני תחומי העבודה העיקריים:
| # | חברה | קוד | Prefix | סוגי תיקים | סטטוס קורפוס |
|---|-------|------|--------|------------|---------------|
| 1 | רישוי ובנייה | CMP | `42a7acd0...` | 1xxx | 24 החלטות אימון, ניתוח סגנון מלא |
| 2 | היטלי השבחה + פיצויים | CMPA | `8639e837...` | 8xxx, 9xxx | **ריק — אין אף החלטת אימון** |
**המצב היום**: חברת CMPA כבר קיימת ב-Paperclip ומופתה בקוד (ניתוב אוטומטי לפי מספר תיק). אבל אין לה **קורפוס אימון** — המערכת לא מכירה את הסגנון של דפנה בהחלטות היטל השבחה ולא יכולה לחפש תקדימים.
**מה שצריך לעשות**: להעלות את ההחלטות, לעבד אותן, ולהריץ ניתוח סגנון — בדיוק כמו שנעשה עם 24 ההחלטות של רישוי ובנייה.
---
## שתי המערכות — הגדרת תפקידים
### מערכת 1: עוזר משפטי (Legal-AI)
**תפקיד**: מערכת הידע, הניתוח והניסוח — מחזיקה את כל התוכן המשפטי ומספקת כלים לכתיבת החלטות.
**מה חי רק במערכת הזו**:
| רכיב | תיאור | טבלת DB |
|-------|--------|---------|
| תיקים (Cases) | מספר תיק, כותרת, סטטוס, צדדים | `cases` |
| מסמכי מקור | כתבי ערר, תגובות, פרוטוקולים (PDF/DOCX) | `documents` + filesystem |
| חלקים סמנטיים (Chunks) | embeddings לחיפוש RAG (Voyage AI, 1024 ממדים) | `document_chunks` + pgvector |
| קורפוס אימון | החלטות קודמות של דפנה — גרסאות מנוקות | `style_corpus` |
| דפוסי סגנון | ביטויי מעבר, נוסחאות פתיחה/סיום, מבנה ניתוח | `style_patterns` |
| בלוקי החלטה | 12 בלוקים (מבנה ההחלטה) + פסקאות | `decision_blocks`, `decision_paragraphs` |
| טענות צדדים | טענות שחולצו מכתבי טענות | `claims` |
| תקדימים (פסיקה) | ספריית case law + embeddings | `case_law`, `case_law_embeddings` |
| חקיקה | סעיפי חוק שאוזכרו | `statutory_provisions` |
| הערות יו"ר | feedback של דפנה על טיוטות | `chair_feedback` |
| לקחים | תובנות שחולצו מ-feedback | `lessons_learned` |
| צ'קליסטים | רשימות בדיקה לבלוק דיון (לפי סוג ערר) | hardcoded ב-`lessons.py` |
| מיפוי חברות | קישור appeal_subtype ← company_id | `tag_company_mappings` |
**שירותי הליבה**:
- **RAG** — חיפוש סמנטי בתקדימים ובמסמכי מקור, מסונן לפי `appeal_subtype`
- **Proofreading** — ניקוי מסמכי נבו מ-artifacts
- **Style Analysis** — ניתוח קורפוס וחילוץ דפוסי כתיבה
- **Decision Drafting** — ייצור טיוטות לפי ארכיטקטורת 12 בלוקים
- **DOCX Export** — מסמך מעוצב מוכן להגשה
---
### מערכת 2: Paperclip
**תפקיד**: מערכת התזמור והסוכנים — מנהלת את תהליך העבודה, מפעילה סוכני AI, ומספקת ממשק Kanban.
**מה חי רק במערכת הזו**:
| רכיב | תיאור | טבלת DB |
|-------|--------|---------|
| חברות (Companies) | CMP (רישוי), CMPA (היטלי השבחה) — boards נפרדים | `companies` |
| פרויקטים | כרטיס Kanban לכל תיק | `projects` |
| Issues | משימות עבודה (CMP-123, CMPA-456) | `issues` |
| תגובות | דיון בין סוכנים ומשתמשים | `issue_comments` |
| סוכנים (Agents) | CEO, Researcher, Writer — Claude Code agents | מערכת agents |
| SOUL.md | הנחיות לכל סוכן | קונפיגורציית agent |
| Skills | workflows לשימוש חוזר (SKILL.md) | `company_skills` + filesystem |
| Plugin state | נתוני plugin (case_number ← issue) | `plugin_state` |
**תפקידי הליבה**:
- **תזמור** — CEO agent מקבל בקשות, מנתב לסוכן המתאים
- **ניהול משימות** — Kanban board עם issues, מעקב סטטוס
- **הפעלת סוכנים** — wakeup mechanism, heartbeat cycle
- **ממשק דיון** — comments על issues (משתמש ← agent ← agent)
---
### תהליכי גומלין — מי מדבר עם מי
```
┌──────────────────────────────────────────────────────────────────────────┐
│ תהליכי גומלין │
│ │
│ LEGAL-AI PAPERCLIP │
│ ════════ ═════════ │
│ │
│ ┌─────────┐ יצירת project+issue ┌─────────┐ │
│ │ Cases │ ─────── DB insert ──────→ │Projects │ │
│ │ │ ─────── DB insert ──────→ │ Issues │ │
│ └─────────┘ └─────────┘ │
│ │
│ ┌─────────┐ wakeup signal ┌─────────┐ │
│ │Workflow │ ─────── HTTP POST ───────→ │ CEO │ │
│ │ Start │ (issueId + mutation) │ Agent │ │
│ └─────────┘ └─────────┘ │
│ │
│ ┌─────────┐ קריאת case_number ┌─────────┐ │
│ │ Data │ ←──── plugin_state ────── │ Plugin │ │
│ │ (API) │ ←──── HTTP GET/POST ───── │legal-ai │ │
│ └─────────┘ (תקדימים, טענות, סגנון) └─────────┘ │
│ │
│ ┌─────────┐ skill sync ┌─────────┐ │
│ │ Skills │ ──── DB + filesystem ────→ │company_ │ │
│ │ (disk) │ │ skills │ │
│ └─────────┘ └─────────┘ │
│ │
│ ┌─────────┐ שאילתת חברות ┌─────────┐ │
│ │Settings │ ←──── DB query ────────── │companies│ │
│ │ UI │ │ table │ │
│ └─────────┘ └─────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
```
#### כיוון 1: Legal-AI → Paperclip (יצירה ושליטה)
| פעולה | מנגנון | מתי |
|-------|--------|-----|
| יצירת Project | DB insert ישיר ב-Paperclip | יצירת תיק חדש |
| יצירת Issue | DB insert ישיר ב-Paperclip | יצירת תיק / התחלת workflow |
| קישור case ← issue | DB insert ב-`plugin_state` | יצירת project |
| הערת אימות | DB insert ב-`issue_comments` | אחרי יצירת project |
| הפעלת CEO | **HTTP POST** ל-`/api/agents/{id}/wakeup` | התחלת workflow |
| סנכרון skill | DB insert/update ב-`company_skills` | התקנת/עדכון skill |
#### כיוון 2: Paperclip → Legal-AI (שאילתות וקריאות חזרה)
| פעולה | מנגנון | מתי |
|-------|--------|-----|
| קריאת case_number | plugin קורא `plugin_state` | סוכן מקבל issue |
| שליפת מסמכים | HTTP GET/POST ל-API של legal-ai | סוכן עובד על תיק |
| חיפוש תקדימים | HTTP ל-`/api/precedents/search` | researcher מחפש |
| קריאת style guide | HTTP ל-MCP / API | writer כותב טיוטה |
| רשימת חברות | DB query ישיר מ-`companies` | UI הגדרות |
#### החוליה המקשרת: `plugin_state`
```
plugin_state:
plugin_id = "53461b5a..." (marcusgroup.legal-ai)
scope_kind = "issue"
scope_id = "{issue-uuid}"
state_key = "legal-case-number"
value_json = "\"1234\""
```
זו ה"כתובת" שמאפשרת לסוכן Paperclip לדעת איזה תיק ב-Legal-AI שייך ל-issue שהוא עובד עליו.
---
### מצב קיים לכל חברה
#### CMP — רישוי ובנייה (מוכן לעבודה)
**ב-Legal-AI**:
- 24 החלטות אימון בקורפוס
- ניתוח סגנון מלא (דפוסים, ביטויים, יחסי אורך)
- content checklists ל-3 סוגי משנה (substantive, threshold, property)
- RAG פעיל עם chunks + embeddings
**ב-Paperclip**:
- חברה CMP פעילה
- סוכנים מוגדרים ופעילים
- Plugin פעיל
- Skills מותקנים
#### CMPA — היטלי השבחה (דורש הקמה)
**ב-Legal-AI**:
- appeal_subtype `betterment_levy` מוגדר בקוד
- ניתוב אוטומטי (8xxx → CMPA) עובד
- **חסר**: 0 החלטות אימון, 0 style patterns, 0 chunks, אין content checklist
**ב-Paperclip**:
- חברה CMPA קיימת
- **לוודא**: סוכנים מקושרים, plugin פעיל, skills מותקנים
---
## התהליך המלא — צעד אחר צעד
### שלב 1: הכנת הקבצים
**מיקום**: הנח את כל קבצי ה-DOCX בתיקייה נגישה (למשל `~/Downloads/hitlei-hashbacha/`)
**בדיקות מקדימות**:
1. וודא שכל הקבצים בפורמט DOCX או PDF
2. וודא שהשמות כוללים מספר תיק (לצורך metadata)
3. ספור כמה החלטות יש — זה ישפיע על זמן העיבוד
**דגשים**:
- ההחלטות מגיעות מנבו — יש להן watermarks, headers, footnotes שצריך לנקות
- מערכת ה-proofreading שלנו מטפלת בזה אוטומטית
---
### שלב 2: העלאה — 3 נתיבים אפשריים
#### נתיב א: ממשק Web (מומלץ להעלאה המונית)
```
כתובת: https://legal-ai.nautilus.marcusgroup.org
נתיב: /api/training/upload
```
**מה קורה מאחורי הקלעים**:
1. הקובץ נשמר כ-temp file
2. **Proofreading** — ניקוי אוטומטי של תוספות נבו:
- הסרת watermarks ("ספרות:", "חקיקה שאוזכרה:")
- הסרת headers/footers של עמודים
- הסרת קודי נבו inline
- הסרת URLs וזכויות יוצרים
3. **שמירת גרסה מנוקה**`data/training/proofread/{filename}.md`
4. **שמירת מקור**`data/training/{filename}.docx`
5. **הוספה ל-DB** → טבלת `style_corpus` עם metadata
6. **חיתוך לחלקים** → chunks סמנטיים
7. **יצירת embeddings** → Voyage AI → וקטורים 1024 ממדים
8. **שמירה ב-RAG** → טבלת `document_chunks` (עם practice_area + appeal_subtype)
#### נתיב ב: MCP Tool (מ-Claude Code)
```
tool: document_upload_training
params:
file_path: "/path/to/file.docx"
decision_number: "ARAR-24-8001"
decision_date: "2024-06-15"
subject_categories: ["היטל השבחה"]
title: "שם ההחלטה"
practice_area: "appeals_committee"
appeal_subtype: "betterment_levy"
```
#### נתיב ג: Skill Command (אינטראקטיבי)
```
/upload-training
```
עונים על שאלות: נתיב קובץ, מספר החלטה, תאריך, קטגוריות.
---
### שלב 3: ביקורת (Proofreading QA)
**קריטי**: לפני שממשיכים לניתוח — **לבדוק כל החלטה שהועלתה**.
**מה לבדוק**:
- [ ] הטקסט המנוקה (`data/training/proofread/`) קריא ושלם
- [ ] לא נחתכו חלקים מהותיים
- [ ] ה-metadata נכון (מספר תיק, תאריך, קטגוריה)
- [ ] אין שאריות של artifacts מנבו
- [ ] appeal_subtype = `betterment_levy` (ולא `building_permit`)
**כלי בדיקה**:
```
GET /api/training/status — סטטוס העלאה ועיבוד
```
---
### שלב 4: ניתוח סגנון (Style Analysis)
אחרי שכל ההחלטות הועלו ונבדקו, מריצים ניתוח סגנון:
```
POST /api/training/analyze-style
```
**מה קורה**:
1. שליפת כל ההחלטות מ-`style_corpus` (לפי practice_area/subtype)
2. בדיקת תקציב tokens:
- עד 900K tokens → pass יחיד (הכל ל-Claude בבת אחת)
- מעל 900K → multi-pass (כל החלטה בנפרד + סינתזה)
3. **חילוץ דפוסים** באמצעות Claude:
- נוסחאות פתיחה
- ביטויי מעבר
- סגנון ציטוט פסיקה
- מבנה ניתוח
- נוסחאות סיום
- ביטויים אופייניים
- זרימת טיעון
- טיפול בראיות
4. שמירה בטבלת `style_patterns` עם תדירות, הקשר, ודוגמאות
**תוצר**: מדריך סגנון מבוסס-נתונים ספציפי להיטלי השבחה.
---
### שלב 5: ניתוח קורפוס (Corpus Analysis)
בדומה ל-`docs/corpus-analysis.md` שנבנה עבור רישוי ובנייה, צריך ליצור ניתוח מקביל:
**מה לנתח**:
- הרכב הקורפוס: כמה החלטות, תוצאות (קבלה/דחייה/חלקית)
- אורך פרק דיון טיפוסי
- נושאים ייחודיים להיטלי השבחה:
- שומות (שומה מוסכמת, שומה אחרת, שמאי מכריע)
- תכנית משביחה — זיהוי, פרשנות
- מועד השבחה / "מועד אישור התכנית"
- חישוב עליית ערך (לפני/אחרי)
- פטורים (ס' 19 לתוספת השלישית)
- שיעור היטל
- דיני ראיות שמאיים
- ביטויי מעבר ייחודיים
- סגנון דיון — "קר ומקצועי" (לפי CLAUDE.md)
- השוואה לרישוי ובנייה (מה שונה)
**תוצר**: מסמך `docs/corpus-analysis-betterment.md`
---
### שלב 6: עדכון Content Checklists
הקובץ `lessons.py` מכיל צ'קליסטים לבלוק י (דיון) לפי סוג ערר.
**מה צריך**:
- ליצור `CONTENT_CHECKLISTS["betterment_levy"]` עם נושאים ייחודיים
- נושאים צפויים: שומות, תכנית משביחה, מועד, חישוב, פטורים, ראיות שמאיות
- הצ'קליסט ייבנה מתוך ניתוח הקורפוס (שלב 5)
---
### שלב 7: אימות Paperclip
לוודא שחברת CMPA מוגדרת נכון:
**בדיקות**:
- [ ] חברה CMPA קיימת ופעילה ב-Paperclip DB
- [ ] Issue prefix = CMPA
- [ ] Plugin `legal-ai` פעיל בחברה
- [ ] סוכנים (CEO, researcher, writer) מוגדרים
- [ ] tag_company_mappings נכון ב-legal-ai DB:
- `betterment_levy``8639e837...`
- `compensation_197``8639e837...`
- [ ] יצירת תיק 8xxx מנותבת נכון
**כלי בדיקה**:
```
GET /api/settings/tag-mappings
GET /api/paperclip/companies
```
---
## סיכום — סדר פעולות
| # | שלב | מה | כלי | זמן משוער |
|---|------|----|------|-----------|
| 1 | הכנה | איסוף קבצי DOCX, בדיקת פורמט | ידני | — |
| 2 | העלאה | העלאת כל ההחלטות + proofreading אוטומטי | Web API / MCP | דקות לכל החלטה |
| 3 | ביקורת | בדיקת כל טקסט מנוקה + metadata | ידני / Claude | כמה שעות |
| 4 | ניתוח סגנון | חילוץ דפוסים מהקורפוס | API analyze-style | ~30 דק |
| 5 | ניתוח קורפוס | מפת תוכן + נושאים + השוואה | Claude + מסמך | כמה שעות |
| 6 | צ'קליסט | יצירת content checklist להיטלי השבחה | עדכון קוד | — |
| 7 | אימות Paperclip | בדיקת הגדרות חברה + ניתוב | API / DB | — |
---
## הערות חשובות
### ההבדל בין רישוי ובנייה להיטלי השבחה (מ-CLAUDE.md)
| מאפיין | רישוי ובנייה (1xxx) | היטלי השבחה (8xxx) |
|---------|---------------------|-------------------|
| טון | חם יחסית | קר ומקצועי |
| תוכן | הקשר תכנוני רחב, אלמנטים אנושיים | יבש, ללא רגשות |
| נושאי דיון | תכניות, חניה, קווי בניין, שכנים | שומות, חישובי השבחה, פטורים |
| פסיקה | ס' 152, הלכת שפר, דיני הקלה | ס' 196-198, תוספת שלישית, שמאי מכריע |
### סינון RAG לפי סוג
כל ה-chunks נשמרים עם `appeal_subtype`, כך שחיפוש סמנטי בתיק היטל השבחה ימצא רק תקדימים רלוונטיים מהתחום — לא יערבב עם רישוי ובנייה.
### ניתוח סגנון נפרד
ייתכן שנצטרך **מדריך סגנון נפרד** להיטלי השבחה, כי הטון שונה מהותית. הניתוח בשלב 4 יחשוף את ההבדלים.
---
## סוכנים — שיתוף בין החברות
### עיקרון: אותם סוכנים, הקשר שונה
**אין צורך בסוכנים נפרדים** לכל חברה. הסוכנים (CEO, researcher, writer) עובדים לפי **מתודולוגיה** — ארכיטקטורת 12 בלוקים, CREAC, מבחן השופט — שחלה על כל סוגי העררים.
**מה שמשתנה אוטומטית לפי `appeal_subtype`**:
| רכיב | מקור | מנגנון הפרדה |
|-------|------|--------------|
| Style patterns | טבלת `style_patterns` | ניתוח סגנון נפרד per-subtype |
| Content checklists | `lessons.py` | key שונה: `building_permit` vs `betterment_levy` |
| תקדימים (RAG) | טבלת `document_chunks` | סינון לפי `appeal_subtype` בחיפוש |
| טון | style guide + patterns | דפוסים שונים מהקורפוס |
**למה שיתוף סוכנים עדיף**:
1. שיפור במתודולוגיה חל אוטומטית על שני התחומים
2. אין כפילות בתחזוקת סוכנים
3. ההפרדה היא **ברמת הנתונים**, לא ברמת הלוגיקה
**מה כן צריך לוודא**:
- [ ] הסוכנים ב-Paperclip מקושרים לשתי החברות (CMP + CMPA)
- [ ] כש-issue נפתח ב-CMPA, הסוכנים מופעלים באותו אופן
- [ ] ה-context שהסוכן מקבל כולל את ה-`appeal_subtype` הנכון

View File

@@ -19,6 +19,7 @@ dependencies = [
"google-cloud-vision>=3.7.0",
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"httpx>=0.27.0",
]
[build-system]

View File

@@ -165,10 +165,13 @@ async def document_upload_training(
decision_date: str = "",
subject_categories: list[str] | None = None,
title: str = "",
practice_area: str = "appeals_committee",
appeal_subtype: str = "",
) -> str:
"""העלאת החלטה קודמת של דפנה לקורפוס הסגנון. קטגוריות: בנייה, שימוש חורג, תכנית, היתר, הקלה, חלוקה, תמ"א 38, היטל השבחה, פיצויים 197."""
"""העלאת החלטה קודמת של דפנה לקורפוס הסגנון. קטגוריות: בנייה, שימוש חורג, תכנית, היתר, הקלה, חלוקה, תמ"א 38, היטל השבחה, פיצויים 197. סוג ערר: building_permit / betterment_levy / compensation_197 (ריק = אוטומטי ממספר ההחלטה)."""
return await documents.document_upload_training(
file_path, decision_number, decision_date, subject_categories, title,
practice_area, appeal_subtype,
)
@@ -220,9 +223,14 @@ async def search_decisions(
query: str,
limit: int = 10,
section_type: str = "",
practice_area: str = "",
appeal_subtype: str = "",
case_number: str = "",
) -> str:
"""חיפוש סמנטי בהחלטות קודמות ובמסמכים."""
return await search.search_decisions(query, limit, section_type)
"""חיפוש סמנטי בהחלטות קודמות ובמסמכים — מסונן לפי תחום משפטי."""
return await search.search_decisions(
query, limit, section_type, practice_area, appeal_subtype, case_number,
)
@mcp.tool()
@@ -239,9 +247,14 @@ async def search_case_documents(
async def find_similar_cases(
description: str,
limit: int = 5,
practice_area: str = "",
appeal_subtype: str = "",
case_number: str = "",
) -> str:
"""מציאת תיקים דומים על בסיס תיאור."""
return await search.find_similar_cases(description, limit)
"""מציאת תיקים דומים על בסיס תיאור — מסונן לפי תחום משפטי."""
return await search.find_similar_cases(
description, limit, practice_area, appeal_subtype, case_number,
)
# Drafting
@@ -309,9 +322,28 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
@mcp.tool()
async def analyze_style() -> str:
"""ניתוח סגנון על קורפוס ההחלטות של דפנה. מחלץ ושומר דפוסי כתיבה."""
return await drafting.analyze_style()
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
"""רישום עריכה שהעלה המשתמש (עריכה-v*.docx) כמקור האמת החדש — מזריק bookmarks אם חסר."""
return await drafting.apply_user_edit(case_number, edit_filename)
@mcp.tool()
async def list_bookmarks(case_number: str) -> str:
"""רשימת bookmarks הקיימים ב-active_draft של התיק (אנקורים ל-revisions)."""
return await drafting.list_bookmarks(case_number)
@mcp.tool()
async def revise_draft(case_number: str, revisions_json: str,
author: str = "מערכת AI") -> str:
"""החלת revisions (Track Changes) על ה-active_draft, יוצר טיוטה-v{N+1}.docx חדשה."""
return await drafting.revise_draft(case_number, revisions_json, author)
@mcp.tool()
async def analyze_style(appeal_subtype: str = "") -> str:
"""ניתוח סגנון על קורפוס ההחלטות של דפנה. מחלץ ושומר דפוסי כתיבה. סוג ערר: building_permit / betterment_levy / compensation_197 (ריק = הכל)."""
return await drafting.analyze_style(appeal_subtype)
@mcp.tool()

View File

@@ -0,0 +1,503 @@
"""Export the legal analysis (analysis-and-research.md + precedents) to a
DOCX file that uses דפנה's decision template styles.
The template lives at `skills/docx/decision_template.docx` (converted once
from `טיוטת החלטה.dotx` via `scripts/convert_decision_template.py`).
We open it, wipe the sample body paragraphs, and write new content by
applying style names only — never by hand-setting font/size/RTL/margins,
because the template's styles.xml already carries those.
Style mapping:
"Title" → the document title (case number, date)
"Heading 2" → top-level section headers
(טענות סף / סוגיות להכרעה / מסקנות)
"Normal" + bold → subsection headers (individual claim/issue)
"Normal" → field label (bold run) + value
"Quote" → precedent quote text
"Normal" (italic) → precedent citation
Output: data/cases/{case_number}/exports/ניתוח-משפטי-v{N}.docx
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from uuid import UUID
from docx import Document
from docx.document import Document as DocumentT
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
from docx.text.paragraph import Paragraph
from docx.text.run import Run
from legal_mcp import config
from legal_mcp.services import db, research_md
def _mark_run_rtl(run: Run) -> None:
"""Mark a run as complex-script (Hebrew/Arabic) so Word uses the `cs`
font slot from the style (David) rather than `ascii` (Times New Roman).
Without this, runs we add programmatically render Hebrew in the ascii
font — even though the paragraph style has `<w:rFonts cs="David"/>`.
"""
rPr = run._r.get_or_add_rPr()
if rPr.find(qn("w:rtl")) is None:
rPr.append(OxmlElement("w:rtl"))
def _mark_paragraph_rtl(paragraph: Paragraph) -> None:
"""Add `<w:rtl/>` inside the paragraph's rPr so the paragraph mark
itself is treated as RTL. The paragraph style already sets bidi
direction, but empty paragraphs and trailing marks need this flag.
"""
pPr = paragraph._p.get_or_add_pPr()
rPr = pPr.find(qn("w:rPr"))
if rPr is None:
rPr = OxmlElement("w:rPr")
pPr.append(rPr)
if rPr.find(qn("w:rtl")) is None:
rPr.append(OxmlElement("w:rtl"))
# Path to the converted template. Static — populated by
# scripts/convert_decision_template.py.
TEMPLATE_PATH = (
Path(__file__).resolve().parents[4]
/ "skills"
/ "docx"
/ "decision_template.docx"
)
CHAIR_POSITION_LABEL = "עמדת ועדת הערר"
CHAIR_POSITION_PLACEHOLDER = "[טרם מולאה עמדת ועדת הערר]"
NUMBERED_LINE_RE = re.compile(r"^\s*(\d+)[.)]\s+(.+)$")
BULLET_LINE_RE = re.compile(r"^\s*[\-\u2022\*\u25CF\u25E6]\s+(.+)$")
# (א) (ב) (ג) ... — Hebrew-letter enumeration used by the authors.
# We keep the marker inside the text (the author wrote it), but render the
# paragraph as "List Paragraph" without the numPr so the visual indentation
# matches the template's list style without adding a double "1." prefix.
HEB_LETTER_LINE_RE = re.compile(r"^\s*\([א-ת]\)\s+")
# A standalone **LABEL:** line (the whole trimmed line is wrapped in ** **)
STANDALONE_LABEL_RE = re.compile(r"^\s*\*\*([^\n*]+?):\*\*\s*$")
# A short standalone "XYZ:" line (no ** **) — acts as a sub-heading for the
# paragraphs that follow. Limit to short phrases to avoid eating real
# sentences that happen to end with a colon.
PLAIN_LABEL_RE = re.compile(r"^\s*([^\n:]{2,40}):\s*$")
# "**LABEL:** value" inline — bold label followed by prose on the same line.
INLINE_LABEL_RE = re.compile(r"^\s*\*\*([^\n*]+?):\*\*\s+(.+)$")
def _classify_line(line: str) -> tuple[str, str]:
"""Return (kind, clean_text) where kind ∈ {numbered, bullet, heb_letter,
label_heading, inline_label, plain}.
clean_text conventions:
- numbered/bullet — marker stripped
- heb_letter — marker kept (author supplied it)
- label_heading — surrounding ** and trailing : stripped
- inline_label — "LABEL\x00VALUE" (NUL-separated; _emit splits it)
"""
m = STANDALONE_LABEL_RE.match(line)
if m:
return "label_heading", m.group(1).strip()
m = INLINE_LABEL_RE.match(line)
if m:
return "inline_label", f"{m.group(1).strip()}\x00{m.group(2).strip()}"
m = NUMBERED_LINE_RE.match(line)
if m:
return "numbered", m.group(2).strip()
m = BULLET_LINE_RE.match(line)
if m:
inner = m.group(1).strip()
# A bullet whose only content is **LABEL:** is a heading, not a list item.
# E.g. "- **נקודות פתוחות:**"
m2 = STANDALONE_LABEL_RE.match(inner)
if m2:
return "label_heading", m2.group(1).strip()
# A bullet of the form "- **LABEL:** value" → inline label.
m3 = INLINE_LABEL_RE.match(inner)
if m3:
return "inline_label", f"{m3.group(1).strip()}\x00{m3.group(2).strip()}"
return "bullet", inner
if HEB_LETTER_LINE_RE.match(line):
return "heb_letter", line.strip()
m = PLAIN_LABEL_RE.match(line)
if m:
return "label_heading", m.group(1).strip()
return "plain", line.strip()
def _strip_numpr(paragraph: Paragraph) -> None:
"""Remove any <w:numPr> from the paragraph's pPr.
Used when we want the visual styling of `List Paragraph` (indent,
font) without Word's auto-decimal "1." prefix — e.g. for Hebrew-
letter enumeration where the author wrote (א) (ב) (ג) manually.
"""
pPr = paragraph._p.get_or_add_pPr()
for numPr in pPr.findall(qn("w:numPr")):
pPr.remove(numPr)
# Characters that the code should never emit (user instruction: "no dashes").
# Applied only to code-generated text, not to user content from the md file.
_CODE_DASH_RE = re.compile(r"[\u2013\u2014]")
# Markdown inline bold — `**...**`
_INLINE_BOLD_RE = re.compile(r"\*\*([^\n*]+?)\*\*")
def _no_dash(text: str) -> str:
"""Strip em/en dashes from text the code emits (not from source content)."""
return _CODE_DASH_RE.sub("", text)
def _add_runs_with_inline_bold(paragraph: Paragraph, text: str) -> None:
"""Split `text` on `**...**` markers, adding alternating plain and bold
runs to `paragraph`. All runs are marked RTL and passed through
`_no_dash`.
This keeps `**טענה חשובה**` rendering as bold (as the author intended)
instead of leaving the literal asterisks in the output.
"""
text = _no_dash(text)
pos = 0
for m in _INLINE_BOLD_RE.finditer(text):
if m.start() > pos:
plain = paragraph.add_run(text[pos : m.start()])
_mark_run_rtl(plain)
bold = paragraph.add_run(m.group(1))
bold.bold = True
_mark_run_rtl(bold)
pos = m.end()
if pos < len(text):
tail = paragraph.add_run(text[pos:])
_mark_run_rtl(tail)
def _clear_body(doc: DocumentT) -> None:
"""Remove every paragraph currently in the document body.
The template ships with example paragraphs ("רקע", "דיון והכרעה"…)
that we don't want in the output. Section properties (sectPr) are
kept so page size / margins / RTL / footer remain intact.
"""
body = doc.element.body
for p in list(body.findall(qn("w:p"))):
body.remove(p)
# Leave sectPr alone — it carries page setup including bidi.
def _add_paragraph(doc: DocumentT, text: str, style: str) -> Paragraph:
p = doc.add_paragraph(style=style)
_mark_paragraph_rtl(p)
if text:
_add_runs_with_inline_bold(p, text)
return p
def _add_label_value(
doc: DocumentT, label: str, value: str, *, value_italic: bool = False
) -> Paragraph:
"""Add a paragraph with a bold label and an inline value.
Example rendering: **עמדת המבקשת:** The party argues that…
"""
p = doc.add_paragraph(style="Normal")
_mark_paragraph_rtl(p)
run_label = p.add_run(f"{_no_dash(label)}: ")
run_label.bold = True
_mark_run_rtl(run_label)
if value:
if value_italic:
# Placeholder text — italic, no inline-bold handling.
run_value = p.add_run(_no_dash(value))
run_value.italic = True
_mark_run_rtl(run_value)
else:
_add_runs_with_inline_bold(p, value)
return p
def _add_multiline_value(
doc: DocumentT, label: str, value: str
) -> None:
"""Render a field (label + value).
Multi-line values get the label as its own Heading 2 paragraph (so the
structure visually breaks between fields), then each body line as its
own paragraph routed through `_emit_content_line`.
Single-line values stay inline (bold label + text) — a Heading 2 for
a one-liner would look inflated.
"""
lines = [ln for ln in value.splitlines() if ln.strip()]
if not lines:
_add_label_value(doc, label, "")
return
if len(lines) == 1:
kind, text = _classify_line(lines[0])
# Single-line — inline with label regardless of kind
_add_label_value(doc, label, text)
return
# Multi-line: label as Heading 2, then each line via _emit_content_line
_add_paragraph(doc, label, "Heading 2")
for line in lines:
_emit_content_line(doc, line)
def _emit_content_line(doc: DocumentT, line: str) -> None:
"""Render a single line of content using the right template style.
- `label_heading` (e.g. "**נקודות פתוחות:**" alone) → Heading 2
- `numbered` ("1. ...") → List Paragraph
(auto-decimal)
- `heb_letter` ("(א) ...") → List Paragraph
with numPr stripped
(author supplied
the marker)
- `bullet` ("- ...") → Normal (marker
stripped)
- `plain` → Normal
"""
kind, text = _classify_line(line)
if kind == "label_heading":
_add_paragraph(doc, text, "Heading 2")
return
if kind == "inline_label":
label, value = text.split("\x00", 1)
_add_label_value(doc, label, value)
return
if kind == "numbered":
para = doc.add_paragraph(style="List Paragraph")
elif kind == "heb_letter":
para = doc.add_paragraph(style="List Paragraph")
_strip_numpr(para)
else:
para = doc.add_paragraph(style="Normal")
_mark_paragraph_rtl(para)
_add_runs_with_inline_bold(para, text)
def _format_subsection_title(item: dict[str, Any], kind_label: str) -> str:
"""Return '{kind_label} {number}: {title}' e.g. 'טענת סף 1: חוסר סמכות'."""
number = item.get("number") or ""
title = item.get("title", "").strip()
if number and title:
return f"{kind_label} {number}: {title}"
if title:
return title
return f"{kind_label} {number}".strip()
def _write_subsection(
doc: DocumentT,
item: dict[str, Any],
precedents_for_item: list[dict[str, Any]],
kind_label: str,
) -> None:
# Subsection header — bolded Normal paragraph, not a Heading,
# so it visually sits under the section's Heading 2.
header_text = _format_subsection_title(item, kind_label)
p = doc.add_paragraph(style="Normal")
_mark_paragraph_rtl(p)
run = p.add_run(_no_dash(header_text))
run.bold = True
_mark_run_rtl(run)
# Regular fields (party positions, legal questions, etc.)
for field in item.get("fields", []):
label = field.get("label", "").strip()
content = field.get("content", "").strip()
if not label:
continue
_add_multiline_value(doc, label, content)
# Chair position — special handling: always render, use placeholder if empty.
chair_position = (item.get("chair_position") or "").strip()
if chair_position:
_add_multiline_value(doc, CHAIR_POSITION_LABEL, chair_position)
else:
_add_label_value(
doc, CHAIR_POSITION_LABEL, CHAIR_POSITION_PLACEHOLDER,
value_italic=True,
)
# Precedents attached to this subsection
if precedents_for_item:
p = doc.add_paragraph(style="Normal")
_mark_paragraph_rtl(p)
run = p.add_run("פסיקה רלוונטית:")
run.bold = True
_mark_run_rtl(run)
for prec in precedents_for_item:
quote = (prec.get("quote") or "").strip()
citation = (prec.get("citation") or "").strip()
if quote:
_add_paragraph(doc, quote, "Quote")
if citation:
cite_p = doc.add_paragraph(style="Normal")
_mark_paragraph_rtl(cite_p)
cite_run = cite_p.add_run(_no_dash(citation))
cite_run.italic = True
_mark_run_rtl(cite_run)
def _add_background_section(
doc: DocumentT, title: str, body: str | None
) -> None:
"""Render a background H2 section (e.g. "רקע דיוני") from a prose
body. Lines are routed through `_emit_content_line` so bullets,
`**labels:**`, and (א) enumerations all get the template styles.
"""
if not body or not body.strip():
return
_add_paragraph(doc, title, "Heading 2")
for raw in body.splitlines():
if not raw.strip():
continue
_emit_content_line(doc, raw)
def _group_precedents(
precedents: list[dict[str, Any]],
) -> tuple[list[dict], dict[str, list[dict]]]:
"""Split the flat precedent list into case-level and per-section maps.
Returns (case_level_precedents, {section_id: [precedents]}).
"""
case_level: list[dict] = []
by_section: dict[str, list[dict]] = {}
for p in precedents:
sid = p.get("section_id")
if sid is None:
case_level.append(p)
else:
by_section.setdefault(sid, []).append(p)
return case_level, by_section
def _next_version(export_dir: Path) -> int:
"""Return the next version number for ניתוח-משפטי-v{N}.docx."""
existing = sorted(export_dir.glob("ניתוח-משפטי-v*.docx"))
next_ver = 1
for p in existing:
try:
ver = int(p.stem.split("-v")[1])
except (IndexError, ValueError):
continue
next_ver = max(next_ver, ver + 1)
return next_ver
async def build_analysis_docx(case_number: str) -> Path:
"""Build a DOCX of the legal analysis for a case using the template
styles, and save a versioned copy under the case's exports folder.
Raises FileNotFoundError if no analysis file or template exists.
"""
if not TEMPLATE_PATH.exists():
raise FileNotFoundError(
f"Template not found at {TEMPLATE_PATH}. "
"Run: python scripts/convert_decision_template.py"
)
case_dir = config.find_case_dir(case_number)
analysis_path = case_dir / "documents" / "research" / "analysis-and-research.md"
if not analysis_path.exists():
raise FileNotFoundError(
f"Analysis file not found for case {case_number}"
)
parsed = research_md.parse(analysis_path)
# Resolve case_id so we can fetch precedents. Missing case → proceed
# without precedents rather than failing the export.
case_level_precedents: list[dict] = []
precedents_by_section: dict[str, list[dict]] = {}
case = await db.get_case_by_number(case_number)
if case:
precedents = await db.list_case_precedents(UUID(case["id"]))
case_level_precedents, precedents_by_section = _group_precedents(precedents)
doc = Document(str(TEMPLATE_PATH))
_clear_body(doc)
# Document title
header = parsed.get("header", {})
date = header.get("date", "").strip()
title_text = f"ניתוח משפטי וכתיבת עמדה בערר {case_number}"
_add_paragraph(doc, title_text, "Heading 1")
if date:
p_date = doc.add_paragraph(style="Normal")
_mark_paragraph_rtl(p_date)
run_date = p_date.add_run(f"תאריך: {date}")
_mark_run_rtl(run_date)
# Background sections — printed first so the reader gets context
# before any claims/precedents. These come only in the exported DOCX,
# not in the web UI (the UI renders them elsewhere).
_add_background_section(doc, "רקע לניתוח", parsed.get("represented_party"))
_add_background_section(doc, "רקע דיוני", parsed.get("procedural_background"))
_add_background_section(doc, "עובדות מוסכמות", parsed.get("agreed_facts"))
_add_background_section(
doc, "עובדות שנויות במחלוקת", parsed.get("disputed_facts")
)
# Case-level precedents appear at the top (they cut across claims/issues)
if case_level_precedents:
_add_paragraph(doc, "פסיקה כללית", "Heading 2")
for prec in case_level_precedents:
quote = (prec.get("quote") or "").strip()
citation = (prec.get("citation") or "").strip()
if quote:
_add_paragraph(doc, quote, "Quote")
if citation:
cp = doc.add_paragraph(style="Normal")
_mark_paragraph_rtl(cp)
cr = cp.add_run(_no_dash(citation))
cr.italic = True
_mark_run_rtl(cr)
# Threshold claims
threshold_claims = parsed.get("threshold_claims", [])
if threshold_claims:
_add_paragraph(doc, "טענות סף", "Heading 2")
for tc in threshold_claims:
_write_subsection(
doc, tc, precedents_by_section.get(tc["id"], []), "טענת סף"
)
# Issues
issues = parsed.get("issues", [])
if issues:
_add_paragraph(doc, "סוגיות להכרעה", "Heading 2")
for iss in issues:
_write_subsection(
doc, iss, precedents_by_section.get(iss["id"], []), "סוגיה"
)
# Conclusions
conclusions = (parsed.get("conclusions") or "").strip()
if conclusions:
_add_paragraph(doc, "מסקנות", "Heading 2")
for raw in conclusions.splitlines():
if not raw.strip():
continue
_emit_content_line(doc, raw)
# Save versioned
export_dir = case_dir / "exports"
export_dir.mkdir(parents=True, exist_ok=True)
version = _next_version(export_dir)
out_path = export_dir / f"ניתוח-משפטי-v{version}.docx"
doc.save(str(out_path))
return out_path

View File

@@ -27,8 +27,8 @@ logger = logging.getLogger(__name__)
# ── Block configuration ───────────────────────────────────────────
# Output token limits per Anthropic docs (April 2026):
# Opus 4.6: up to 128K output tokens
# Output token limits per Anthropic docs:
# Opus 4.7: up to 128K output tokens (new tokenizer — ~35% more tokens)
# Sonnet 4.6: up to 64K output tokens
# Streaming required when max_tokens > 21,333
BLOCK_CONFIG = {
@@ -48,7 +48,7 @@ BLOCK_CONFIG = {
MODEL_MAP = {
"sonnet": "claude-sonnet-4-20250514",
"opus": "claude-opus-4-20250514",
"opus": "claude-opus-4-7",
}

View File

@@ -104,6 +104,8 @@ CREATE TABLE IF NOT EXISTS style_corpus (
summary TEXT DEFAULT '',
outcome TEXT DEFAULT '',
key_principles JSONB DEFAULT '[]',
practice_area TEXT DEFAULT 'appeals_committee',
appeal_subtype TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT now()
);
@@ -114,6 +116,7 @@ CREATE TABLE IF NOT EXISTS style_patterns (
frequency INTEGER DEFAULT 1,
context TEXT DEFAULT '',
examples JSONB DEFAULT '[]',
appeal_subtype TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT now()
);
@@ -156,6 +159,20 @@ ALTER TABLE decisions ADD COLUMN IF NOT EXISTS outcome_reasoning TEXT DEFAULT ''
-- הרחבת cases עם appeal_type (אם לא קיים)
ALTER TABLE cases ADD COLUMN IF NOT EXISTS appeal_type TEXT DEFAULT '';
ALTER TABLE cases ADD COLUMN IF NOT EXISTS practice_area TEXT DEFAULT 'appeals_committee';
ALTER TABLE cases ADD COLUMN IF NOT EXISTS appeal_subtype TEXT DEFAULT '';
-- active_draft_path = path to the DOCX that is the current source of truth
-- for this case's decision text. Set to the latest טיוטה-v*.docx after export,
-- or the latest עריכה-v*.docx after user upload. Used by revise_draft to know
-- what file to base Track Changes revisions on.
ALTER TABLE cases ADD COLUMN IF NOT EXISTS active_draft_path TEXT;
-- הרחבת style_corpus עם practice_area / appeal_subtype
ALTER TABLE style_corpus ADD COLUMN IF NOT EXISTS practice_area TEXT DEFAULT 'appeals_committee';
ALTER TABLE style_corpus ADD COLUMN IF NOT EXISTS appeal_subtype TEXT DEFAULT '';
-- הרחבת style_patterns עם appeal_subtype לניתוח סגנון נפרד לכל סוג ערר
ALTER TABLE style_patterns ADD COLUMN IF NOT EXISTS appeal_subtype TEXT DEFAULT '';
-- טבלת qa_results
CREATE TABLE IF NOT EXISTS qa_results (
@@ -374,6 +391,16 @@ CREATE TABLE IF NOT EXISTS chair_feedback (
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tag_company_mappings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tag TEXT NOT NULL, -- appeal_subtype value (e.g. building_permit)
tag_label TEXT NOT NULL DEFAULT '', -- Hebrew display label
company_id TEXT NOT NULL, -- Paperclip company UUID
company_name TEXT NOT NULL DEFAULT '', -- cached company name for display
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(tag, company_id)
);
-- ═══════════════════════════════════════════════════════════════════
-- Indexes
-- ═══════════════════════════════════════════════════════════════════
@@ -467,6 +494,8 @@ async def create_case(
hearing_date: date | None = None,
notes: str = "",
expected_outcome: str = "",
practice_area: str = "appeals_committee",
appeal_subtype: str = "",
) -> dict:
pool = await get_pool()
case_id = uuid4()
@@ -474,13 +503,15 @@ async def create_case(
await conn.execute(
"""INSERT INTO cases (id, case_number, title, appellants, respondents,
subject, property_address, permit_number, committee_type,
hearing_date, notes, expected_outcome)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)""",
hearing_date, notes, expected_outcome,
practice_area, appeal_subtype)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)""",
case_id, case_number, title,
json.dumps(appellants or []),
json.dumps(respondents or []),
subject, property_address, permit_number, committee_type,
hearing_date, notes, expected_outcome,
practice_area, appeal_subtype,
)
return await get_case(case_id)
@@ -494,6 +525,25 @@ async def get_case(case_id: UUID) -> dict | None:
return _row_to_case(row)
async def set_active_draft_path(case_id: UUID, path: str | None) -> None:
"""Update the case's active_draft_path (the DOCX that is source of truth)."""
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE cases SET active_draft_path = $1, updated_at = now() WHERE id = $2",
path, case_id,
)
async def get_active_draft_path(case_id: UUID) -> str | None:
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT active_draft_path FROM cases WHERE id = $1", case_id,
)
return row["active_draft_path"] if row else None
async def get_case_by_number(case_number: str) -> dict | None:
pool = await get_pool()
async with pool.acquire() as conn:
@@ -809,6 +859,8 @@ async def search_similar(
limit: int = 10,
case_id: UUID | None = None,
section_type: str | None = None,
practice_area: str | None = None,
appeal_subtype: str | None = None,
) -> list[dict]:
"""Cosine similarity search on document chunks."""
pool = await get_pool()
@@ -824,6 +876,14 @@ async def search_similar(
conditions.append(f"dc.section_type = ${param_idx}")
params.append(section_type)
param_idx += 1
if practice_area:
conditions.append(f"c.practice_area = ${param_idx}")
params.append(practice_area)
param_idx += 1
if appeal_subtype:
conditions.append(f"c.appeal_subtype = ${param_idx}")
params.append(appeal_subtype)
param_idx += 1
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
@@ -856,6 +916,8 @@ async def add_to_style_corpus(
summary: str = "",
outcome: str = "",
key_principles: list[str] | None = None,
practice_area: str = "appeals_committee",
appeal_subtype: str = "",
) -> UUID:
pool = await get_pool()
corpus_id = uuid4()
@@ -863,11 +925,13 @@ async def add_to_style_corpus(
await conn.execute(
"""INSERT INTO style_corpus
(id, document_id, decision_number, decision_date,
subject_categories, full_text, summary, outcome, key_principles)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)""",
subject_categories, full_text, summary, outcome, key_principles,
practice_area, appeal_subtype)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)""",
corpus_id, document_id, decision_number, decision_date,
json.dumps(subject_categories), full_text, summary, outcome,
json.dumps(key_principles or []),
practice_area, appeal_subtype,
)
return corpus_id
@@ -937,12 +1001,14 @@ async def upsert_style_pattern(
pattern_text: str,
context: str = "",
examples: list[str] | None = None,
appeal_subtype: str = "",
) -> None:
pool = await get_pool()
async with pool.acquire() as conn:
existing = await conn.fetchrow(
"SELECT id, frequency FROM style_patterns WHERE pattern_type = $1 AND pattern_text = $2",
pattern_type, pattern_text,
"SELECT id, frequency FROM style_patterns "
"WHERE pattern_type = $1 AND pattern_text = $2 AND appeal_subtype = $3",
pattern_type, pattern_text, appeal_subtype,
)
if existing:
await conn.execute(
@@ -951,18 +1017,27 @@ async def upsert_style_pattern(
)
else:
await conn.execute(
"""INSERT INTO style_patterns (pattern_type, pattern_text, context, examples)
VALUES ($1, $2, $3, $4)""",
"""INSERT INTO style_patterns (pattern_type, pattern_text, context, examples, appeal_subtype)
VALUES ($1, $2, $3, $4, $5)""",
pattern_type, pattern_text, context,
json.dumps(examples or []),
appeal_subtype,
)
async def clear_style_patterns() -> None:
"""Delete all existing style patterns (used before re-analysis)."""
async def clear_style_patterns(appeal_subtype: str = "") -> None:
"""Delete style patterns, optionally filtered by appeal_subtype.
Empty appeal_subtype = delete ALL patterns.
"""
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM style_patterns")
if appeal_subtype:
await conn.execute(
"DELETE FROM style_patterns WHERE appeal_subtype = $1", appeal_subtype
)
else:
await conn.execute("DELETE FROM style_patterns")
# ── Semantic Search (V2 — decision blocks & case law) ─────────────

View File

@@ -58,6 +58,57 @@ def _set_rtl_section(section) -> None:
sectPr.append(bidi)
# ── Bookmark helpers ──────────────────────────────────────────────
# Keep a per-document bookmark id counter. Bookmarks must have unique ids
# across the whole document; we start from a high value to avoid collisions
# with whatever Word's default template already assigned.
_BOOKMARK_ID_START = 10000
def _insert_bookmark_start(paragraph, name: str, bm_id: int) -> None:
"""Insert a <w:bookmarkStart> at the beginning of a paragraph."""
el = OxmlElement("w:bookmarkStart")
el.set(qn("w:id"), str(bm_id))
el.set(qn("w:name"), name)
paragraph._p.insert(0, el)
def _insert_bookmark_end(paragraph, bm_id: int) -> None:
"""Insert a <w:bookmarkEnd> at the end of a paragraph."""
el = OxmlElement("w:bookmarkEnd")
el.set(qn("w:id"), str(bm_id))
paragraph._p.append(el)
def _wrap_block_with_bookmarks(doc, block_name: str,
write_block_fn, bm_counter: list[int]) -> None:
"""Write a block with bookmarkStart before and bookmarkEnd after.
Uses a mutable counter (list of one int) so the caller keeps state
across multiple blocks.
"""
# Record paragraph count before writing
body = doc.element.body
before_count = len([c for c in body if c.tag == qn("w:p")])
write_block_fn()
after_count = len([c for c in body if c.tag == qn("w:p")])
if after_count == before_count:
# Block produced no paragraphs — nothing to wrap
return
# Use python-docx's paragraph indexing
first_new = doc.paragraphs[before_count]
last_new = doc.paragraphs[after_count - 1]
bm_counter[0] += 1
bm_id = bm_counter[0]
_insert_bookmark_start(first_new, block_name, bm_id)
_insert_bookmark_end(last_new, bm_id)
def _add_paragraph(doc, text: str, style: str = "Normal",
bold: bool = False, font_size=None,
alignment=None, space_after: Pt | None = None) -> None:
@@ -160,14 +211,22 @@ async def export_decision(case_id: UUID, output_path: str | None = None) -> str:
section.right_margin = PAGE_MARGIN
_set_rtl_section(section)
# Write blocks
# Write blocks with bookmarks wrapping each block (anchors for revisions)
bm_counter = [_BOOKMARK_ID_START]
for block in blocks:
block_id = block["block_id"]
content = block["content"] or ""
if not content.strip():
continue
_write_block_to_docx(doc, block_id, block["title"], content)
_wrap_block_with_bookmarks(
doc,
f"block-{block_id}",
lambda b=block, bid=block_id, c=content: _write_block_to_docx(
doc, bid, b["title"], c,
),
bm_counter,
)
# Determine output path — versioned under cases/{case_number}/exports/
if not output_path:

View File

@@ -0,0 +1,290 @@
"""הזרקת bookmarks רטרואקטיבית ל-DOCX שלא נוצרו ע"י ה-exporter.
כאשר משתמש מעלה `עריכה-v*.docx` שנערך ב-Word מחוץ למערכת, אין בו את ה-
bookmarks שאנו מצפים להם (block-alef ... block-yod-bet). השירות כאן
מזהה את תחילת כל בלוק לפי סימני הפתיחה העבריים (א., ב., ... יב.) ב-
הפסקאות הראשונות שלו, ומזריק bookmarkStart/bookmarkEnd בהתאם.
נעשה בצורה defensive — אם לא מצליחים לזהות בלוק, הוא פשוט לא יקבל
bookmark (`missing_blocks` בתוצאה). השרת אמור להתריע למשתמש.
"""
from __future__ import annotations
import logging
import re
import shutil
import zipfile
from io import BytesIO
from pathlib import Path
from lxml import etree
from legal_mcp.services.docx_reviser import (
NSMAP,
_load_docx_xml,
_save_docx_xml,
_w,
)
logger = logging.getLogger(__name__)
# ── Block identification ──────────────────────────────────────────
# The 12 blocks in order, with their Hebrew letter marker
BLOCK_ORDER = [
("block-alef", "א"),
("block-bet", "ב"),
("block-gimel", "ג"),
("block-dalet", "ד"),
("block-heh", "ה"),
("block-vav", "ו"),
("block-zayin", "ז"),
("block-chet", "ח"),
("block-tet", "ט"),
("block-yod", "י"),
("block-yod-alef", "יא"),
("block-yod-bet", "יב"),
]
# Regex matching a paragraph that begins with a Hebrew block marker
# followed by '.', ')', ' ', or end-of-string. The marker must be followed
# either by whitespace/punctuation or end of text to avoid matching longer
# words that happen to start with these letters.
_BLOCK_MARKERS_BY_LETTER: dict[str, str] = {letter: name for name, letter in BLOCK_ORDER}
# Longer markers (יא, יב) first so regex matches them before falling back to 'י'
_MARKER_ALTERNATION = "|".join(
re.escape(letter)
for letter in sorted(_BLOCK_MARKERS_BY_LETTER, key=len, reverse=True)
)
_BLOCK_MARKER_RE = re.compile(
rf"^\s*({_MARKER_ALTERNATION})\s*[\.\)\-]\s*"
)
# Secondary heuristic: Hebrew section headings that reliably mark the
# start of each block in the Daphna Tamir style (used when markers
# "א.", "ב." etc. are missing — common in user-edited Word files).
#
# Key observations from the 12-block schema:
# block-alef: "בפני: דפנה תמיר" or decision number page
# block-bet: "ערר מספר" line
# block-gimel: appellants vs respondents (parties)
# block-dalet: bold "החלטה" centered
# block-heh: "רקע" / "רקע עובדתי" / "פתח דבר"
# block-vav: "תכניות חלות" / "ההליך שבפנינו" / "ההליכים בפני"
# block-zayin: "תמצית טענות" / "טענות הצדדים"
# block-chet: "תגובת המשיבה" / "עמדת הוועדה"
# block-tet: "ההליכים בפני ועדת הערר" / "הדיון בפנינו"
# block-yod: "דיון והכרעה" / "דיון"
# block-yod-alef: "סוף דבר" / "סיכום"
# block-yod-bet: "ההחלטה" (signature / closing block)
_BLOCK_HEADING_PATTERNS: list[tuple[str, list[str]]] = [
("block-alef", [r"בפני[:\s]", r"ועדת הערר"]),
("block-bet", [r"^ערר\s+מספר", r"^ערר\s+\d"]),
("block-gimel", [r"^נגד\s*$", r"^—\s*נגד\s*—"]),
("block-dalet", [r"^החלטה\s*$"]),
("block-heh", [r"^רקע\s*$", r"^רקע\s+עובדתי", r"^פתח\s+דבר"]),
("block-vav", [r"^תכניות\s+חלות", r"^ההליכים?\s+שבפנינו", r"^ההליכים?\s+בפני\s+הוועדה\s+המקומית"]),
("block-zayin", [r"^תמצית\s+טענות", r"^טענות\s+הצדדים", r"^טענות\s+העוררי"]),
("block-chet", [r"^תגובת\s+המשיב", r"^עמדת\s+הוועדה\s+המקומית", r"^תשובת"]),
("block-tet", [r"^ההליכים?\s+בפני\s+ועדת\s+הערר", r"^הדיון\s+בפנינו"]),
("block-yod", [r"^דיון\s+והכרעה", r"^דיון\s*$", r"^ההכרעה"]),
("block-yod-alef", [r"^סוף\s+דבר", r"^סיכום\s*$"]),
("block-yod-bet", [r"^ההחלטה\s*$", r"^על\s+כן[,\.]?"]),
]
_COMPILED_HEADING_PATTERNS: list[tuple[str, list[re.Pattern[str]]]] = [
(name, [re.compile(p) for p in patterns])
for name, patterns in _BLOCK_HEADING_PATTERNS
]
def _paragraph_text(p: etree._Element) -> str:
"""Return the full text of a paragraph, joining all w:t nodes."""
return "".join(p.itertext()).strip()
def _detect_block_starts(
paragraphs: list[etree._Element],
) -> dict[str, int]:
"""Return a mapping of block_name → paragraph index (start of that block).
Uses a greedy scan: for each paragraph, if its text starts with an
expected block marker and the block hasn't been assigned yet, assign
this paragraph as the block's start.
"""
found: dict[str, int] = {}
expected_order = [name for name, _ in BLOCK_ORDER]
pointer = 0 # index into expected_order — next expected block
for i, p in enumerate(paragraphs):
text = _paragraph_text(p)
if not text:
continue
matched_name: str | None = None
# Try marker-based (א., ב., ...) first
m = _BLOCK_MARKER_RE.match(text)
if m:
letter = m.group(1)
matched_name = _BLOCK_MARKERS_BY_LETTER.get(letter)
# Fall back to heading-keyword heuristic (Daphna style)
if matched_name is None:
for name, patterns in _COMPILED_HEADING_PATTERNS:
if name in found:
continue
# Only check patterns for blocks we haven't assigned yet
# AND that come at/after the current pointer — to keep the
# greedy forward-scan semantics consistent with markers.
if expected_order.index(name) < pointer:
continue
if any(pat.search(text) for pat in patterns):
matched_name = name
break
if matched_name is None:
continue
if matched_name in found:
continue
if pointer >= len(expected_order):
continue
name_idx_in_order = expected_order.index(matched_name)
if name_idx_in_order >= pointer:
found[matched_name] = i
pointer = name_idx_in_order + 1
return found
def _insert_bookmark_around_range(
body: etree._Element,
paragraphs: list[etree._Element],
start_idx: int,
end_idx: int,
name: str,
bm_id: int,
) -> None:
"""Insert bookmarkStart at the start of paragraph start_idx and
bookmarkEnd at the end of paragraph end_idx."""
start_el = etree.Element(_w("bookmarkStart"))
start_el.set(_w("id"), str(bm_id))
start_el.set(_w("name"), name)
end_el = etree.Element(_w("bookmarkEnd"))
end_el.set(_w("id"), str(bm_id))
start_p = paragraphs[start_idx]
end_p = paragraphs[end_idx]
start_p.insert(0, start_el)
end_p.append(end_el)
def _next_bookmark_id(doc_tree: etree._Element) -> int:
"""Find max existing bookmark id and return next unused."""
max_id = 9999
for el in doc_tree.iterfind(".//w:bookmarkStart", NSMAP):
wid = el.get(_w("id"))
if wid:
try:
max_id = max(max_id, int(wid))
except ValueError:
pass
return max_id + 1
# ── Public API ────────────────────────────────────────────────────
def retrofit_bookmarks(
docx_path: str | Path,
*,
output_path: str | Path | None = None,
backup: bool = True,
) -> dict:
"""Inject block-* bookmarks into an existing DOCX via heuristic detection.
Args:
docx_path: path to DOCX file (modified in place unless output_path set).
output_path: if given, write to this path instead of overwriting.
backup: if True and writing in place, save the original as
`<path>.pre-retrofit.docx` first.
Returns:
{
'bookmarks_added': ['block-alef', ...],
'missing_blocks': ['block-dalet', ...],
'existing_bookmarks': [...] # bookmarks already on the doc
}
"""
docx_path = Path(docx_path)
if not docx_path.exists():
raise FileNotFoundError(str(docx_path))
if output_path is None:
output_path = docx_path
output_path = Path(output_path)
members, doc_tree, settings_tree = _load_docx_xml(docx_path)
# Existing bookmarks
existing_names: list[str] = []
for el in doc_tree.iterfind(".//w:bookmarkStart", NSMAP):
name = el.get(_w("name"))
if name:
existing_names.append(name)
# Collect *top-level* body paragraphs (don't descend into tables etc.
# for now — MVP). The XPath ".//w:p" would include table cells too;
# for retrofitting we only care about the main flow.
body = doc_tree.find(f".//{_w('body')}")
if body is None:
raise ValueError("document has no <w:body>")
paragraphs = [p for p in body if p.tag == _w("p")]
if not paragraphs:
return {
"bookmarks_added": [],
"missing_blocks": [n for n, _ in BLOCK_ORDER],
"existing_bookmarks": existing_names,
}
block_starts = _detect_block_starts(paragraphs)
# Calculate end_idx for each block = paragraph before the next block's start,
# or last paragraph if this is the last block found.
ordered_found = sorted(block_starts.items(), key=lambda kv: kv[1])
ranges: list[tuple[str, int, int]] = []
for i, (name, start_idx) in enumerate(ordered_found):
if i + 1 < len(ordered_found):
end_idx = ordered_found[i + 1][1] - 1
else:
end_idx = len(paragraphs) - 1
ranges.append((name, start_idx, max(start_idx, end_idx)))
# Backup if overwriting in place
if backup and output_path.resolve() == docx_path.resolve():
backup_path = docx_path.with_suffix(".pre-retrofit.docx")
shutil.copy2(str(docx_path), str(backup_path))
# Inject bookmarks, skipping any that already exist
next_id = _next_bookmark_id(doc_tree)
added: list[str] = []
for name, s, e in ranges:
if name in existing_names:
continue
_insert_bookmark_around_range(body, paragraphs, s, e, name, next_id)
added.append(name)
next_id += 1
_save_docx_xml(members, doc_tree, settings_tree, output_path)
missing = [n for n, _ in BLOCK_ORDER if n not in block_starts and n not in existing_names]
logger.info("retrofit %s: added=%s missing=%s",
docx_path.name, added, missing)
return {
"bookmarks_added": added,
"missing_blocks": missing,
"existing_bookmarks": existing_names,
}

View File

@@ -0,0 +1,514 @@
"""עריכת DOCX עם Track Changes אמיתיים של Word.
השירות מיועד לקבל DOCX קיים (עם bookmarks שזיהו אנקורים) ולהחיל עליו
עריכות מסומנות כ-w:ins / w:del, שבאים לידי ביטוי ב-Word כ-Track Changes
שהמשתמש יכול Accept/Reject.
אסטרטגיית אנקורים: bookmarks בשמות כגון 'block-yod', 'block-yod-para-3'
שמוכנסים בזמן הייצוא הראשוני (docx_exporter.py) או רטרואקטיבית
(docx_retrofit.py).
"""
from __future__ import annotations
import logging
import shutil
import zipfile
from dataclasses import dataclass, field
from datetime import datetime, timezone
from io import BytesIO
from pathlib import Path
from typing import Literal
from lxml import etree
logger = logging.getLogger(__name__)
# ── XML namespaces ─────────────────────────────────────────────────
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
NSMAP = {"w": W_NS}
def _w(tag: str) -> str:
"""Build a fully qualified tag name in the w: namespace."""
return f"{{{W_NS}}}{tag}"
# ── Data models ────────────────────────────────────────────────────
RevisionType = Literal["insert_after", "insert_before", "replace", "delete"]
StyleType = Literal["body", "quote", "heading", "bold"]
@dataclass
class Revision:
"""A single tracked change to apply to the DOCX."""
id: str
type: RevisionType
anchor_bookmark: str
content: str = ""
style: StyleType = "body"
reason: str = ""
anchor_position: Literal["start", "end"] = "end"
@dataclass
class RevisionResult:
"""Result of applying a single revision."""
id: str
status: Literal["applied", "failed"]
error: str | None = None
ins_id: int | None = None
@dataclass
class RevisionBatchResult:
"""Aggregate result of applying a revision batch."""
applied: int = 0
failed: int = 0
results: list[RevisionResult] = field(default_factory=list)
output_path: str = ""
# ── XML helpers ────────────────────────────────────────────────────
def _load_docx_xml(docx_path: Path) -> tuple[dict[str, bytes], etree._Element, etree._Element]:
"""Load a DOCX as a dict of zip members + parsed document/settings trees."""
members: dict[str, bytes] = {}
with zipfile.ZipFile(docx_path, "r") as zf:
for name in zf.namelist():
members[name] = zf.read(name)
if "word/document.xml" not in members:
raise ValueError(f"{docx_path}: missing word/document.xml")
document_tree = etree.fromstring(members["word/document.xml"])
settings_bytes = members.get("word/settings.xml")
if settings_bytes:
settings_tree = etree.fromstring(settings_bytes)
else:
settings_tree = etree.Element(_w("settings"), nsmap=NSMAP)
return members, document_tree, settings_tree
def _save_docx_xml(
members: dict[str, bytes],
document_tree: etree._Element,
settings_tree: etree._Element,
output_path: Path,
) -> None:
"""Write a DOCX back to disk with updated document/settings XML."""
members = dict(members)
members["word/document.xml"] = etree.tostring(
document_tree, xml_declaration=True, encoding="UTF-8", standalone=True
)
members["word/settings.xml"] = etree.tostring(
settings_tree, xml_declaration=True, encoding="UTF-8", standalone=True
)
output_path.parent.mkdir(parents=True, exist_ok=True)
buffer = BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for name, data in members.items():
zf.writestr(name, data)
output_path.write_bytes(buffer.getvalue())
def _ensure_track_revisions(settings_tree: etree._Element) -> None:
"""Ensure <w:trackRevisions/> is present in settings.xml.
Note: This enables *display* of track changes — actual w:ins/w:del nodes
are rendered as tracked regardless. Word respects trackRevisions for
recording further user edits too.
"""
existing = settings_tree.find(_w("trackRevisions"))
if existing is None:
el = etree.SubElement(settings_tree, _w("trackRevisions"))
el.set(_w("val"), "true")
def _next_revision_id(document_tree: etree._Element) -> int:
"""Find max existing w:id on w:ins/w:del/w:bookmarkStart and return next."""
max_id = 0
for xpath in (
".//w:ins", ".//w:del", ".//w:bookmarkStart", ".//w:bookmarkEnd",
".//w:commentRangeStart", ".//w:comment",
):
for el in document_tree.iterfind(xpath, NSMAP):
val = el.get(_w("id"))
if val:
try:
max_id = max(max_id, int(val))
except ValueError:
pass
return max_id + 1
def _find_bookmark(
document_tree: etree._Element, name: str
) -> tuple[etree._Element | None, etree._Element | None]:
"""Find w:bookmarkStart and w:bookmarkEnd elements by bookmark name."""
start = None
end = None
for el in document_tree.iterfind(".//w:bookmarkStart", NSMAP):
if el.get(_w("name")) == name:
start = el
break
if start is None:
return None, None
bm_id = start.get(_w("id"))
for el in document_tree.iterfind(".//w:bookmarkEnd", NSMAP):
if el.get(_w("id")) == bm_id:
end = el
break
return start, end
def _find_enclosing_paragraph(element: etree._Element) -> etree._Element | None:
"""Walk up from an element to find its enclosing w:p."""
cur = element
while cur is not None:
if cur.tag == _w("p"):
return cur
cur = cur.getparent()
return None
# ── Paragraph builders ─────────────────────────────────────────────
def _build_run(text: str, *, bold: bool = False, italic: bool = False,
font: str = "David", size_half_pt: int | None = None) -> etree._Element:
"""Build a w:r (run) element with RTL/David defaults and given text."""
r = etree.Element(_w("r"))
rPr = etree.SubElement(r, _w("rPr"))
rFonts = etree.SubElement(rPr, _w("rFonts"))
rFonts.set(_w("ascii"), font)
rFonts.set(_w("hAnsi"), font)
rFonts.set(_w("cs"), font)
rFonts.set(_w("hint"), "cs")
if size_half_pt is not None:
sz = etree.SubElement(rPr, _w("sz"))
sz.set(_w("val"), str(size_half_pt))
szCs = etree.SubElement(rPr, _w("szCs"))
szCs.set(_w("val"), str(size_half_pt))
if bold:
etree.SubElement(rPr, _w("b"))
etree.SubElement(rPr, _w("bCs"))
if italic:
etree.SubElement(rPr, _w("i"))
etree.SubElement(rPr, _w("iCs"))
etree.SubElement(rPr, _w("rtl"))
t = etree.SubElement(r, _w("t"))
t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
t.text = text
return r
def _build_paragraph(text: str, *, style: StyleType = "body") -> etree._Element:
"""Build a w:p (paragraph) with RTL + David + given text."""
p = etree.Element(_w("p"))
pPr = etree.SubElement(p, _w("pPr"))
bidi = etree.SubElement(pPr, _w("bidi"))
bidi.set(_w("val"), "1")
# Right alignment for body/RTL
jc = etree.SubElement(pPr, _w("jc"))
jc.set(_w("val"), "right")
rPr_p = etree.SubElement(pPr, _w("rPr"))
etree.SubElement(rPr_p, _w("rtl"))
bold = style in ("heading", "bold")
italic = style == "quote"
size = None
if style == "heading":
size = 28 # 14pt
elif style == "quote":
size = 22 # 11pt
run = _build_run(text, bold=bold, italic=italic, size_half_pt=size)
p.append(run)
return p
def _wrap_in_ins(elements: list[etree._Element], *, ins_id: int,
author: str, date_iso: str) -> etree._Element:
"""Wrap a list of *run-level* elements in a single <w:ins>."""
ins = etree.Element(_w("ins"))
ins.set(_w("id"), str(ins_id))
ins.set(_w("author"), author)
ins.set(_w("date"), date_iso)
for el in elements:
ins.append(el)
return ins
def _make_tracked_paragraph_insert(
text: str, *, style: StyleType, ins_id: int, author: str, date_iso: str,
mark_id: int | None = None,
) -> etree._Element:
"""Build a whole tracked-inserted paragraph.
DOCX convention for a fully-inserted paragraph:
1. All <w:r> runs are wrapped in a single <w:ins> (own id).
2. The paragraph's pPr/rPr gets an <w:ins> marker for the paragraph
mark itself (pilcrow) — this uses its *own* id.
"""
if mark_id is None:
mark_id = ins_id
p = _build_paragraph(text, style=style)
pPr = p.find(_w("pPr"))
assert pPr is not None
rPr = pPr.find(_w("rPr"))
if rPr is None:
rPr = etree.SubElement(pPr, _w("rPr"))
ins_mark = etree.SubElement(rPr, _w("ins"))
ins_mark.set(_w("id"), str(mark_id))
ins_mark.set(_w("author"), author)
ins_mark.set(_w("date"), date_iso)
runs = [child for child in list(p) if child.tag == _w("r")]
if runs:
for r in runs:
p.remove(r)
ins = _wrap_in_ins(runs, ins_id=ins_id, author=author, date_iso=date_iso)
p.append(ins)
return p
def _mark_runs_as_deleted(paragraph: etree._Element, *, del_id: int,
author: str, date_iso: str) -> None:
"""Convert all <w:r> in a paragraph to <w:del>-wrapped runs.
Within a <w:del>, <w:t> must become <w:delText>.
"""
runs = [child for child in list(paragraph) if child.tag == _w("r")]
if not runs:
return
# Convert <w:t> → <w:delText> inside each run
for r in runs:
for t in r.findall(_w("t")):
t.tag = _w("delText")
paragraph.remove(r)
wrapper = etree.Element(_w("del"))
wrapper.set(_w("id"), str(del_id))
wrapper.set(_w("author"), author)
wrapper.set(_w("date"), date_iso)
for r in runs:
wrapper.append(r)
paragraph.append(wrapper)
# ── Revision application ───────────────────────────────────────────
def _apply_insert(
document_tree: etree._Element,
revision: Revision,
*,
ins_id: int,
author: str,
date_iso: str,
) -> RevisionResult:
"""Apply insert_after / insert_before relative to a bookmark."""
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
if start is None:
return RevisionResult(id=revision.id, status="failed",
error=f"bookmark '{revision.anchor_bookmark}' not found")
# Pick anchor element based on position
if revision.type == "insert_before":
anchor = start
else: # insert_after — default
anchor = end if end is not None else start
enclosing_p = _find_enclosing_paragraph(anchor)
if enclosing_p is None:
return RevisionResult(id=revision.id, status="failed",
error="anchor has no enclosing paragraph")
# Build new tracked paragraph. ins_id for run wrapper, ins_id+1 for mark.
new_p = _make_tracked_paragraph_insert(
revision.content, style=revision.style,
ins_id=ins_id, mark_id=ins_id + 1,
author=author, date_iso=date_iso,
)
parent = enclosing_p.getparent()
if parent is None:
return RevisionResult(id=revision.id, status="failed",
error="enclosing paragraph has no parent")
idx = list(parent).index(enclosing_p)
insert_idx = idx if revision.type == "insert_before" else idx + 1
parent.insert(insert_idx, new_p)
return RevisionResult(id=revision.id, status="applied", ins_id=ins_id)
def _apply_delete(
document_tree: etree._Element,
revision: Revision,
*,
del_id: int,
author: str,
date_iso: str,
) -> RevisionResult:
"""Mark the paragraph enclosed by a bookmark as deleted."""
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
if start is None:
return RevisionResult(id=revision.id, status="failed",
error=f"bookmark '{revision.anchor_bookmark}' not found")
enclosing_p = _find_enclosing_paragraph(start)
if enclosing_p is None:
return RevisionResult(id=revision.id, status="failed",
error="anchor has no enclosing paragraph")
_mark_runs_as_deleted(enclosing_p, del_id=del_id,
author=author, date_iso=date_iso)
return RevisionResult(id=revision.id, status="applied", ins_id=del_id)
def _apply_replace(
document_tree: etree._Element,
revision: Revision,
*,
ins_id: int,
del_id: int,
author: str,
date_iso: str,
) -> RevisionResult:
"""Replace = delete the existing paragraph + insert new one after it."""
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
if start is None:
return RevisionResult(id=revision.id, status="failed",
error=f"bookmark '{revision.anchor_bookmark}' not found")
enclosing_p = _find_enclosing_paragraph(start)
if enclosing_p is None:
return RevisionResult(id=revision.id, status="failed",
error="anchor has no enclosing paragraph")
parent = enclosing_p.getparent()
if parent is None:
return RevisionResult(id=revision.id, status="failed",
error="enclosing paragraph has no parent")
new_p = _make_tracked_paragraph_insert(
revision.content, style=revision.style,
ins_id=ins_id, mark_id=ins_id + 1,
author=author, date_iso=date_iso,
)
idx = list(parent).index(enclosing_p)
parent.insert(idx + 1, new_p)
_mark_runs_as_deleted(enclosing_p, del_id=del_id,
author=author, date_iso=date_iso)
return RevisionResult(id=revision.id, status="applied", ins_id=ins_id)
# ── Public API ─────────────────────────────────────────────────────
def apply_tracked_revisions(
source_path: str | Path,
output_path: str | Path,
revisions: list[Revision],
*,
author: str = "מערכת AI",
date: datetime | None = None,
) -> RevisionBatchResult:
"""Apply a batch of tracked revisions to a DOCX, producing a new DOCX.
The source file is never mutated. Output is a new DOCX with <w:ins> /
<w:del> markers that Word renders as Track Changes (Accept/Reject).
Args:
source_path: existing DOCX (e.g. עריכה-v1.docx) — retains user edits.
output_path: where to write the revised DOCX (e.g. טיוטה-v6.docx).
revisions: list of Revision objects. Anchors are bookmark names.
author: displayed as the revision author in Word.
date: revision timestamp (defaults to now, UTC).
Returns:
RevisionBatchResult with per-revision status.
"""
source_path = Path(source_path)
output_path = Path(output_path)
if date is None:
date = datetime.now(timezone.utc)
date_iso = date.strftime("%Y-%m-%dT%H:%M:%SZ")
members, doc_tree, settings_tree = _load_docx_xml(source_path)
_ensure_track_revisions(settings_tree)
next_id = _next_revision_id(doc_tree)
batch = RevisionBatchResult()
for rev in revisions:
try:
if rev.type in ("insert_after", "insert_before"):
result = _apply_insert(doc_tree, rev, ins_id=next_id,
author=author, date_iso=date_iso)
# insert consumes 2 IDs: run-wrapper + paragraph-mark
next_id += 2
elif rev.type == "delete":
result = _apply_delete(doc_tree, rev, del_id=next_id,
author=author, date_iso=date_iso)
next_id += 1
elif rev.type == "replace":
result = _apply_replace(doc_tree, rev,
ins_id=next_id, del_id=next_id + 2,
author=author, date_iso=date_iso)
# replace consumes 3 IDs: ins-run, ins-mark, del
next_id += 3
else:
result = RevisionResult(id=rev.id, status="failed",
error=f"unknown type: {rev.type}")
except Exception as e: # pragma: no cover - defensive
logger.exception("revision %s failed", rev.id)
result = RevisionResult(id=rev.id, status="failed", error=str(e))
batch.results.append(result)
if result.status == "applied":
batch.applied += 1
else:
batch.failed += 1
_save_docx_xml(members, doc_tree, settings_tree, output_path)
batch.output_path = str(output_path)
logger.info("applied %d revisions (failed %d) → %s",
batch.applied, batch.failed, output_path)
return batch
def list_bookmarks(docx_path: str | Path) -> list[str]:
"""Return bookmark names present in the DOCX (excluding '_' internal ones)."""
docx_path = Path(docx_path)
members, doc_tree, _ = _load_docx_xml(docx_path)
names: list[str] = []
for el in doc_tree.iterfind(".//w:bookmarkStart", NSMAP):
name = el.get(_w("name"))
if name and not name.startswith("_"):
names.append(name)
return names
def copy_with_revisions(
source_path: str | Path, output_path: str | Path,
) -> None:
"""Copy source → output unchanged (used when revisions list is empty)."""
shutil.copy2(str(source_path), str(output_path))

View File

@@ -1,7 +1,8 @@
"""Text extraction from PDF, DOCX, and RTF files.
"""Text extraction from PDF, DOCX, DOC, and RTF files.
Primary PDF extraction: PyMuPDF direct text (for born-digital PDFs).
Fallback: Google Cloud Vision OCR (for scanned documents).
DOC files: converted to DOCX via LibreOffice before extraction.
Post-processing: Hebrew abbreviation quote fixer.
"""
@@ -10,6 +11,8 @@ from __future__ import annotations
import asyncio
import logging
import re
import subprocess
import tempfile
from pathlib import Path
import fitz # PyMuPDF
@@ -129,6 +132,8 @@ async def extract_text(file_path: str) -> tuple[str, int]:
return await _extract_pdf(path)
elif suffix == ".docx":
return _extract_docx(path), 0
elif suffix == ".doc":
return _extract_doc(path), 0
elif suffix == ".rtf":
return _extract_rtf(path), 0
elif suffix in (".txt", ".md"):
@@ -187,6 +192,21 @@ def _ocr_with_google_vision(image_bytes: bytes, page_num: int) -> str:
return _fix_hebrew_quotes(text)
def _extract_doc(path: Path) -> str:
"""Extract text from legacy .doc file by converting to .docx via LibreOffice."""
with tempfile.TemporaryDirectory() as tmp_dir:
result = subprocess.run(
["libreoffice", "--headless", "--convert-to", "docx", str(path), "--outdir", tmp_dir],
capture_output=True, text=True, timeout=120,
)
if result.returncode != 0:
raise RuntimeError(f"LibreOffice conversion failed: {result.stderr}")
docx_path = Path(tmp_dir) / f"{path.stem}.docx"
if not docx_path.exists():
raise FileNotFoundError(f"Converted file not found: {docx_path}")
return _extract_docx(docx_path)
def _extract_docx(path: Path) -> str:
"""Extract text from DOCX file."""
doc = DocxDocument(str(path))
@@ -198,3 +218,30 @@ def _extract_rtf(path: Path) -> str:
"""Extract text from RTF file."""
rtf_content = path.read_text(encoding="utf-8", errors="replace")
return rtf_to_text(rtf_content)
# ── Nevo preamble stripping ──────────────────────────────────────
_NEVO_MARKERS = ("ספרות:", "חקיקה שאוזכרה:", "מיני-רציו:", "פסקי דין שאוזכרו:",
"כתבי עת:", "הועתק מנבו")
_DECISION_START = re.compile(
r"^(בפנינו|לפנינו|הערר שבנדון|ועדת הערר לתכנון|רקע עובדתי|עסקינן)",
re.MULTILINE,
)
def strip_nevo_preamble(text: str) -> str:
"""Remove Nevo database preamble (bibliography, legislation, mini-ratio) from decision text.
Returns the original text unchanged if no preamble is detected.
"""
head = text[:400]
if not any(marker in head for marker in _NEVO_MARKERS):
return text
m = _DECISION_START.search(text)
if m and m.start() > 50:
stripped = text[m.start():]
logger.debug("Stripped %d chars of Nevo preamble", m.start())
return stripped
return text

View File

@@ -72,9 +72,14 @@ OPENING_STRATEGIES = {
),
},
"betterment_levy": {
"style": "direct_with_disclaimer",
"style": "direct_factual",
"paragraphs": (1, 3),
"description": "פתיחה ישירה עם מסקנה + 'על מנת לא לצאת בחסר'",
"description": (
"פתיחה ישירה ועובדתית: 'בפנינו ערר על דרישת תשלום היטל השבחה מיום [תאריך] "
"בסך של [סכום] ₪' → רקע קצר (נכס, תכנית משביחה, מימוש) → "
"תמצית טענות הצדדים (עוררים + משיבה בנפרד). "
"אין הקשר תכנוני רחב. הפתיחה = עובדות בלבד."
),
},
}
@@ -101,9 +106,16 @@ SUMMARY_STRATEGIES = {
),
},
"betterment_levy": {
"heading": "סיכום",
"format": "numbered_hebrew_dry",
"description": "אותיות עבריות, סיום יבש ללא פסקה חמה",
"heading": "various",
"format": "dry_operative",
"description": (
"סיום יבש ואופרטיבי. כותרת משתנה: 'סוף דבר' / 'לאור כל האמור לעיל' / ללא כותרת. "
"תוכן: 'הערר נדחה/מתקבל' + הוצאות ('כל צד ישא בהוצאותיו' / חיוב בסכום). "
"אם מתקבל: הוראות אופרטיביות (החזר, שומה מתוקנת, תנאים). "
"חתימה: 'ניתנה פה אחד היום, [תאריך עברי], [תאריך לועזי].' "
"לעיתים: 'התיק ייסגר.' / 'עומדת זכות ערר כדין.' "
"אין פסקה חמה. אין חזרה על נימוקים."
),
},
}
@@ -129,7 +141,12 @@ DISCUSSION_RULES: dict[str, list[str]] = {
"מבנה ישיר: נקודות עיקריות → ניתוח → מסקנה.",
],
"betterment_levy": [
"מבנה ישיר עם מסקנה מוקדמת + 'על מנת לא לצאת בחסר' לנקודות נוספות.",
"פתיחת דיון: מסקנה מוקדמת ('לאחר שבחנו... מצאנו כי דין הערר להידחות/להתקבל').",
"תקן ביקורת: ציון רף ההתערבות בשומה מכרעת (בר\"ם 3644/13 גלר) — אבחנה בין שמאי למשפטי.",
"הצגת הלכה פסוקה: ציטוט ארוך מפס\"ד מרכזי → 'ברוח הדברים לעיל נבחן את טענות הצדדים'.",
"טיפול שיטתי: כל טענה/סוגיה בנפרד → ניתוח → מסקנת ביניים.",
"ביטויים: 'אין בידינו לקבל', 'לא מצאנו מקום להתערב', 'קביעה נכונה שאין מקום להתערב בה'.",
"'על מנת לא לצאת בחסר' — לנקודות obiter dicta בסוף הדיון.",
],
}
@@ -448,26 +465,41 @@ CONTENT_CHECKLISTS: dict[str, str] = {
""",
"betterment_levy": """## צ'קליסט תוכן — ערר היטל השבחה
⚠️ שים לב: אין עדיין החלטות היטל השבחה בקורפוס האימון.
הצ'קליסט הזה מבוסס על ידע כללי — לא על ניתוח ספציפי של סגנון דפנה.
מבוסס על ניתוח 26 החלטות של דפנה תמיר (קורפוס CMPA, אפריל 2026).
### א. המסגרת הנורמטיבית
### א. תקן ביקורת (חובה בפתיחת הדיון)
- ציין את רף ההתערבות: "ועדת הערר תיטה לאמץ את חוות דעתו של השמאי..."
- אבחנה: התערבות מצומצמת בעניינים שמאיים-מקצועיים, התערבות רחבה בעניינים משפטיים
- הפניה ל-בר"ם 3644/13 גלר או פסיקה דומה
### ב. המסגרת הנורמטיבית
- התוספת השלישית לחוק התכנון והבנייה
- אירוע מס — מה יצר את ההשבחה?
- סעיפי הפטור הרלוונטיים (ס' 19(ג), ס' 19(ב) וכו')
- אירוע מס — מה יצר את ההשבחה? (תכנית, היתר, מכר)
- מועד המימוש ומועד הקובע
### ב. שומה
- שיטת השומה (שומה מכרעת / שמאי מייעץ)
- מועד הקובע
- זכויות בנייה — לפני ואחרי
### ג. שומה ומתודולוגיה שמאית
- שיטת השומה (שומה מכרעת / שומה מוסכמת / שמאי מייעץ)
- מבחן השימוש הטוב והיעיל (highest and best use) — מצב קודם ומצב חדש
- זכויות בנייה — לפני ואחרי (אחוזי בנייה, שטחים עיקריים, תמהיל שימושים)
- שווי מקרקעין — מצב קודם ומצב חדש (שיטת השוואה / יחידות תועלת)
- עלויות עודפות (חניה, מטלות ציבוריות, תשתיות)
- מקדמי זמינות, שיעורי הפקעה
### ג. שאלות משפטיות
- פטורים (ס' 19)
- מועדי תשלום
- שיערוך
### ד. שאלות משפטיות (לפי רלוונטיות)
- פטורים — דירת מגורים (ס' 19(ג)(1)), שטח עד 140 מ"ר, תא משפחתי
- מועד מימוש — זיכרון דברים vs הסכם מכר, העברת זכויות
- זהות החייב — בעלים, חוכר, יזם, חברה בבעלות יזם
- מקרקעי ישראל — הסדרים מיוחדים (ס' 21 לתוספת השלישית)
- שומות מוסכמות — תוקף, משמעות, "בלתי נצפה מראש"
- פרשנות תכניות — ייעוד, שימושים מותרים, מדיניות ועדה מקומית
### ד. ניתוח שמאי
- האם השומה תקינה?
- פערים בין השומות
### ה. ניתוח שמאי (כשיש שומה מכרעת)
- האם השומה מבוססת על מסד עובדתי הולם?
- האם השיטה השמאית מקובלת?
- האם ההנחות סבירות והגיוניות?
- טעות מהותית / דופי חמור?
- פגם מינהלי (ניגוד עניינים, משוא פנים)?
""",
}

View File

@@ -43,14 +43,17 @@ SUBTYPES_BY_AREA: dict[str, set[str]] = {
# ── Derivation ─────────────────────────────────────────────────────
_FIRST_DIGIT = re.compile(r"^\s*(\d)")
_APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE = {
"1": "building_permit",
"8": "betterment_levy",
"9": "compensation_197",
}
# Match the case number (last numeric group) in formats like:
# ARAR-25-8126, ARAR-24-01-8007-33, 8126/25, 1170, ערר 1024-25
_CASE_NUM = re.compile(r"(?:ARAR[-\s]*\d{2}[-\s]*(?:\d{2}[-\s]*)?)(\d{4})", re.IGNORECASE)
_PLAIN_NUM = re.compile(r"(\d{4})")
def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA) -> str:
"""Infer the appeal_subtype from case_number.
@@ -58,15 +61,20 @@ def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA)
For appeals_committee, the convention is:
1xxx → building_permit, 8xxx → betterment_levy, 9xxx → compensation_197.
For other practice areas there is no public numbering convention yet,
so we return 'unknown' until a real rule is defined.
Handles multiple formats: ARAR-25-8126, 8126/25, 1170, ערר 1024-25.
"""
if practice_area != "appeals_committee":
return "unknown"
m = _FIRST_DIGIT.match(case_number or "")
cn = case_number or ""
# Try ARAR format first (extracts the 4-digit case number after year prefix)
m = _CASE_NUM.search(cn)
if not m:
# Fallback: first 4-digit number in the string
m = _PLAIN_NUM.search(cn)
if not m:
return "unknown"
return _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE.get(m.group(1), "unknown")
first_digit = m.group(1)[0]
return _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE.get(first_digit, "unknown")
# ── Validation ─────────────────────────────────────────────────────

View File

@@ -109,22 +109,33 @@ SYNTHESIS_PROMPT = """\
"""
async def analyze_corpus() -> dict:
async def analyze_corpus(appeal_subtype: str = "") -> dict:
"""Analyze the style corpus and extract/update patterns.
Args:
appeal_subtype: filter by appeal subtype (e.g. 'betterment_levy', 'building_permit').
Empty string = all decisions.
Returns summary of patterns found.
"""
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT full_text, decision_number FROM style_corpus ORDER BY decision_date DESC LIMIT 20"
)
if appeal_subtype:
rows = await conn.fetch(
"SELECT full_text, decision_number FROM style_corpus "
"WHERE appeal_subtype = $1 ORDER BY decision_date DESC LIMIT 20",
appeal_subtype,
)
else:
rows = await conn.fetch(
"SELECT full_text, decision_number FROM style_corpus ORDER BY decision_date DESC LIMIT 20"
)
if not rows:
return {"error": "אין החלטות בקורפוס. העלה החלטות קודמות תחילה."}
# Clear old patterns before re-analysis
await db.clear_style_patterns()
# Clear old patterns for this subtype (or all if unfiltered)
await db.clear_style_patterns(appeal_subtype)
# Calculate token budget
total_chars = sum(len(row["full_text"]) for row in rows)
@@ -136,12 +147,12 @@ async def analyze_corpus() -> dict:
)
if estimated_tokens < MAX_INPUT_TOKENS:
return await _analyze_single_pass(rows)
return await _analyze_single_pass(rows, appeal_subtype)
else:
return await _analyze_multi_pass(rows)
return await _analyze_multi_pass(rows, appeal_subtype)
async def _analyze_single_pass(rows) -> dict:
async def _analyze_single_pass(rows, appeal_subtype: str = "") -> dict:
"""Send all decisions in a single API call."""
decisions_text = ""
for row in rows:
@@ -153,10 +164,10 @@ async def _analyze_single_pass(rows) -> dict:
timeout=claude_session.LONG_TIMEOUT,
)
return await _parse_and_store_patterns(raw, len(rows))
return await _parse_and_store_patterns(raw, len(rows), appeal_subtype)
async def _analyze_multi_pass(rows) -> dict:
async def _analyze_multi_pass(rows, appeal_subtype: str = "") -> dict:
"""Analyze each decision individually, then synthesize patterns."""
all_patterns = []
@@ -186,7 +197,7 @@ async def _analyze_multi_pass(rows) -> dict:
timeout=claude_session.LONG_TIMEOUT,
)
return await _parse_and_store_patterns(raw, len(rows))
return await _parse_and_store_patterns(raw, len(rows), appeal_subtype)
def _extract_json(response_text: str) -> list | None:
@@ -237,14 +248,16 @@ def _extract_json(response_text: str) -> list | None:
return None
async def _parse_and_store_patterns(response_text: str, num_decisions: int) -> dict:
async def _parse_and_store_patterns(
response_text: str, num_decisions: int, appeal_subtype: str = "",
) -> dict:
"""Parse Claude's response and store patterns in the database."""
patterns = _extract_json(response_text)
if patterns is None:
return {"error": "Could not parse analysis results", "raw": response_text}
# Store patterns
# Store patterns tagged by appeal_subtype
count = 0
for pattern in patterns:
await db.upsert_style_pattern(
@@ -252,11 +265,13 @@ async def _parse_and_store_patterns(response_text: str, num_decisions: int) -> d
pattern_text=pattern.get("text", ""),
context=pattern.get("context", ""),
examples=[pattern.get("example", "")],
appeal_subtype=appeal_subtype,
)
count += 1
return {
"patterns_found": count,
"decisions_analyzed": num_decisions,
"appeal_subtype": appeal_subtype or "all",
"pattern_types": list({p.get("type") for p in patterns}),
}

View File

@@ -3,14 +3,107 @@
from __future__ import annotations
import json
import logging
import os
import shutil
import subprocess
from pathlib import Path
from uuid import UUID
import httpx
from legal_mcp import config
from legal_mcp.services import audit, db, practice_area as pa
logger = logging.getLogger(__name__)
GITEA_ORG = "cases"
def _gitea_host() -> str:
return os.environ.get("GITEA_HOST", "https://gitea.nautilus.marcusgroup.org")
def _gitea_token() -> str:
return os.environ.get("GITEA_ACCESS_TOKEN") or os.environ.get("GITEA_TOKEN", "")
async def _setup_gitea_remote(case_number: str, title: str, case_dir: Path) -> bool:
"""Create Gitea repo and configure git remote. Best-effort — returns False on failure."""
token = _gitea_token()
if not token:
logger.info("No GITEA_TOKEN — skipping Gitea repo creation for %s", case_number)
return False
try:
async with httpx.AsyncClient(verify=False, timeout=30) as client:
resp = await client.post(
f"{_gitea_host()}/api/v1/orgs/{GITEA_ORG}/repos",
headers={"Authorization": f"token {token}"},
json={
"name": case_number,
"description": f"ערר {case_number}{title}"[:255],
"private": True,
"auto_init": False,
},
)
if resp.status_code == 409:
resp2 = await client.get(
f"{_gitea_host()}/api/v1/repos/{GITEA_ORG}/{case_number}",
headers={"Authorization": f"token {token}"},
)
resp2.raise_for_status()
repo = resp2.json()
else:
resp.raise_for_status()
repo = resp.json()
clone_url = repo.get("clone_url", "")
if not clone_url:
return False
auth_url = clone_url.replace("https://", f"https://chaim:{token}@")
git_env = {
"GIT_AUTHOR_NAME": "Ezer Mishpati",
"GIT_AUTHOR_EMAIL": "legal@local",
"GIT_COMMITTER_NAME": "Ezer Mishpati",
"GIT_COMMITTER_EMAIL": "legal@local",
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
}
# Add or update remote
result = subprocess.run(
["git", "remote", "get-url", "origin"],
cwd=case_dir, capture_output=True, text=True,
)
if result.returncode == 0:
subprocess.run(
["git", "remote", "set-url", "origin", auth_url],
cwd=case_dir, capture_output=True, env=git_env,
)
else:
subprocess.run(
["git", "remote", "add", "origin", auth_url],
cwd=case_dir, capture_output=True, env=git_env,
)
# Push
push = subprocess.run(
["git", "push", "-u", "origin", "HEAD"],
cwd=case_dir, capture_output=True, text=True, env=git_env,
)
if push.returncode != 0:
logger.warning("Gitea push failed for %s: %s", case_number, push.stderr)
return False
logger.info("Gitea repo created and pushed for %s", case_number)
return True
except Exception as exc:
logger.warning("Gitea setup failed for %s: %s", case_number, exc)
return False
async def case_create(
case_number: str,
@@ -92,7 +185,7 @@ async def case_create(
case_dir.mkdir(parents=True, exist_ok=True)
docs_dir = case_dir / "documents"
docs_dir.mkdir(exist_ok=True)
(docs_dir / "original").mkdir(exist_ok=True)
(docs_dir / "originals").mkdir(exist_ok=True)
(docs_dir / "extracted").mkdir(exist_ok=True)
(docs_dir / "proofread").mkdir(exist_ok=True)
(docs_dir / "backup").mkdir(exist_ok=True)
@@ -106,17 +199,26 @@ async def case_create(
notes_file = case_dir / "notes.md"
notes_file.write_text(f"# הערות - תיק {case_number}\n\n{notes}\n")
# Initialize git repo
subprocess.run(["git", "init"], cwd=case_dir, capture_output=True)
subprocess.run(["git", "add", "."], cwd=case_dir, capture_output=True)
subprocess.run(
["git", "commit", "-m", f"אתחול תיק {case_number}: {title}"],
cwd=case_dir,
capture_output=True,
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
"PATH": "/usr/bin:/bin"},
)
# Initialize git repo (best-effort)
try:
subprocess.run(["git", "init"], cwd=case_dir, capture_output=True)
subprocess.run(["git", "add", "."], cwd=case_dir, capture_output=True)
subprocess.run(
["git", "commit", "-m", f"אתחול תיק {case_number}: {title}"],
cwd=case_dir,
capture_output=True,
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
"PATH": "/usr/bin:/bin"},
)
except Exception:
pass # git not available — non-critical
# Create Gitea repo and configure remote (best-effort)
try:
await _setup_gitea_remote(case_number, title, case_dir)
except Exception:
pass # Gitea not available — non-critical
return json.dumps(case, default=str, ensure_ascii=False, indent=2)
@@ -175,13 +277,27 @@ async def case_update(
"""
from datetime import date as date_type
# Ordered workflow statuses — regression protection
STATUS_ORDER = [
"new", "uploading", "processing", "documents_ready",
"analyst_verified", "research_complete", "outcome_set",
"brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing",
"drafting", "qa_review", "drafted",
"exported", "reviewed", "final",
]
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
fields = {}
if status:
fields["status"] = status
current = case.get("status", "")
cur_idx = STATUS_ORDER.index(current) if current in STATUS_ORDER else -1
new_idx = STATUS_ORDER.index(status) if status in STATUS_ORDER else -1
# Only update if advancing or status is unknown to the order
if new_idx >= cur_idx or new_idx == -1:
fields["status"] = status
if title:
fields["title"] = title
if subject:
@@ -199,20 +315,23 @@ async def case_update(
updated = await db.update_case(UUID(case["id"]), **fields)
# Git commit the update
case_dir = config.find_case_dir(case_number)
if case_dir.exists():
case_json = case_dir / "case.json"
case_json.write_text(json.dumps(updated, default=str, ensure_ascii=False, indent=2))
subprocess.run(["git", "add", "case.json"], cwd=case_dir, capture_output=True)
subprocess.run(
["git", "commit", "-m", f"עדכון תיק: {', '.join(fields.keys())}"],
cwd=case_dir,
capture_output=True,
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
"PATH": "/usr/bin:/bin"},
)
# Git commit the update (best-effort)
try:
case_dir = config.find_case_dir(case_number)
if case_dir.exists():
case_json = case_dir / "case.json"
case_json.write_text(json.dumps(updated, default=str, ensure_ascii=False, indent=2))
subprocess.run(["git", "add", "case.json"], cwd=case_dir, capture_output=True)
subprocess.run(
["git", "commit", "-m", f"עדכון תיק: {', '.join(fields.keys())}"],
cwd=case_dir,
capture_output=True,
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
"PATH": "/usr/bin:/bin"},
)
except Exception:
pass # git not available — non-critical
return json.dumps(updated, default=str, ensure_ascii=False, indent=2)

View File

@@ -67,31 +67,34 @@ async def document_upload(
await db.update_document(UUID(doc["id"]), doc_type=classified_type)
doc["doc_type"] = classified_type
# Git commit
repo_dir = config.find_case_dir(case_number)
if repo_dir.exists():
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
doc_type_hebrew = {
"appeal": "כתב ערר",
"response": "תשובה",
"protocol": "פרוטוקול",
"plan": "תכנית",
"permit": "היתר",
"court_decision": "פסק דין",
"decision": "החלטה",
"appraisal": "שומה",
"objection": "התנגדות",
"exhibit": "נספח",
"reference": "מסמך עזר",
}.get(actual_doc_type, actual_doc_type)
subprocess.run(
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {title}"],
cwd=repo_dir,
capture_output=True,
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
"PATH": "/usr/bin:/bin"},
)
# Git commit (best-effort — don't fail upload on git errors)
try:
repo_dir = config.find_case_dir(case_number)
if repo_dir.exists():
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
doc_type_hebrew = {
"appeal": "כתב ערר",
"response": "תשובה",
"protocol": "פרוטוקול",
"plan": "תכנית",
"permit": "היתר",
"court_decision": "פסק דין",
"decision": "החלטה",
"appraisal": "שומה",
"objection": "התנגדות",
"exhibit": "נספח",
"reference": "מסמך עזר",
}.get(actual_doc_type, actual_doc_type)
subprocess.run(
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {title}"],
cwd=repo_dir,
capture_output=True,
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
"PATH": "/usr/bin:/bin"},
)
except Exception:
pass # git not available in container — non-critical
return json.dumps({
"document": doc,
@@ -136,14 +139,22 @@ async def document_upload_training(
appeal_subtype = pa.derive_subtype(decision_number, practice_area)
pa.validate(practice_area, appeal_subtype)
# Copy to training directory (skip if already there)
config.TRAINING_DIR.mkdir(parents=True, exist_ok=True)
dest = config.TRAINING_DIR / source.name
# Copy to training directory, organized by subtype
_SUBTYPE_DIRS = {
"betterment_levy": "cmpa",
"compensation_197": "cmpa",
"building_permit": "cmp",
}
subdir = _SUBTYPE_DIRS.get(appeal_subtype, "")
training_dest = config.TRAINING_DIR / subdir if subdir else config.TRAINING_DIR
training_dest.mkdir(parents=True, exist_ok=True)
dest = training_dest / source.name
if source.resolve() != dest.resolve():
shutil.copy2(str(source), str(dest))
# Extract text
# Extract text and strip Nevo preamble
text, page_count = await extractor.extract_text(str(dest))
text = extractor.strip_nevo_preamble(text)
# Parse date
d_date = None
@@ -171,11 +182,12 @@ async def document_upload_training(
title=f"[קורפוס] {title}",
file_path=str(dest),
page_count=page_count,
practice_area=practice_area,
appeal_subtype=appeal_subtype,
)
doc_id = UUID(doc["id"])
await db.update_document(doc_id, extracted_text=text, extraction_status="completed")
await db.update_document(
doc_id, extracted_text=text, extraction_status="completed",
metadata={"practice_area": practice_area, "appeal_subtype": appeal_subtype},
)
# Generate embeddings and store chunks
texts = [c.content for c in chunks]
@@ -190,10 +202,7 @@ async def document_upload_training(
}
for c, emb in zip(chunks, embs)
]
await db.store_chunks(
doc_id, None, chunk_dicts,
practice_area=practice_area, appeal_subtype=appeal_subtype,
)
await db.store_chunks(doc_id, None, chunk_dicts)
return json.dumps({
"corpus_id": str(corpus_id),

View File

@@ -384,6 +384,9 @@ async def validate_decision(case_number: str) -> str:
async def export_docx(case_number: str, output_path: str = "") -> str:
"""ייצוא החלטה לקובץ DOCX מעוצב — גופן David, RTL, כותרות, מספור סעיפים.
הקובץ נוצר עם bookmarks ב-12 הבלוקים (אנקורים ל-revisions עתידיים),
ומסומן כ-active_draft_path של התיק.
Args:
case_number: מספר תיק הערר
output_path: נתיב לשמירה (אופציונלי — ברירת מחדל: תיקיית התיק)
@@ -398,9 +401,12 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
try:
path = await docx_exporter.export_decision(case_id, output_path or None)
# Register this export as the new source of truth
await db.set_active_draft_path(case_id, path)
return json.dumps({
"status": "completed",
"path": path,
"active_draft_path": path,
"message": f"DOCX נוצר: {path}",
}, ensure_ascii=False, indent=2)
except ValueError as e:
@@ -410,6 +416,163 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
}, ensure_ascii=False, indent=2)
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
"""רישום עריכה שהעלה המשתמש כמקור האמת החדש של התיק.
התהליך:
1. מאתר את הקובץ `עריכה-v*.docx` בתיקיית ה-exports
2. מזריק bookmarks רטרואקטיבית (אם אין) דרך docx_retrofit
3. מעדכן את cases.active_draft_path
Args:
case_number: מספר תיק הערר
edit_filename: שם הקובץ (למשל "עריכה-v1.docx") או נתיב מלא
"""
from legal_mcp.services import docx_retrofit
case = await db.get_case_by_number(case_number)
if not case:
return json.dumps({"status": "error",
"message": f"תיק {case_number} לא נמצא."},
ensure_ascii=False, indent=2)
case_id = UUID(case["id"])
export_dir = config.find_case_dir(case_number) / "exports"
edit_path = export_dir / edit_filename if "/" not in edit_filename else Path(edit_filename)
if not edit_path.exists():
return json.dumps({"status": "error",
"message": f"קובץ לא נמצא: {edit_path}"},
ensure_ascii=False, indent=2)
try:
retrofit_result = docx_retrofit.retrofit_bookmarks(edit_path)
await db.set_active_draft_path(case_id, str(edit_path))
return json.dumps({
"status": "completed",
"active_draft_path": str(edit_path),
"bookmarks_added": retrofit_result.get("bookmarks_added", []),
"missing_blocks": retrofit_result.get("missing_blocks", []),
"existing_bookmarks": retrofit_result.get("existing_bookmarks", []),
}, ensure_ascii=False, indent=2)
except Exception as e:
return json.dumps({"status": "error", "message": str(e)},
ensure_ascii=False, indent=2)
async def list_bookmarks(case_number: str) -> str:
"""רשימת bookmarks הקיימים ב-active_draft של התיק.
משמש לסוכנים כדי לדעת אילו אנקורים זמינים לפני שליחת revisions.
"""
from legal_mcp.services import docx_reviser
case = await db.get_case_by_number(case_number)
if not case:
return json.dumps({"status": "error",
"message": f"תיק {case_number} לא נמצא."},
ensure_ascii=False, indent=2)
active_path = await db.get_active_draft_path(UUID(case["id"]))
if not active_path or not Path(active_path).exists():
return json.dumps({"status": "no_active_draft",
"message": "לא נמצא active_draft. הרץ ייצוא או העלה עריכה."},
ensure_ascii=False, indent=2)
try:
names = docx_reviser.list_bookmarks(active_path)
return json.dumps({
"status": "completed",
"active_draft_path": active_path,
"bookmarks": names,
}, ensure_ascii=False, indent=2)
except Exception as e:
return json.dumps({"status": "error", "message": str(e)},
ensure_ascii=False, indent=2)
async def revise_draft(case_number: str, revisions_json: str,
author: str = "מערכת AI") -> str:
"""החלת revisions מסומנים כ-Track Changes על ה-active_draft של התיק.
יוצר קובץ חדש `טיוטה-v{N+1}.docx` (מגרסה הבאה בתור), ומעדכן את
active_draft_path אליו.
Args:
case_number: מספר תיק הערר
revisions_json: JSON string של array עם אובייקטים:
[{"id": "r1", "type": "insert_after"|"insert_before"|"replace"|"delete",
"anchor_bookmark": "block-yod", "content": "...", "style": "body"|"heading"|"quote",
"reason": "..."}, ...]
author: מחרוזת המחבר שתופיע ב-Track Changes
"""
from legal_mcp.services import docx_reviser
case = await db.get_case_by_number(case_number)
if not case:
return json.dumps({"status": "error",
"message": f"תיק {case_number} לא נמצא."},
ensure_ascii=False, indent=2)
case_id = UUID(case["id"])
active_path = await db.get_active_draft_path(case_id)
if not active_path or not Path(active_path).exists():
return json.dumps({"status": "error",
"message": "אין active_draft. הרץ ייצוא או apply_user_edit קודם."},
ensure_ascii=False, indent=2)
try:
raw = json.loads(revisions_json) if isinstance(revisions_json, str) else revisions_json
except json.JSONDecodeError as e:
return json.dumps({"status": "error", "message": f"JSON לא תקף: {e}"},
ensure_ascii=False, indent=2)
revisions = []
for item in raw:
revisions.append(docx_reviser.Revision(
id=item.get("id", ""),
type=item["type"],
anchor_bookmark=item["anchor_bookmark"],
content=item.get("content", ""),
style=item.get("style", "body"),
reason=item.get("reason", ""),
anchor_position=item.get("anchor_position", "end"),
))
# Determine output path — next טיוטה-v{N}.docx
export_dir = config.find_case_dir(case_number) / "exports"
export_dir.mkdir(parents=True, exist_ok=True)
existing = list(export_dir.glob("טיוטה-v*.docx"))
next_ver = 1
for p in existing:
try:
ver = int(p.stem.split("-v")[1])
next_ver = max(next_ver, ver + 1)
except (IndexError, ValueError):
pass
output_path = export_dir / f"טיוטה-v{next_ver}.docx"
try:
result = docx_reviser.apply_tracked_revisions(
active_path, output_path, revisions, author=author,
)
await db.set_active_draft_path(case_id, str(output_path))
return json.dumps({
"status": "completed",
"output_path": str(output_path),
"version": next_ver,
"applied": result.applied,
"failed": result.failed,
"active_draft_path": str(output_path),
"results": [
{"id": r.id, "status": r.status, "error": r.error}
for r in result.results
],
}, ensure_ascii=False, indent=2)
except Exception as e:
return json.dumps({"status": "error", "message": str(e)},
ensure_ascii=False, indent=2)
async def get_block_context(case_number: str, block_id: str, instructions: str = "") -> str:
"""קבלת הקשר מלא לכתיבת בלוק — ללא קריאה ל-API. Claude Code כותב את הבלוק.
@@ -454,11 +617,16 @@ async def save_block_content(case_number: str, block_id: str, content: str) -> s
return str(e)
async def analyze_style() -> str:
"""הרצת ניתוח סגנון על קורפוס ההחלטות של דפנה. מחלץ דפוסי כתיבה ושומר אותם."""
async def analyze_style(appeal_subtype: str = "") -> str:
"""הרצת ניתוח סגנון על קורפוס ההחלטות של דפנה. מחלץ דפוסי כתיבה ושומר אותם.
Args:
appeal_subtype: סינון לפי סוג ערר (building_permit / betterment_levy / compensation_197).
ריק = כל ההחלטות.
"""
from legal_mcp.services.style_analyzer import analyze_corpus
result = await analyze_corpus()
result = await analyze_corpus(appeal_subtype)
return json.dumps(result, ensure_ascii=False, indent=2)

View File

@@ -52,7 +52,7 @@ async def precedent_attach(
pdf_document_id=pdf_uuid,
practice_area=case.get("practice_area"),
)
return json.dumps(row, ensure_ascii=False, indent=2)
return json.dumps(row, ensure_ascii=False, indent=2, default=str)
async def precedent_list(case_number: str) -> str:
@@ -62,7 +62,7 @@ async def precedent_list(case_number: str) -> str:
return json.dumps({"error": f"תיק {case_number} לא נמצא."}, ensure_ascii=False)
rows = await db.list_case_precedents(UUID(case["id"]))
return json.dumps(rows, ensure_ascii=False, indent=2)
return json.dumps(rows, ensure_ascii=False, indent=2, default=str)
async def precedent_remove(precedent_id: str) -> str:
@@ -92,4 +92,4 @@ async def precedent_search_library(
return json.dumps([], ensure_ascii=False)
rows = await db.search_precedent_library(query.strip(), practice_area, limit)
return json.dumps(rows, ensure_ascii=False, indent=2)
return json.dumps(rows, ensure_ascii=False, indent=2, default=str)

View File

View File

@@ -0,0 +1,103 @@
"""בדיקות ל-bookmark helpers ב-docx_exporter.
הבדיקות מתרכזות ב-helper functions בלבד (לא בכל ה-export flow שדורש DB).
"""
from __future__ import annotations
import zipfile
from pathlib import Path
from docx import Document
from lxml import etree
from legal_mcp.services.docx_exporter import (
_BOOKMARK_ID_START,
_insert_bookmark_end,
_insert_bookmark_start,
_wrap_block_with_bookmarks,
)
from legal_mcp.services.docx_reviser import NSMAP, _w, list_bookmarks
def test_insert_bookmark_helpers_create_valid_xml(tmp_path: Path) -> None:
doc = Document()
p = doc.add_paragraph("תוכן בלוק י")
_insert_bookmark_start(p, "block-yod", 10001)
_insert_bookmark_end(p, 10001)
out = tmp_path / "out.docx"
doc.save(str(out))
# Verify via list_bookmarks (uses the same XML)
assert list_bookmarks(out) == ["block-yod"]
def test_wrap_block_with_bookmarks_wraps_multiple_paragraphs(tmp_path: Path) -> None:
doc = Document()
doc.add_paragraph("ראשון — לפני") # noise before
bm_counter = [_BOOKMARK_ID_START]
def writer() -> None:
doc.add_paragraph("בלוק — פסקה 1")
doc.add_paragraph("בלוק — פסקה 2")
doc.add_paragraph("בלוק — פסקה 3")
_wrap_block_with_bookmarks(doc, "block-yod", writer, bm_counter)
doc.add_paragraph("אחרי — אחרון") # noise after
out = tmp_path / "out.docx"
doc.save(str(out))
# The bookmark should wrap exactly the 3 middle paragraphs
with zipfile.ZipFile(out, "r") as zf:
tree = etree.fromstring(zf.read("word/document.xml"))
paragraphs = tree.findall(".//w:p", NSMAP)
# Find para index of bookmarkStart and bookmarkEnd
start_idx = end_idx = None
for i, p in enumerate(paragraphs):
if p.find(".//w:bookmarkStart", NSMAP) is not None:
start_idx = i
if p.find(".//w:bookmarkEnd", NSMAP) is not None:
end_idx = i
assert start_idx is not None
assert end_idx is not None
# The paragraph containing start must be the first new one ("פסקה 1")
start_text = "".join(paragraphs[start_idx].itertext())
end_text = "".join(paragraphs[end_idx].itertext())
assert "פסקה 1" in start_text
assert "פסקה 3" in end_text
def test_wrap_block_skipped_when_writer_adds_nothing(tmp_path: Path) -> None:
doc = Document()
bm_counter = [_BOOKMARK_ID_START]
_wrap_block_with_bookmarks(doc, "block-empty", lambda: None, bm_counter)
out = tmp_path / "out.docx"
doc.save(str(out))
assert list_bookmarks(out) == []
def test_multiple_blocks_get_unique_bookmark_ids(tmp_path: Path) -> None:
doc = Document()
bm_counter = [_BOOKMARK_ID_START]
for name in ("block-alef", "block-bet", "block-gimel"):
_wrap_block_with_bookmarks(
doc, name,
lambda n=name: doc.add_paragraph(f"תוכן של {n}"),
bm_counter,
)
out = tmp_path / "out.docx"
doc.save(str(out))
with zipfile.ZipFile(out, "r") as zf:
tree = etree.fromstring(zf.read("word/document.xml"))
ids = [el.get(_w("id")) for el in tree.iterfind(".//w:bookmarkStart", NSMAP)]
assert len(ids) == 3
assert len(set(ids)) == 3
names = list_bookmarks(out)
assert set(names) == {"block-alef", "block-bet", "block-gimel"}

View File

@@ -0,0 +1,141 @@
"""בדיקות docx_retrofit — הזרקת bookmarks רטרואקטיבית."""
from __future__ import annotations
from pathlib import Path
from docx import Document
from legal_mcp.services.docx_retrofit import (
BLOCK_ORDER,
retrofit_bookmarks,
)
from legal_mcp.services.docx_reviser import list_bookmarks
def _make_docx_with_hebrew_blocks(path: Path, markers: list[str]) -> None:
"""Create a DOCX where each paragraph starts with a Hebrew block marker."""
doc = Document()
for marker in markers:
doc.add_paragraph(f"{marker}. תוכן הבלוק שמתחיל ב-{marker}")
doc.add_paragraph(f"עוד פסקה בבלוק {marker}")
doc.save(str(path))
def test_retrofit_detects_all_standard_blocks(tmp_path: Path) -> None:
src = tmp_path / "src.docx"
_make_docx_with_hebrew_blocks(
src, ["א", "ב", "ג", "ד", "ה", "ו", "ז", "ח", "ט", "י", "יא", "יב"],
)
result = retrofit_bookmarks(src, backup=False)
assert len(result["bookmarks_added"]) == 12
assert result["missing_blocks"] == []
names = list_bookmarks(src)
expected = {name for name, _ in BLOCK_ORDER}
assert set(names) == expected
def test_retrofit_reports_missing_blocks(tmp_path: Path) -> None:
src = tmp_path / "src.docx"
# Only 4 blocks present
_make_docx_with_hebrew_blocks(src, ["א", "ב", "ג", "ד"])
result = retrofit_bookmarks(src, backup=False)
assert result["bookmarks_added"] == [
"block-alef", "block-bet", "block-gimel", "block-dalet",
]
assert "block-heh" in result["missing_blocks"]
assert "block-yod-bet" in result["missing_blocks"]
def test_retrofit_distinguishes_yod_from_yod_alef_yod_bet(tmp_path: Path) -> None:
"""י, יא, יב must all be distinguished — longer markers win."""
src = tmp_path / "src.docx"
_make_docx_with_hebrew_blocks(src, ["ט", "י", "יא", "יב"])
result = retrofit_bookmarks(src, backup=False)
assert set(result["bookmarks_added"]) == {
"block-tet", "block-yod", "block-yod-alef", "block-yod-bet",
}
def test_retrofit_skips_existing_bookmarks(tmp_path: Path) -> None:
"""Running retrofit twice doesn't duplicate bookmarks."""
src = tmp_path / "src.docx"
_make_docx_with_hebrew_blocks(src, ["א", "ב"])
first = retrofit_bookmarks(src, backup=False)
assert first["bookmarks_added"] == ["block-alef", "block-bet"]
second = retrofit_bookmarks(src, backup=False)
assert second["bookmarks_added"] == [] # nothing new
assert set(second["existing_bookmarks"]) == {"block-alef", "block-bet"}
# Final document should still have exactly 2 bookmarks
assert set(list_bookmarks(src)) == {"block-alef", "block-bet"}
def test_retrofit_creates_backup(tmp_path: Path) -> None:
src = tmp_path / "file.docx"
_make_docx_with_hebrew_blocks(src, ["א", "ב"])
retrofit_bookmarks(src) # backup=True (default)
backup = src.with_suffix(".pre-retrofit.docx")
assert backup.exists()
def test_retrofit_to_different_output_path_no_backup(tmp_path: Path) -> None:
src = tmp_path / "src.docx"
out = tmp_path / "out.docx"
_make_docx_with_hebrew_blocks(src, ["א", "ב"])
retrofit_bookmarks(src, output_path=out)
# source untouched
assert list_bookmarks(src) == []
# output has bookmarks
assert set(list_bookmarks(out)) == {"block-alef", "block-bet"}
def test_retrofit_ignores_marker_in_middle_of_text(tmp_path: Path) -> None:
"""A lone 'י' inside body text (not at start) should not be detected as block."""
src = tmp_path / "src.docx"
doc = Document()
doc.add_paragraph("א. תחילת הבלוק")
doc.add_paragraph("טקסט עם האות י לא בתחילת שורה, זה לא בלוק.")
doc.add_paragraph("ב. בלוק שני")
doc.save(str(src))
result = retrofit_bookmarks(src, backup=False)
assert "block-alef" in result["bookmarks_added"]
assert "block-bet" in result["bookmarks_added"]
# 'block-yod' should NOT be detected
assert "block-yod" not in result["bookmarks_added"]
def test_retrofit_out_of_order_markers_picks_forward_only(tmp_path: Path) -> None:
"""If a later-ordered marker appears first, earlier ones are treated as missing.
Scanner advances forward through BLOCK_ORDER — it won't go back to claim
an earlier marker after already seeing a later one.
"""
src = tmp_path / "src.docx"
doc = Document()
doc.add_paragraph("ב. מופיע ראשון")
doc.add_paragraph("א. מופיע אחרי — יידחה כי 'א' לפני 'ב'")
doc.add_paragraph("ג. בלוק גימל")
doc.save(str(src))
result = retrofit_bookmarks(src, backup=False)
assert "block-bet" in result["bookmarks_added"]
assert "block-gimel" in result["bookmarks_added"]
# 'א' was not detected (the first paragraph was 'ב' — scanner advanced past א)
assert "block-alef" in result["missing_blocks"]
def test_retrofit_empty_document_reports_all_missing(tmp_path: Path) -> None:
src = tmp_path / "empty.docx"
doc = Document()
doc.save(str(src))
result = retrofit_bookmarks(src, backup=False)
assert result["bookmarks_added"] == []
assert len(result["missing_blocks"]) == 12

View File

@@ -0,0 +1,342 @@
"""בדיקות docx_reviser — Track Changes XML surgery.
הבדיקות יוצרות DOCX בסיסי עם bookmarks, מפעילות revisions, ובודקות:
1. שה-XML שנוצר תקף ונטען חזרה כ-Document
2. שה-<w:ins> / <w:del> קיימים בפורמט הנכון
3. שה-bookmarks נשמרים אחרי עריכה
4. שגופן David ו-RTL נשמרים
5. שכשלונות מטופלים אלגנטית (bookmark חסר → failed, לא crash)
"""
from __future__ import annotations
import zipfile
from datetime import datetime, timezone
from io import BytesIO
from pathlib import Path
import pytest
from docx import Document
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from lxml import etree
from legal_mcp.services import docx_reviser
from legal_mcp.services.docx_reviser import (
NSMAP,
Revision,
_w,
apply_tracked_revisions,
list_bookmarks,
)
# ── Test fixtures ──────────────────────────────────────────────────
def _insert_bookmark(paragraph, name: str, bm_id: int) -> None:
"""Insert a <w:bookmarkStart> at the start of a paragraph and a
<w:bookmarkEnd> at the end."""
p_elem = paragraph._p
start = OxmlElement("w:bookmarkStart")
start.set(qn("w:id"), str(bm_id))
start.set(qn("w:name"), name)
p_elem.insert(0, start)
end = OxmlElement("w:bookmarkEnd")
end.set(qn("w:id"), str(bm_id))
p_elem.append(end)
def _make_sample_docx(path: Path) -> None:
"""Create a simple DOCX with 3 paragraphs, each with a bookmark."""
doc = Document()
for idx, name in enumerate(("block-alef", "block-yod", "block-yod-bet")):
p = doc.add_paragraph()
run = p.add_run(f"תוכן פסקה של {name}")
run.font.name = "David"
_insert_bookmark(p, name, idx + 1)
doc.save(str(path))
@pytest.fixture
def sample_docx(tmp_path: Path) -> Path:
path = tmp_path / "source.docx"
_make_sample_docx(path)
return path
# ── list_bookmarks ────────────────────────────────────────────────
def test_list_bookmarks_returns_all_named(sample_docx: Path) -> None:
names = list_bookmarks(sample_docx)
assert set(names) == {"block-alef", "block-yod", "block-yod-bet"}
def test_list_bookmarks_excludes_internal(tmp_path: Path) -> None:
"""Bookmarks starting with '_' (like _GoBack) should be filtered out."""
path = tmp_path / "internal.docx"
doc = Document()
p1 = doc.add_paragraph("visible")
_insert_bookmark(p1, "block-real", 1)
p2 = doc.add_paragraph("hidden")
_insert_bookmark(p2, "_GoBack", 2)
doc.save(str(path))
names = list_bookmarks(path)
assert names == ["block-real"]
# ── apply_tracked_revisions: insert_after ─────────────────────────
def test_insert_after_adds_tracked_paragraph(sample_docx: Path, tmp_path: Path) -> None:
out = tmp_path / "out.docx"
rev = Revision(
id="r1",
type="insert_after",
anchor_bookmark="block-yod",
content="פסקה חדשה שהמערכת מוסיפה.",
)
result = apply_tracked_revisions(
sample_docx, out, [rev],
author="מערכת AI",
date=datetime(2026, 4, 16, 14, 0, tzinfo=timezone.utc),
)
assert result.applied == 1
assert result.failed == 0
assert out.exists()
# Verify <w:ins> present in document.xml
with zipfile.ZipFile(out, "r") as zf:
doc_xml = zf.read("word/document.xml")
tree = etree.fromstring(doc_xml)
ins_elements = tree.findall(".//w:ins", NSMAP)
assert len(ins_elements) >= 1
# Verify the content is there
all_text = "".join(tree.itertext())
assert "פסקה חדשה שהמערכת מוסיפה." in all_text
# Verify original content preserved
assert "תוכן פסקה של block-yod" in all_text
def _find_ins_with_runs(tree: etree._Element) -> etree._Element | None:
"""Pick the <w:ins> that actually wraps runs (not the pilcrow-marker one)."""
for ins in tree.iterfind(".//w:ins", NSMAP):
if ins.find(".//w:r", NSMAP) is not None:
return ins
return None
def test_insert_after_ins_has_author_and_date(sample_docx: Path, tmp_path: Path) -> None:
out = tmp_path / "out.docx"
rev = Revision(id="r1", type="insert_after",
anchor_bookmark="block-alef", content="test")
apply_tracked_revisions(sample_docx, out, [rev], author="דפנה")
with zipfile.ZipFile(out, "r") as zf:
doc_xml = zf.read("word/document.xml")
tree = etree.fromstring(doc_xml)
ins = _find_ins_with_runs(tree)
assert ins is not None
assert ins.get(_w("author")) == "דפנה"
date_str = ins.get(_w("date"))
assert date_str is not None
assert date_str.endswith("Z") # ISO 8601 UTC
def test_insert_after_uses_rtl_and_david(sample_docx: Path, tmp_path: Path) -> None:
out = tmp_path / "out.docx"
rev = Revision(id="r1", type="insert_after",
anchor_bookmark="block-alef", content="מוסף")
apply_tracked_revisions(sample_docx, out, [rev])
with zipfile.ZipFile(out, "r") as zf:
tree = etree.fromstring(zf.read("word/document.xml"))
ins = _find_ins_with_runs(tree)
assert ins is not None
run = ins.find(".//w:r", NSMAP)
assert run is not None
rPr = run.find(_w("rPr"))
assert rPr is not None
assert rPr.find(_w("rtl")) is not None
rFonts = rPr.find(_w("rFonts"))
assert rFonts is not None
assert rFonts.get(_w("ascii")) == "David"
# ── apply_tracked_revisions: insert_before ────────────────────────
def test_insert_before_places_above_anchor(sample_docx: Path, tmp_path: Path) -> None:
out = tmp_path / "out.docx"
rev = Revision(id="r1", type="insert_before",
anchor_bookmark="block-yod", content="לפני י.")
result = apply_tracked_revisions(sample_docx, out, [rev])
assert result.applied == 1
# Order check: new paragraph's text must appear before "block-yod"
with zipfile.ZipFile(out, "r") as zf:
tree = etree.fromstring(zf.read("word/document.xml"))
paragraphs = tree.findall(".//w:p", NSMAP)
texts = ["".join(p.itertext()) for p in paragraphs]
idx_new = next(i for i, t in enumerate(texts) if "לפני י." in t)
idx_yod = next(i for i, t in enumerate(texts) if "תוכן פסקה של block-yod" in t)
assert idx_new < idx_yod
# ── apply_tracked_revisions: delete ───────────────────────────────
def test_delete_wraps_runs_in_w_del(sample_docx: Path, tmp_path: Path) -> None:
out = tmp_path / "out.docx"
rev = Revision(id="r1", type="delete", anchor_bookmark="block-yod", content="")
result = apply_tracked_revisions(sample_docx, out, [rev])
assert result.applied == 1
with zipfile.ZipFile(out, "r") as zf:
tree = etree.fromstring(zf.read("word/document.xml"))
dels = tree.findall(".//w:del", NSMAP)
assert len(dels) >= 1
# Inside w:del, text elements must become w:delText
del_texts = dels[0].findall(".//w:delText", NSMAP)
assert any("block-yod" in (t.text or "") for t in del_texts)
# ── apply_tracked_revisions: replace ─────────────────────────────
def test_replace_creates_both_ins_and_del(sample_docx: Path, tmp_path: Path) -> None:
out = tmp_path / "out.docx"
rev = Revision(id="r1", type="replace",
anchor_bookmark="block-yod", content="תוכן חדש לחלוטין")
result = apply_tracked_revisions(sample_docx, out, [rev])
assert result.applied == 1
with zipfile.ZipFile(out, "r") as zf:
tree = etree.fromstring(zf.read("word/document.xml"))
assert len(tree.findall(".//w:ins", NSMAP)) >= 1
assert len(tree.findall(".//w:del", NSMAP)) >= 1
# ── Failure modes ─────────────────────────────────────────────────
def test_missing_bookmark_returns_failed_not_crash(
sample_docx: Path, tmp_path: Path,
) -> None:
out = tmp_path / "out.docx"
rev = Revision(id="r1", type="insert_after",
anchor_bookmark="does-not-exist", content="x")
result = apply_tracked_revisions(sample_docx, out, [rev])
assert result.applied == 0
assert result.failed == 1
assert result.results[0].status == "failed"
assert "not found" in (result.results[0].error or "")
# Output file still produced (unchanged copy)
assert out.exists()
def test_empty_revisions_list_produces_copy(sample_docx: Path, tmp_path: Path) -> None:
out = tmp_path / "out.docx"
result = apply_tracked_revisions(sample_docx, out, [])
assert result.applied == 0
assert result.failed == 0
assert out.exists()
# bookmarks should still be there
assert set(list_bookmarks(out)) == {"block-alef", "block-yod", "block-yod-bet"}
# ── Track revisions flag in settings ──────────────────────────────
def test_track_revisions_flag_is_enabled(sample_docx: Path, tmp_path: Path) -> None:
out = tmp_path / "out.docx"
rev = Revision(id="r1", type="insert_after",
anchor_bookmark="block-alef", content="x")
apply_tracked_revisions(sample_docx, out, [rev])
with zipfile.ZipFile(out, "r") as zf:
settings_xml = zf.read("word/settings.xml")
settings_tree = etree.fromstring(settings_xml)
tr = settings_tree.find(_w("trackRevisions"))
assert tr is not None
# ── Multiple revisions with unique IDs ────────────────────────────
def test_multiple_revisions_get_unique_ids(sample_docx: Path, tmp_path: Path) -> None:
out = tmp_path / "out.docx"
revs = [
Revision(id="r1", type="insert_after",
anchor_bookmark="block-alef", content="ראשון"),
Revision(id="r2", type="insert_after",
anchor_bookmark="block-yod", content="שני"),
Revision(id="r3", type="delete", anchor_bookmark="block-yod-bet"),
]
result = apply_tracked_revisions(sample_docx, out, revs)
assert result.applied == 3
with zipfile.ZipFile(out, "r") as zf:
tree = etree.fromstring(zf.read("word/document.xml"))
all_ids: list[str] = []
for xpath in (".//w:ins", ".//w:del"):
for el in tree.iterfind(xpath, NSMAP):
wid = el.get(_w("id"))
if wid:
all_ids.append(wid)
assert len(all_ids) == len(set(all_ids)), f"duplicate IDs: {all_ids}"
# ── DOCX remains openable as Document ─────────────────────────────
def test_output_docx_is_openable_by_python_docx(
sample_docx: Path, tmp_path: Path,
) -> None:
out = tmp_path / "out.docx"
rev = Revision(id="r1", type="insert_after",
anchor_bookmark="block-yod", content="תוכן חדש")
apply_tracked_revisions(sample_docx, out, [rev])
# Must be openable as a valid DOCX by python-docx (no exceptions)
doc = Document(str(out))
# Original text is still accessible via python-docx
all_text = "\n".join(p.text for p in doc.paragraphs)
assert "block-yod" in all_text
# Inserted (tracked) text is present in the raw XML via itertext
with zipfile.ZipFile(out, "r") as zf:
tree = etree.fromstring(zf.read("word/document.xml"))
raw_text = "".join(tree.itertext())
assert "תוכן חדש" in raw_text
# ── Bookmarks preserved through revisions ─────────────────────────
def test_bookmarks_preserved_after_insert(sample_docx: Path, tmp_path: Path) -> None:
out = tmp_path / "out.docx"
rev = Revision(id="r1", type="insert_after",
anchor_bookmark="block-yod", content="x")
apply_tracked_revisions(sample_docx, out, [rev])
names = list_bookmarks(out)
assert set(names) == {"block-alef", "block-yod", "block-yod-bet"}
# ── Idempotency of loading/saving without changes ────────────────
def test_save_without_revisions_preserves_content(
sample_docx: Path, tmp_path: Path,
) -> None:
out = tmp_path / "out.docx"
apply_tracked_revisions(sample_docx, out, [])
doc_orig = Document(str(sample_docx))
doc_new = Document(str(out))
orig_text = [p.text for p in doc_orig.paragraphs]
new_text = [p.text for p in doc_new.paragraphs]
assert orig_text == new_text

View File

@@ -0,0 +1,237 @@
"""בדיקות end-to-end לזרימה המלאה: exporter → retrofit → reviser.
הבדיקות האלה מחברות את כל השכבות של ארכיטקטורת Track Changes ומוודאות
שהזרימה עובדת על מסמכים שנוצרו על-ידי ה-exporter עצמו (בלוקים עם bookmarks
מובנים) ועל מסמכים רגילים שעברו retrofit.
"""
from __future__ import annotations
import zipfile
from datetime import datetime, timezone
from pathlib import Path
import pytest
from docx import Document
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from lxml import etree
from legal_mcp.services import docx_retrofit, docx_reviser
from legal_mcp.services.docx_exporter import (
_BOOKMARK_ID_START,
_wrap_block_with_bookmarks,
)
from legal_mcp.services.docx_reviser import (
NSMAP,
Revision,
_w,
apply_tracked_revisions,
list_bookmarks,
)
# ── Helpers ────────────────────────────────────────────────────────
def _make_exporter_style_docx(path: Path) -> None:
"""Simulate what docx_exporter produces: paragraphs wrapped in bookmarks
for each of the 12 blocks, with David font and RTL."""
doc = Document()
bm_counter = [_BOOKMARK_ID_START]
blocks = [
("block-alef", "בפני: דפנה תמיר, יו\"ר ועדת הערר"),
("block-bet", "ערר מספר 1033-25"),
("block-heh", "רקע\nהנכס מצוי ברחוב הר בשן"),
("block-yod", "דיון והכרעה\nלאחר שבחנו את טענות הצדדים"),
("block-yod-bet", "ההחלטה\nהערר מתקבל בחלקו"),
]
for name, content in blocks:
def writer(c=content):
for line in c.split("\n"):
if line.strip():
doc.add_paragraph(line.strip())
_wrap_block_with_bookmarks(doc, name, writer, bm_counter)
doc.save(str(path))
def _make_user_edited_docx(path: Path) -> None:
"""Simulate what a user produces by editing in Word: no bookmarks,
heading-style paragraphs in Daphna style."""
doc = Document()
for text in [
"בפני: דפנה תמיר, יו\"ר ועדת הערר מחוז ירושלים",
"ערר מספר 9999-25",
"רקע",
"הנכס מצוי ברחוב שמואל הנגיד 10, ירושלים",
"תמצית טענות הצדדים",
"העוררים טוענים שהבנייה חורגת מהתכנית",
"תגובת המשיבה",
"הוועדה המקומית טוענת שהבקשה תואמת",
"ההליכים בפני ועדת הערר",
"קיימנו דיון בנוכחות הצדדים",
"דיון והכרעה",
"לאחר שבחנו את טענות הצדדים בחון מעמיק",
"סוף דבר",
"הערר נדחה",
]:
doc.add_paragraph(text)
doc.save(str(path))
# ── Exporter-style (built-in bookmarks) ──────────────────────────
def test_exporter_output_works_with_reviser(tmp_path: Path) -> None:
src = tmp_path / "exported.docx"
_make_exporter_style_docx(src)
# All 5 bookmarks should be present directly from "export"
bookmarks = list_bookmarks(src)
assert set(bookmarks) >= {"block-alef", "block-bet", "block-heh",
"block-yod", "block-yod-bet"}
out = tmp_path / "revised.docx"
revs = [
Revision(id="r1", type="insert_after", anchor_bookmark="block-yod",
content="תוספת מערכת: פסק הלכה חדש", style="body"),
]
result = apply_tracked_revisions(src, out, revs)
assert result.applied == 1
with zipfile.ZipFile(out, "r") as zf:
tree = etree.fromstring(zf.read("word/document.xml"))
raw_text = "".join(tree.itertext())
assert "תוספת מערכת" in raw_text
# The revision is tracked (inside <w:ins>)
ins_list = tree.findall(".//w:ins", NSMAP)
assert any("תוספת מערכת" in "".join(el.itertext()) for el in ins_list)
# ── User-edited DOCX (no bookmarks) — needs retrofit first ──────
def test_retrofit_then_revise_on_user_edit(tmp_path: Path) -> None:
user_file = tmp_path / "user_edit.docx"
_make_user_edited_docx(user_file)
# Initially no named bookmarks
assert list_bookmarks(user_file) == []
# Retrofit — should detect blocks via heading heuristic
result = docx_retrofit.retrofit_bookmarks(user_file, backup=False)
added = set(result["bookmarks_added"])
# Must include at least block-yod (for common "insert pasak halacha" task)
assert "block-yod" in added
# Plus block-heh (רקע) and block-zayin (תמצית טענות)
assert "block-heh" in added
assert "block-zayin" in added
# Now apply a revision on the retrofitted file
out = tmp_path / "revised.docx"
revs = [Revision(id="r1", type="insert_after",
anchor_bookmark="block-yod",
content="פסק הלכה שהוסף: בבג\"ץ 1/23 נקבע כי...",
style="body")]
rr = apply_tracked_revisions(user_file, out, revs)
assert rr.applied == 1
# Verify output has the insertion inside <w:ins>
with zipfile.ZipFile(out, "r") as zf:
tree = etree.fromstring(zf.read("word/document.xml"))
ins_texts = ["".join(el.itertext()) for el in tree.iterfind(".//w:ins", NSMAP)]
assert any("פסק הלכה שהוסף" in t for t in ins_texts)
def test_retrofit_preserves_original_paragraphs(tmp_path: Path) -> None:
user_file = tmp_path / "user.docx"
_make_user_edited_docx(user_file)
before_doc = Document(str(user_file))
before_texts = [p.text for p in before_doc.paragraphs]
docx_retrofit.retrofit_bookmarks(user_file, backup=False)
after_doc = Document(str(user_file))
after_texts = [p.text for p in after_doc.paragraphs]
# Paragraph texts should be identical (we only added bookmark markers)
assert before_texts == after_texts
def test_idempotent_retrofit_and_revise(tmp_path: Path) -> None:
"""Running retrofit twice + revising should still produce valid output."""
user_file = tmp_path / "user.docx"
_make_user_edited_docx(user_file)
# First retrofit
r1 = docx_retrofit.retrofit_bookmarks(user_file, backup=False)
# Second retrofit — should add no new bookmarks
r2 = docx_retrofit.retrofit_bookmarks(user_file, backup=False)
assert r2["bookmarks_added"] == []
assert set(r2["existing_bookmarks"]) >= set(r1["bookmarks_added"])
# Then revise works normally
out = tmp_path / "revised.docx"
revs = [Revision(id="r1", type="insert_after",
anchor_bookmark="block-yod", content="x")]
result = apply_tracked_revisions(user_file, out, revs)
assert result.applied == 1
def test_multiple_revisions_all_tracked_independently(tmp_path: Path) -> None:
"""Verify multiple tracked changes each get independent ins ids so
user can Accept/Reject each one separately in Word."""
user_file = tmp_path / "user.docx"
_make_user_edited_docx(user_file)
docx_retrofit.retrofit_bookmarks(user_file, backup=False)
out = tmp_path / "revised.docx"
revs = [
Revision(id="r1", type="insert_after",
anchor_bookmark="block-heh", content="תוספת 1"),
Revision(id="r2", type="insert_after",
anchor_bookmark="block-yod", content="תוספת 2"),
Revision(id="r3", type="insert_before",
anchor_bookmark="block-yod-alef", content="תוספת 3"),
]
result = apply_tracked_revisions(user_file, out, revs)
assert result.applied == 3
with zipfile.ZipFile(out, "r") as zf:
tree = etree.fromstring(zf.read("word/document.xml"))
ins_ids = {el.get(_w("id")) for el in tree.iterfind(".//w:ins", NSMAP)}
assert len(ins_ids) >= 3 # at least one unique id per revision
def test_rtl_preserved_in_tracked_insertion(tmp_path: Path) -> None:
"""Inserted paragraph must have bidi + rtl + David font so it renders
correctly in Word alongside the user's content."""
user_file = tmp_path / "user.docx"
_make_user_edited_docx(user_file)
docx_retrofit.retrofit_bookmarks(user_file, backup=False)
out = tmp_path / "out.docx"
revs = [Revision(id="r1", type="insert_after",
anchor_bookmark="block-yod", content="עברית RTL")]
apply_tracked_revisions(user_file, out, revs)
with zipfile.ZipFile(out, "r") as zf:
tree = etree.fromstring(zf.read("word/document.xml"))
# Find the ins that holds runs
for ins in tree.iterfind(".//w:ins", NSMAP):
runs = ins.findall(".//w:r", NSMAP)
for r in runs:
text_els = r.findall(".//w:t", NSMAP)
if any("עברית RTL" in (t.text or "") for t in text_els):
rPr = r.find(_w("rPr"))
assert rPr is not None
assert rPr.find(_w("rtl")) is not None
rFonts = rPr.find(_w("rFonts"))
assert rFonts is not None
assert rFonts.get(_w("ascii")) == "David"
return
pytest.fail("tracked insertion with 'עברית RTL' not found")

View File

@@ -1,65 +0,0 @@
# Bug: Skill import from Gitea — wrong raw URL format causes empty SKILL.md
**File at:** https://github.com/paperclipai/paperclip/issues/new
## Title
Skill import from Gitea: wrong raw URL format causes empty SKILL.md
## Body
### Bug Summary
When importing skills from a **Gitea** instance (self-hosted), Paperclip fetches the git tree successfully via the `/api/v3/` endpoint (which Gitea supports), but then uses the **wrong raw file URL format** to download `SKILL.md` content, resulting in a 404 and an almost-empty stub being saved.
### Environment
- Paperclip server: `@paperclipai/server@2026.403.0`
- Gitea instance: self-hosted Gitea
### Steps to Reproduce
1. Host a skill repo on a Gitea instance with a `SKILL.md` (32KB+), `scripts/`, and `references/` directories
2. Import the skill via URL: `https://my-gitea.example.com/org/skill-name.git`
3. Observe that only a stub SKILL.md (~283 bytes) is saved, and subdirectories are missing
### Root Cause
In `server/dist/services/github-fetch.js`, the `resolveRawGitHubUrl()` function builds:
```
https://{hostname}/raw/{owner}/{repo}/{ref}/{file}
```
This format works for **GitHub Enterprise**, but **not for Gitea**. Gitea expects:
```
https://{hostname}/{owner}/{repo}/raw/branch/{ref}/{file}
```
### Proof
```bash
# Paperclip's URL format -> 404
$ curl -s -o /dev/null -w "%{http_code}" "https://my-gitea.example.com/raw/org/skill-repo/main/SKILL.md"
404
# Correct Gitea format -> 200
$ curl -s -o /dev/null -w "%{http_code}" "https://my-gitea.example.com/org/skill-repo/raw/branch/main/SKILL.md"
200
```
### Secondary Issue
When `SKILL.md` is at the repository root, `path.posix.dirname("SKILL.md")` returns `"."`, causing the inventory filter `entry.startsWith("./")` to miss all sibling directories (`scripts/`, `references/`). This means even if the raw URL worked, subdirectories would still be excluded from the file inventory.
### Suggested Fix
1. **Detect Gitea** vs GitHub Enterprise (e.g., check for `/api/v1/` endpoint which is Gitea-specific, vs `/api/v3/`)
2. **Use the correct raw URL format** per platform:
- GitHub/GHE: `https://{hostname}/raw/{owner}/{repo}/{ref}/{file}`
- Gitea: `https://{hostname}/{owner}/{repo}/raw/branch/{ref}/{file}`
3. **Fix root-level SKILL.md inventory**: when `skillDir === "."`, include all files instead of filtering by `entry.startsWith("./")`
### Workaround
Manually clone the repo into `~/.paperclip/instances/default/skills/{company_id}/{slug}/` and update the `company_skills` table directly with correct markdown content and file_inventory.

54
scripts/SCRIPTS.md Normal file
View File

@@ -0,0 +1,54 @@
# scripts/ — מדריך סקריפטים
> **כלל:** כל עדכון, יצירה, או מחיקה של סקריפט בתיקייה זו מחייב עדכון של קובץ זה.
---
## סקריפטים פעילים
| Script | Type | Purpose | Scheduled |
|--------|------|---------|-----------|
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
| `notify.py` | python | שליחת מייל התראה מסוכנים via SMTP (Gmail) | נקרא ע"י סוכנים |
| `bidi_table.py` | python | יצירת טבלאות box-drawing עם תמיכה ב-BiDi (עברית+אנגלית) | ספריית עזר |
| `convert_decision_template.py` | python | המרת `data/training/טיוטת החלטה.dotx``skills/docx/decision_template.docx` לטעינה ב-python-docx | להריץ כשמתעדכנת התבנית |
| `deploy-track-changes.sh` | bash | סנכרון skills CMP↔CMPA + בדיקות + הנחיות deploy לארכיטקטורת Track Changes | ידני |
| `retrofit_case.py` | python | retrofit רטרואקטיבי — מזריק bookmarks לקובץ קיים של תיק ספציפי ומגדיר אותו כ-active_draft | ידני (חד-פעמי לתיק) |
## תיקיית `.archive/` — סקריפטים שהושלמו
סקריפטים חד-פעמיים שהפונקציונליות שלהם הוטמעה ב-MCP server או ב-API.
נשמרים ב-git לצורך היסטוריה — **אין להריץ אותם**.
| Script | Original Purpose | Superseded By |
|--------|-----------------|---------------|
| `backfill_pattern_frequency.py` | עדכון תדירות דפוסי סגנון ב-DB | `web/app.py::_extract_pattern_variants()` |
| `batch_upload_training.py` | העלאת קורפוס אימון (16 קבצים) | Web UI: `/api/training/upload` |
| `benchmark_embeddings.py` | השוואת מודלי embeddings (voyage-3 vs voyage-4) | הושלם — voyage-3-large נבחר |
| `benchmark_new_vs_old.py` | השוואת Google Vision vs markdown קיים | הושלם — בדיקה חד-פעמית לתיק 1130-25 |
| `decompose-decisions.py` | פירוק החלטות סופיות ל-12 בלוקים | MCP: `write_block()`, `write_all_blocks()` |
| `export-decision-docx.py` | ייצוא החלטה ל-DOCX | MCP: `export_docx()` |
| `extract-citations.py` | חילוץ ציטוטי פסיקה מבלוק י | MCP service: `references_extractor.py` |
| `extract-claims.py` | חילוץ טענות מבלוק ז | MCP: `extract_claims()` + `claims_extractor.py` |
| `extract_all_google_vision.py` | OCR בכמות עם Google Vision | MCP: `document_upload()` pipeline |
| `extract_originals.py` | חילוץ טקסט מ-PDF עם Claude Opus | MCP service: `extractor.py` |
| `extract_originals_ocr.py` | חילוץ OCR מלא מ-PDF | MCP service: `extractor.py` |
| `generate-embeddings.py` | יצירת embeddings לבלוקים ופסיקה | אוטומטי — נוצרים עם יצירת בלוקים |
| `link-claims-to-discussion.py` | קישור טענות לפסקאות דיון | MCP service: `qa_validator.py` |
| `proofread_training_corpus.py` | ניקוי Nevo מ-DOCX/PDF ל-Markdown | MCP service: `proofreader.py` + Web UI |
| `seed-appeals.py` | seeding תיקי ערר ראשוניים ל-DB | MCP: `case_create()` |
| `seed-knowledge.py` | seeding לקחים, ביטויי מעבר, פסיקה | MCP: `record_chair_feedback()`, `precedent_attach()` |
| `validate-decision.py` | ולידציה מול block-schema | MCP: `validate_decision()` + `qa_validator.py` |
## סקריפטים שנמחקו (git history בלבד)
| Script | Reason |
|--------|--------|
| `import-final-decisions.py` | מיגרציה הושלמה — כל ההחלטות ב-`data/training/` |
| `compare_extractions.py` | בדיקה חד-פעמית לתיק 1130-25 |
| `decompose-decisions-v2.py` | כפילות של v1 |
| `extract_google_vision.py` | hardcoded לתיק בודד |
| `extract_google_vision_single.py` | wrapper חד-פעמי |
| `test-search.py` | סקריפט דיבאג |

View File

@@ -4,34 +4,50 @@
CASES_DIR="/home/chaim/legal-ai/data/cases"
LOG="/home/chaim/legal-ai/data/.auto-sync.log"
GIT_ENV="GIT_AUTHOR_NAME=Ezer Mishpati GIT_AUTHOR_EMAIL=legal@local GIT_COMMITTER_NAME=Ezer Mishpati GIT_COMMITTER_EMAIL=legal@local GIT_TERMINAL_PROMPT=0"
for status_dir in "$CASES_DIR"/new "$CASES_DIR"/in-progress "$CASES_DIR"/completed; do
[ -d "$status_dir" ] || continue
for case_dir in "$status_dir"/*/; do
[ -d "$case_dir/.git" ] || continue
export GIT_AUTHOR_NAME="Ezer Mishpati"
export GIT_AUTHOR_EMAIL="legal@local"
export GIT_COMMITTER_NAME="Ezer Mishpati"
export GIT_COMMITTER_EMAIL="legal@local"
export GIT_TERMINAL_PROMPT=0
cd "$case_dir" || continue
for case_dir in "$CASES_DIR"/*/; do
[ -d "$case_dir/.git" ] || continue
# Check for any changes (modified, new, deleted)
changes=$(git status --porcelain 2>/dev/null)
[ -z "$changes" ] && continue
case_name=$(basename "$case_dir")
# Stage all changes
git add -A 2>/dev/null
# Ensure safe.directory is set for this repo
git config --global --get-all safe.directory | grep -qF "$case_dir" \
|| git config --global --add safe.directory "$case_dir"
# Build commit message from changed files
changed_files=$(git diff --cached --name-only 2>/dev/null | head -5)
count=$(git diff --cached --name-only 2>/dev/null | wc -l)
case_name=$(basename "$case_dir")
msg="סנכרון אוטומטי — ${count} קבצים שונו"
cd "$case_dir" || continue
# Commit
env $GIT_ENV git commit -m "$msg" --quiet 2>/dev/null
if [ $? -eq 0 ]; then
# Push (non-blocking, ignore errors)
git push origin main --quiet 2>/dev/null
echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | $count files synced" >> "$LOG"
# Check for any changes (modified, new, deleted)
changes=$(git status --porcelain 2>/dev/null)
[ -z "$changes" ] && continue
# Stage all changes
git add -A 2>/dev/null
# Count changed files
count=$(git diff --cached --name-only 2>/dev/null | wc -l)
[ "$count" -eq 0 ] && continue
msg="סנכרון אוטומטי — ${count} קבצים שונו"
# Commit
if git commit -m "$msg" --quiet 2>/dev/null; then
# Push only if remote exists
if git remote get-url origin >/dev/null 2>&1; then
if git push origin HEAD --quiet 2>/dev/null; then
echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | $count files synced + pushed" >> "$LOG"
else
echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | $count files committed, push FAILED" >> "$LOG"
fi
else
echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | $count files committed (no remote)" >> "$LOG"
fi
done
else
echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | commit FAILED" >> "$LOG"
fi
done

View File

@@ -1,126 +0,0 @@
"""Compare existing MD files with freshly extracted text from PDFs."""
import difflib
from pathlib import Path
DOCS_DIR = Path("/home/chaim/legal-ai/data/cases/1130-25/documents")
EXTRACTED_DIR = DOCS_DIR / "extracted"
# Map: existing MD -> extracted MD
PAIRS = [
("2025-08-14-כתב-ערר-קובר.md", "מרק קובר-כתב ערר.md", "Appeal - Kuber"),
("2025-09-01-כתב-תשובה-ליבמן-לערר.md", "תשובה לערר מטעם המשיבים.md", "Response - Livman"),
("2025-09-02-כתב-תשובה-ועדת-הראל-לערר.md", "תשובת הועדה המרחבית לערר.md", "Response - Committee"),
("2025-10-22-כתב-ערר-מטמון.md", "תשובת המשיב-יצחק מטמון.md", "Response - Matmon"),
]
def normalize(text: str) -> str:
"""Normalize text for comparison."""
# Remove markdown formatting, extra whitespace
lines = text.strip().split("\n")
lines = [l.strip() for l in lines if l.strip()]
return "\n".join(lines)
def word_overlap(a: str, b: str) -> float:
"""Calculate word-level overlap ratio."""
words_a = set(a.split())
words_b = set(b.split())
if not words_a or not words_b:
return 0.0
intersection = words_a & words_b
return len(intersection) / max(len(words_a), len(words_b))
def main():
print(f"{'=' * 70}")
print("COMPARISON: Existing MD vs Fresh PDF Extraction")
print(f"{'=' * 70}\n")
summary = []
for existing_name, extracted_name, label in PAIRS:
existing_path = DOCS_DIR / existing_name
extracted_path = EXTRACTED_DIR / extracted_name
if not existing_path.exists():
print(f"SKIP: {existing_name} not found")
continue
if not extracted_path.exists():
print(f"SKIP: {extracted_name} not found")
continue
existing_text = existing_path.read_text(encoding="utf-8")
extracted_text = extracted_path.read_text(encoding="utf-8")
existing_norm = normalize(existing_text)
extracted_norm = normalize(extracted_text)
# Stats
existing_chars = len(existing_text)
extracted_chars = len(extracted_text)
existing_words = len(existing_text.split())
extracted_words = len(extracted_text.split())
# Similarity
overlap = word_overlap(existing_norm, extracted_norm)
# Sequence matcher ratio (slower but more accurate)
# Use first 5000 chars for speed
sm = difflib.SequenceMatcher(None, existing_norm[:5000], extracted_norm[:5000])
seq_ratio = sm.ratio()
# Find lines in extracted but not in existing (new content)
existing_lines = set(existing_norm.split("\n"))
extracted_lines = set(extracted_norm.split("\n"))
new_lines = extracted_lines - existing_lines
missing_lines = existing_lines - extracted_lines
print(f"{'=' * 70}")
print(f" {label}")
print(f" Existing: {existing_name}")
print(f" Extracted: {extracted_name}")
print(f"{'=' * 70}")
print(f" {'Metric':<30} {'Existing MD':>15} {'Fresh PDF':>15} {'Diff':>10}")
print(f" {'-' * 70}")
print(f" {'Characters':<30} {existing_chars:>15,} {extracted_chars:>15,} {extracted_chars - existing_chars:>+10,}")
print(f" {'Words':<30} {existing_words:>15,} {extracted_words:>15,} {extracted_words - existing_words:>+10,}")
print(f" {'Lines':<30} {len(existing_lines):>15,} {len(extracted_lines):>15,} {len(extracted_lines) - len(existing_lines):>+10,}")
print(f" {'Word overlap':<30} {overlap:>15.1%}")
print(f" {'Sequence similarity':<30} {seq_ratio:>15.1%}")
print(f" {'Lines only in fresh PDF':<30} {len(new_lines):>15}")
print(f" {'Lines only in existing MD':<30} {len(missing_lines):>15}")
# Show sample differences
if new_lines:
print(f"\n Sample lines ONLY in fresh extraction (first 3):")
for line in sorted(new_lines)[:3]:
print(f" + {line[:100]}")
if missing_lines:
print(f"\n Sample lines ONLY in existing MD (first 3):")
for line in sorted(missing_lines)[:3]:
print(f" - {line[:100]}")
print()
summary.append({
"label": label,
"existing_words": existing_words,
"extracted_words": extracted_words,
"word_overlap": overlap,
"seq_similarity": seq_ratio,
})
# Summary table
print(f"\n{'=' * 70}")
print("SUMMARY")
print(f"{'=' * 70}")
print(f" {'Document':<25} {'Existing':>10} {'Fresh':>10} {'Overlap':>10} {'Similarity':>12}")
print(f" {'-' * 67}")
for s in summary:
print(f" {s['label']:<25} {s['existing_words']:>10,} {s['extracted_words']:>10,} {s['word_overlap']:>10.1%} {s['seq_similarity']:>12.1%}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,102 @@
"""Convert דפנה's decision .dotx template to a loadable .docx file.
python-docx cannot open .dotx files directly (content type is
`...template.main+xml` rather than `...document.main+xml`). This script
produces a sibling .docx by rewriting [Content_Types].xml and dropping
the `word/glossary/` part (which is template-specific and can interfere
with plain Document() loading).
The output preserves every style definition, numbering, fonts, and
section properties — the only things we want from the template.
Run once (or whenever the source .dotx changes):
python scripts/convert_decision_template.py
Input: data/training/טיוטת החלטה.dotx
Output: skills/docx/decision_template.docx
"""
from __future__ import annotations
import re
import sys
import zipfile
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SRC = REPO_ROOT / "data" / "training" / "טיוטת החלטה.dotx"
DST = REPO_ROOT / "skills" / "docx" / "decision_template.docx"
TEMPLATE_CONTENT_TYPE = (
"application/vnd.openxmlformats-officedocument."
"wordprocessingml.template.main+xml"
)
DOCUMENT_CONTENT_TYPE = (
"application/vnd.openxmlformats-officedocument."
"wordprocessingml.document.main+xml"
)
def convert(src: Path, dst: Path) -> None:
if not src.exists():
raise FileNotFoundError(f"Template not found: {src}")
dst.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(src, "r") as zin:
names = zin.namelist()
with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
for name in names:
# Drop glossary part — template-only, confuses Document()
if name.startswith("word/glossary/"):
continue
data = zin.read(name)
if name == "[Content_Types].xml":
text = data.decode("utf-8")
text = text.replace(
TEMPLATE_CONTENT_TYPE, DOCUMENT_CONTENT_TYPE
)
# Drop every <Override> that points at /word/glossary/...
text = re.sub(
r'<Override\s+PartName="/word/glossary/[^"]*"[^>]*?/>',
"",
text,
)
data = text.encode("utf-8")
elif name == "word/_rels/document.xml.rels":
# Strip the glossaryDocument relationship — the target
# part is being removed, so the ref would dangle.
text = data.decode("utf-8")
text = re.sub(
r'<Relationship\s+[^>]*?glossaryDocument[^>]*?/>',
"",
text,
)
data = text.encode("utf-8")
zout.writestr(name, data)
def verify(dst: Path) -> None:
"""Load with python-docx and print a few style names to confirm it works."""
from docx import Document
doc = Document(str(dst))
key_styles = {"Normal", "Heading 2", "Quote", "List Paragraph", "Title"}
found = {s.name for s in doc.styles if s.name in key_styles}
missing = key_styles - found
if missing:
print(f"WARN: missing styles: {missing}", file=sys.stderr)
else:
print(f"OK — all key styles present: {sorted(found)}")
def main() -> None:
print(f"Source: {SRC}")
print(f"Dest: {DST}")
convert(SRC, DST)
print(f"Wrote {DST.stat().st_size:,} bytes")
verify(DST)
if __name__ == "__main__":
main()

View File

@@ -1,289 +0,0 @@
#!/usr/bin/env python3
"""Decompose final decisions into 12-block structure — V2 calibrated on הכט.
Key insight: DOCX extraction strips header blocks (א-ד). The real content
starts at block ה (opening "לפנינו"). We identify blocks by known section
headers and line-by-line analysis.
"""
import asyncio
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "mcp-server" / "src"))
from legal_mcp.services.db import get_pool, init_schema, close_pool
BLOCK_DEFS = [
("block-alef", 1, "כותרת מוסדית", "template-fill"),
("block-bet", 2, "הרכב הוועדה", "template-fill"),
("block-gimel", 3, "צדדים", "template-fill"),
("block-dalet", 4, "כותרת החלטה", "template-fill"),
("block-he", 5, "פתיחה", "paraphrase"),
("block-vav", 6, "רקע עובדתי", "reproduction"),
("block-zayin", 7, "טענות הצדדים", "paraphrase"),
("block-chet", 8, "הליכים בפני ועדת הערר", "reproduction"),
("block-tet", 9, "תכניות חלות", "guided-synthesis"),
("block-yod", 10, "דיון והכרעה", "rhetorical-construction"),
("block-yod-alef", 11, "סיכום", "paraphrase"),
("block-yod-bet", 12, "חתימות", "template-fill"),
]
def find_line(lines: list[str], pattern: str, start: int = 0) -> int:
"""Find first line matching pattern (substring or regex). Returns -1 if not found."""
pat = re.compile(pattern)
for i in range(start, len(lines)):
if pat.search(lines[i]):
return i
return -1
def slice_text(lines: list[str], start: int, end: int) -> str:
"""Join lines[start:end] into text."""
if start < 0 or end <= start:
return ""
return "\n".join(lines[start:end]).strip()
def count_words(text: str) -> int:
return len(text.split()) if text else 0
def decompose(text: str) -> dict[str, str]:
"""Parse decision into blocks. Returns {block_id: content}."""
lines = text.split("\n")
n = len(lines)
blocks = {}
# Find key section headers
# Style 1: רישוי — descriptive headers ("תמצית טענות הצדדים", "דיון והכרעה")
# Style 2: היטל השבחה — numbered headers ("א. רקע עובדתי", "ו. דיון והכרעה")
opening = find_line(lines, r"^לפנינו\s|^בפנינו\s|^בפני\s*ועדת|^בפני\s*בקשה")
claims = find_line(lines, r"תמצית\s*טענות|טענות\s*הצדדים|טענות\s*העוררי")
if claims == -1:
claims = find_line(lines, r"^טענות\s*העוררי")
if claims == -1:
# היטל השבחה style: "ב. טענות העורר"
claims = find_line(lines, r"^[א-ת][\.\)]\s*טענות")
background = find_line(lines, r"^[א-ת][\.\)]\s*רקע\s*עובדתי")
proceedings = find_line(lines, r"ההליכים\s*בפני|הליכים\s*בפני|הדיון\s*בפני\s*ועדת\s*הערר")
if proceedings == -1:
# היטל השבחה: "ד. הבהרות השמאית" or similar procedural sections
proceedings = find_line(lines, r"^[א-ת][\.\)]\s*הבהרות|^[א-ת][\.\)]\s*ההליך")
plans = find_line(lines, r"תכניות\s*חלות|המסגרת\s*הנורמטיבית|הוראות\s*התכנית")
if plans == -1:
plans = find_line(lines, r"^[א-ת][\.\)]\s*המסגרת\s*הנורמטיבית")
discussion = find_line(lines, r"^דיון\s*והכרעה|^דיון$|^הכרעה$")
if discussion == -1:
discussion = find_line(lines, r"^[א-ת][\.\)]\s*דיון\s*והכרעה")
summary = find_line(lines, r"^סיכום\s*$|^סוף\s*דבר\s*$")
if summary == -1:
summary = find_line(lines, r"^[א-ת][\.\)]\s*סיכום")
signature = find_line(lines, r"^ניתנה?\s*(היום|פה\s*אחד|ביום)")
# If no explicit discussion header, look for the opening formula
if discussion == -1:
discussion = find_line(lines, r"לאחר\s*שבחנו\s*את\s*טענות")
# ── Header blocks (א-ד): everything before opening ──
if opening >= 0:
header_text = slice_text(lines, 0, opening)
if header_text:
# Try to split header, but usually DOCX extraction loses these
blocks["block-alef"] = header_text
else:
blocks["block-alef"] = ""
else:
blocks["block-alef"] = ""
blocks["block-bet"] = "" # Usually lost in extraction
blocks["block-gimel"] = ""
blocks["block-dalet"] = "החלטה"
# ── Block ה: Opening — first 1-3 paragraphs from "לפנינו" ──
if opening >= 0:
next_section = claims if claims > opening else discussion if discussion > opening else n
opening_end = opening + 1
for i in range(opening + 1, min(opening + 5, next_section)):
line = lines[i].strip()
if not line:
break
opening_end = i + 1
blocks["block-he"] = slice_text(lines, opening, opening_end)
else:
blocks["block-he"] = ""
# ── Block ו: Background ──
# Style 1 (רישוי): after opening, before claims
# Style 2 (היטל השבחה): explicit "א. רקע עובדתי" section
if background >= 0:
# Explicit background header (היטל השבחה style)
bg_end = claims if claims > background else (proceedings if proceedings > background else (discussion if discussion > background else n))
blocks["block-vav"] = slice_text(lines, background, bg_end)
# In this case, opening (ה) might not exist — "לפנינו" may be absent
elif opening >= 0 and claims > opening:
bg_start = opening + 1
he_lines = count_words(blocks.get("block-he", ""))
if he_lines > 0:
he_end = opening
for i in range(opening, min(opening + 5, claims)):
if lines[i].strip():
he_end = i + 1
else:
break
bg_start = he_end
blocks["block-vav"] = slice_text(lines, bg_start, claims)
elif opening >= 0 and discussion > opening:
blocks["block-vav"] = slice_text(lines, opening + 1, discussion)
else:
blocks["block-vav"] = ""
# ── Block ז: Claims — from claims header to next section ──
if claims >= 0:
claims_end = min(
x for x in [proceedings, plans, discussion, summary, n]
if x > claims
)
blocks["block-zayin"] = slice_text(lines, claims, claims_end)
else:
blocks["block-zayin"] = ""
# ── Block ח: Proceedings (optional) ──
if proceedings >= 0:
proc_end = min(
x for x in [plans, discussion, summary, n]
if x > proceedings
)
blocks["block-chet"] = slice_text(lines, proceedings, proc_end)
else:
blocks["block-chet"] = ""
# ── Block ט: Plans (optional) ──
if plans >= 0 and (discussion == -1 or plans < discussion):
plans_end = min(
x for x in [discussion, summary, n]
if x > plans
)
blocks["block-tet"] = slice_text(lines, plans, plans_end)
else:
blocks["block-tet"] = ""
# ── Block י: Discussion ──
if discussion >= 0:
disc_end = summary if summary > discussion else (signature if signature > discussion else n)
blocks["block-yod"] = slice_text(lines, discussion, disc_end)
else:
blocks["block-yod"] = ""
# ── Block יא: Summary ──
if summary >= 0:
summ_end = signature if signature > summary else n
blocks["block-yod-alef"] = slice_text(lines, summary, summ_end)
else:
blocks["block-yod-alef"] = ""
# ── Block יב: Signatures ──
if signature >= 0:
blocks["block-yod-bet"] = slice_text(lines, signature, n)
else:
blocks["block-yod-bet"] = ""
return blocks
async def main():
await init_schema()
pool = await get_pool()
async with pool.acquire() as conn:
decisions = await conn.fetch(
"""SELECT d.id as decision_id, c.case_number, c.title,
doc.extracted_text
FROM decisions d
JOIN cases c ON c.id = d.case_id
JOIN documents doc ON doc.case_id = d.case_id AND doc.doc_type = 'decision'
WHERE d.status = 'final'
ORDER BY c.case_number"""
)
for dec in decisions:
decision_id = dec["decision_id"]
case_number = dec["case_number"]
text = dec["extracted_text"]
total_words = count_words(text)
print(f"\n{'='*60}")
print(f"מפרק: {case_number}{dec['title']}")
print(f"סה\"כ מילים: {total_words}")
print(f"{'='*60}")
parsed = decompose(text)
async with pool.acquire() as conn:
# Delete existing blocks
await conn.execute(
"DELETE FROM decision_blocks WHERE decision_id = $1", decision_id
)
total_parsed_words = 0
for block_id, block_index, title, gen_type in BLOCK_DEFS:
content = parsed.get(block_id, "")
wc = count_words(content)
weight = round(wc / total_words * 100, 1) if total_words > 0 and wc > 0 else 0
status = "final" if wc > 0 else "empty"
total_parsed_words += wc
await conn.execute(
"""INSERT INTO decision_blocks
(decision_id, block_id, block_index, title, content,
word_count, weight_percent, generation_type, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)""",
decision_id, block_id, block_index, title,
content, wc, weight, gen_type, status,
)
marker = "" if wc > 0 else ""
print(f" {marker} {block_id:18s} | {title:25s} | {wc:5d} מילים | {weight:5.1f}%")
# Update decision totals
disc_words = count_words(parsed.get("block-yod", ""))
disc_paras = len([p for p in parsed.get("block-yod", "").split("\n") if p.strip() and len(p.strip()) > 20])
await conn.execute(
"UPDATE decisions SET total_words = $1, total_paragraphs = $2, updated_at = now() WHERE id = $3",
total_words, disc_paras, decision_id,
)
coverage = round(total_parsed_words / total_words * 100, 1) if total_words > 0 else 0
print(f" --- כיסוי: {total_parsed_words}/{total_words} מילים ({coverage}%)")
# Summary
async with pool.acquire() as conn:
stats = await conn.fetch(
"""SELECT block_id, count(*) as decisions,
avg(word_count) as avg_words,
avg(weight_percent) as avg_weight
FROM decision_blocks
WHERE word_count > 0
GROUP BY block_id ORDER BY block_id"""
)
print(f"\n{'='*60}")
print("סטטיסטיקה לפי בלוק (רק בלוקים עם תוכן):")
for s in stats:
print(f" {s['block_id']:18s} | {s['decisions']} החלטות | ממוצע {s['avg_words']:.0f} מילים | {s['avg_weight']:.1f}%")
await close_pool()
if __name__ == "__main__":
asyncio.run(main())

86
scripts/deploy-track-changes.sh Executable file
View File

@@ -0,0 +1,86 @@
#!/bin/bash
# deploy-track-changes.sh — פריסת ארכיטקטורת Track Changes לשתי חברות (CMP + CMPA)
#
# מה זה עושה:
# 1. מוודא ש-skills קיימים ומסונכרנים בשתי החברות
# 2. git commit + push (אם יש שינויים)
# 3. הודעה להפעלת Coolify deploy
#
# שימוש:
# scripts/deploy-track-changes.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CMP_DIR="/home/chaim/.paperclip/instances/default/skills/42a7acd0-30c5-4cbd-ac97-7424f65df294"
CMPA_DIR="/home/chaim/.paperclip/instances/default/skills/8639e837-4c9d-47fa-a76b-95788d651896"
COOLIFY_UUID="gyjo0mtw2c42ej3xxvbz8zio"
echo "▶ שלב 1: סנכרון skills בין CMP ל-CMPA"
SKILLS=(legal-docx attach-precedents review-analysis writer-readiness
appendix-expert-intern bidi-table-rtl revise-draft)
mkdir -p "$CMPA_DIR"
for skill in "${SKILLS[@]}"; do
if [ ! -d "$CMP_DIR/$skill" ]; then
echo " ⚠ skill לא קיים ב-CMP: $skill — דילוג"
continue
fi
if [ -d "$CMPA_DIR/$skill" ]; then
# Update only — don't delete any CMPA-specific files
rsync -av --update "$CMP_DIR/$skill/" "$CMPA_DIR/$skill/" > /dev/null
echo "$skill (עודכן ב-CMPA)"
else
cp -r "$CMP_DIR/$skill" "$CMPA_DIR/$skill"
echo "$skill (הועתק ל-CMPA)"
fi
done
echo ""
echo "▶ שלב 2: בדיקת פיתוח אחרונה"
cd "$REPO_ROOT"
# Run mcp-server tests
if [ -f mcp-server/.venv/bin/pytest ]; then
echo " מריץ pytest..."
(cd mcp-server && .venv/bin/pytest tests/ -q 2>&1 | tail -5) || {
echo " ✗ בדיקות נכשלו — עצירה"
exit 1
}
echo " ✓ כל הבדיקות עברו"
fi
# Run TypeScript check
if [ -d web-ui/node_modules ]; then
echo " מריץ tsc..."
(cd web-ui && npx tsc --noEmit 2>&1 | head -10) || {
echo " ✗ שגיאות TypeScript — עצירה"
exit 1
}
echo " ✓ TypeScript נקי"
fi
echo ""
echo "▶ שלב 3: סטטוס git"
if [ -n "$(git status --porcelain)" ]; then
echo " יש שינויים ב-git — לא מבצע commit אוטומטי (ריצו ידנית)"
git status --short
echo ""
echo " הפקודה להרצה:"
echo " git add -A"
echo " git commit -m \"Add Track Changes support for draft revisions (CMP + CMPA)\""
echo " git push origin main"
else
echo " ✓ אין שינויים לא שמורים"
fi
echo ""
echo "▶ שלב 4: Coolify deploy"
echo " לאחר push, הריצו:"
echo " mcp__coolify__deploy עם UUID=$COOLIFY_UUID"
echo " או דרך UI: https://coolify.nautilus.marcusgroup.org"
echo ""
echo "✓ הסקריפט הסתיים"

View File

@@ -1,66 +0,0 @@
"""Extract text from PDF using Google Cloud Vision API."""
import io
import time
from pathlib import Path
import fitz # PyMuPDF for rendering pages to images
from google.cloud import vision
API_KEY = "AIzaSyDZgUsxsy_FHkkREU7R_oQLJALU3_V26j8"
PDF_PATH = Path("/home/chaim/legal-ai/data/cases/1130-25/documents/originals/מרק קובר-כתב ערר.pdf")
OUTPUT_DIR = Path("/home/chaim/legal-ai/data/cases/1130-25/documents/extracted")
def main():
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
client = vision.ImageAnnotatorClient(
client_options={"api_key": API_KEY}
)
doc = fitz.open(str(PDF_PATH))
page_count = len(doc)
print(f"Processing: {PDF_PATH.name} ({page_count} pages)\n")
pages_text = []
total_time = 0.0
for i in range(page_count):
page = doc[i]
pix = page.get_pixmap(dpi=300)
img_bytes = pix.tobytes("png")
image = vision.Image(content=img_bytes)
print(f" Page {i+1}/{page_count}...", end=" ", flush=True)
t0 = time.time()
response = client.document_text_detection(
image=image,
image_context={"language_hints": ["he"]}
)
elapsed = time.time() - t0
total_time += elapsed
if response.error.message:
print(f"ERROR: {response.error.message}")
pages_text.append("")
continue
text = response.full_text_annotation.text if response.full_text_annotation else ""
pages_text.append(text)
print(f"{len(text):,} chars, {elapsed:.1f}s")
doc.close()
full_text = "\n\n".join(pages_text)
out_file = OUTPUT_DIR / f"{PDF_PATH.stem}.md"
out_file.write_text(full_text, encoding="utf-8")
print(f"\nTotal: {len(full_text):,} chars, {len(full_text.split()):,} words, {total_time:.1f}s")
print(f"Saved: {out_file}")
if __name__ == "__main__":
main()

View File

@@ -1,54 +0,0 @@
"""Extract text from a single PDF using Google Cloud Vision API."""
import sys
import time
from pathlib import Path
import fitz
from google.cloud import vision
API_KEY = "AIzaSyDZgUsxsy_FHkkREU7R_oQLJALU3_V26j8"
OUTPUT_DIR = Path("/home/chaim/legal-ai/data/cases/1130-25/documents/extracted")
def main():
pdf_path = Path(sys.argv[1])
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
client = vision.ImageAnnotatorClient(client_options={"api_key": API_KEY})
doc = fitz.open(str(pdf_path))
page_count = len(doc)
print(f"Processing: {pdf_path.name} ({page_count} pages)\n")
pages_text = []
total_time = 0.0
for i in range(page_count):
page = doc[i]
pix = page.get_pixmap(dpi=300)
img_bytes = pix.tobytes("png")
image = vision.Image(content=img_bytes)
print(f" Page {i+1}/{page_count}...", end=" ", flush=True)
t0 = time.time()
response = client.document_text_detection(image=image, image_context={"language_hints": ["he"]})
elapsed = time.time() - t0
total_time += elapsed
if response.error.message:
print(f"ERROR: {response.error.message}")
pages_text.append("")
continue
text = response.full_text_annotation.text if response.full_text_annotation else ""
pages_text.append(text)
print(f"{len(text):,} chars, {elapsed:.1f}s")
doc.close()
full_text = "\n\n".join(pages_text)
out_file = OUTPUT_DIR / f"{pdf_path.stem}.md"
out_file.write_text(full_text, encoding="utf-8")
print(f"\nTotal: {len(full_text):,} chars, {len(full_text.split()):,} words, {total_time:.1f}s")
print(f"Saved: {out_file}")
if __name__ == "__main__":
main()

View File

@@ -1,202 +0,0 @@
#!/usr/bin/env python3
"""Import 6 final signed decisions: extract text, store in DB."""
import asyncio
import json
import sys
from datetime import date
from pathlib import Path
from uuid import UUID
sys.path.insert(0, str(Path(__file__).parent.parent / "mcp-server" / "src"))
import fitz # PyMuPDF
from docx import Document as DocxDocument
from legal_mcp.services.db import get_pool, init_schema, close_pool
# ═══════════════════════════════════════════════════════════════════
# 6 Final Decisions
# ═══════════════════════════════════════════════════════════════════
FINAL_DECISIONS = [
{
"case_number": "1180-1181",
"file_path": "legacy/dafna-tamir/04_Archive/ערר 1180-1181 הכט/החלטה/הכט 1180-1181.pdf",
"title": "החלטה סופית — הכט 1180-1181",
"outcome": "rejected",
"decision_date": date(2026, 2, 5),
},
{
"case_number": "8255-25",
"file_path": "legacy/dafna-tamir/04_Archive/בל\"מ 8255-25 אפרים אבי נ' הוועדה המקומית לתכנון ובניה/החלטה/אליהו הרנון - להפצה.docx",
"title": "החלטה סופית — אפרים אבי 8255-25",
"outcome": "rejected",
"decision_date": None,
},
{
"case_number": "8007-24",
"file_path": "legacy/dafna-tamir/04_Archive/ערר 8007-24-עומר דרוויש-ערר על שומה מכרעת/החלטה/החלטה-סופית.docx",
"title": "החלטה סופית — עומר דרוויש 8007-24",
"outcome": "",
"decision_date": None,
},
{
"case_number": "1113/25",
"file_path": "legacy/dafna-tamir/04_Archive/ערר-1113-25-אייל-מבורך/החלטה/החלטה-1113-25-טיוטה-סופית.docx",
"title": "החלטה סופית — מבורך 1113-25",
"outcome": "",
"decision_date": None,
},
{
"case_number": "1126/25+1141/25",
"file_path": "legacy/dafna-tamir/04_Archive/ערר-1126-25-תמא-38-בית-הכרם/החלטה/בית הכרם-טיוטת החלטה-9.pdf",
"title": "החלטה סופית — בית הכרם 1126/25",
"outcome": "partial",
"decision_date": date(2026, 3, 1),
},
{
"case_number": "1128/25",
"file_path": "legacy/dafna-tamir/04_Archive/ערר-1128-25-שטרית/החלטה/1128-25 החלטה להפצה.pdf",
"title": "החלטה סופית — שטרית 1128-25",
"outcome": "",
"decision_date": None,
},
]
PROJECT_ROOT = Path(__file__).parent.parent
def extract_pdf_text(file_path: Path) -> str:
"""Extract text from PDF using PyMuPDF."""
doc = fitz.open(str(file_path))
text_parts = []
for page in doc:
text_parts.append(page.get_text())
doc.close()
return "\n".join(text_parts)
def extract_docx_text(file_path: Path) -> str:
"""Extract text from DOCX."""
doc = DocxDocument(str(file_path))
return "\n".join(p.text for p in doc.paragraphs if p.text.strip())
def extract_text(file_path: Path) -> str:
"""Extract text based on file extension."""
suffix = file_path.suffix.lower()
if suffix == ".pdf":
return extract_pdf_text(file_path)
elif suffix == ".docx":
return extract_docx_text(file_path)
else:
raise ValueError(f"Unsupported format: {suffix}")
def count_words(text: str) -> int:
return len(text.split())
async def main():
await init_schema()
pool = await get_pool()
for d in FINAL_DECISIONS:
file_path = PROJECT_ROOT / d["file_path"]
if not file_path.exists():
print(f"❌ קובץ לא נמצא: {file_path}")
continue
# Extract text
print(f"\nמחלץ טקסט: {d['title']}...")
text = extract_text(file_path)
word_count = count_words(text)
print(f" {word_count} מילים, {len(text)} תווים")
async with pool.acquire() as conn:
# Get case_id
case_id = await conn.fetchval(
"SELECT id FROM cases WHERE case_number = $1", d["case_number"]
)
if not case_id:
print(f" ⚠ תיק {d['case_number']} לא נמצא ב-DB — מדלג")
continue
# Register document
existing_doc = await conn.fetchval(
"SELECT id FROM documents WHERE file_path = $1",
str(file_path),
)
if existing_doc:
doc_id = existing_doc
print(f" מסמך כבר קיים ב-DB: {doc_id}")
# Update text
await conn.execute(
"""UPDATE documents SET extracted_text = $1, extraction_status = 'completed'
WHERE id = $2""",
text, doc_id,
)
else:
doc_id = await conn.fetchval(
"""INSERT INTO documents (case_id, doc_type, title, file_path, extracted_text, extraction_status, page_count)
VALUES ($1, 'decision', $2, $3, $4, 'completed', $5)
RETURNING id""",
case_id, d["title"], str(file_path), text,
len(fitz.open(str(file_path))) if file_path.suffix == ".pdf" else None,
)
print(f" מסמך נרשם: {doc_id}")
# Create/update decision record
existing_decision = await conn.fetchval(
"SELECT id FROM decisions WHERE case_id = $1", case_id
)
if existing_decision:
await conn.execute(
"""UPDATE decisions SET status = 'final', outcome = $1, total_words = $2,
decision_date = $3, updated_at = now() WHERE id = $4""",
d["outcome"], word_count, d["decision_date"], existing_decision,
)
decision_id = existing_decision
print(f" החלטה עודכנה: {decision_id}")
else:
decision_id = await conn.fetchval(
"""INSERT INTO decisions (case_id, version, status, outcome, outcome_summary,
total_words, decision_date, author)
VALUES ($1, 1, 'final', $2, $3, $4, $5, 'דפנה תמיר')
RETURNING id""",
case_id, d["outcome"], d["title"], word_count, d["decision_date"],
)
print(f" החלטה נוצרה: {decision_id}")
# Update case status
await conn.execute(
"UPDATE cases SET status = 'final', expected_outcome = $1, updated_at = now() WHERE id = $2",
d["outcome"], case_id,
)
print(f" ✅ הושלם: {d['case_number']}")
# Summary
async with pool.acquire() as conn:
doc_count = await conn.fetchval(
"SELECT count(*) FROM documents WHERE doc_type = 'decision' AND extraction_status = 'completed'"
)
dec_count = await conn.fetchval(
"SELECT count(*) FROM decisions WHERE status = 'final'"
)
total_words = await conn.fetchval(
"SELECT sum(total_words) FROM decisions WHERE status = 'final'"
)
await close_pool()
print(f"\n{'='*50}")
print(f"✅ סה\"כ מסמכי החלטה: {doc_count}")
print(f"✅ סה\"כ החלטות סופיות: {dec_count}")
print(f"✅ סה\"כ מילים: {total_words:,}")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -15,7 +15,7 @@ SMTP_HOST = "smtp.gmail.com"
SMTP_PORT = 587
FROM_EMAIL = "notify@marcus-law.co.il"
FROM_PASS = "vuva jwed lbuz xjds"
TO_EMAIL = "paperclip+chaim@marcus-law.co.il"
TO_EMAIL = "chaim+paperclip@marcus-law.co.il"
def send(subject: str, body: str) -> bool:

84
scripts/retrofit_case.py Executable file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""retrofit_case.py — הזרקת bookmarks רטרואקטיבית לקובץ קיים בתיק.
שימוש:
python scripts/retrofit_case.py <case_number> <filename>
דוגמה:
python scripts/retrofit_case.py 1033-25 עריכה-v1.docx
פעולה:
1. מזהה את הקובץ ב-data/cases/{case_number}/exports/
2. מזריק bookmarks ב-12 הבלוקים (heuristic)
3. שומר backup כ-{filename}.pre-retrofit.docx
4. מדפיס summary — אילו בלוקים זוהו, אילו חסרים
לתיק 1033-25 — הריצו פעם אחת על עריכה-v1.docx הקיים. אחרי זה תוכלו
להריץ revise_draft דרך ה-CEO.
הערה: השירות הזה נקרא גם אוטומטית דרך apply_user_edit tool ב-MCP,
אז אחרי deploy אין צורך להריץ ידנית. זה לגיבוי/ניפוי.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
# Make mcp-server importable when run from repo root
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "mcp-server" / "src"))
def main() -> int:
if len(sys.argv) != 3:
print(__doc__)
return 2
case_number = sys.argv[1]
filename = sys.argv[2]
from legal_mcp.services import docx_retrofit, docx_reviser
case_dir = REPO_ROOT / "data" / "cases" / case_number / "exports"
file_path = case_dir / filename
if not file_path.exists():
print(f"✗ קובץ לא נמצא: {file_path}", file=sys.stderr)
return 1
print(f"מעבד: {file_path}")
print(f" גודל: {file_path.stat().st_size:,} בייט")
# Existing bookmarks
before = docx_reviser.list_bookmarks(file_path)
print(f" bookmarks קיימים: {before or '(ריק)'}")
result = docx_retrofit.retrofit_bookmarks(file_path)
print()
print("תוצאה:")
print(json.dumps(result, ensure_ascii=False, indent=2))
# Verify post-state
after = docx_reviser.list_bookmarks(file_path)
print()
print(f"bookmarks אחרי: {len(after)}{after}")
backup = file_path.with_suffix(".pre-retrofit.docx")
if backup.exists():
print(f"גיבוי נשמר: {backup}")
# Build an MCP-callable invocation hint
rel = file_path.relative_to(REPO_ROOT)
print()
print("השלב הבא: לעדכן active_draft_path ב-DB. הפקודה:")
print(f' mcp__legal-ai__apply_user_edit case_number="{case_number}" '
f'edit_filename="{filename}"')
print()
print(f"(זה ירוץ retrofit שוב idempotent ואז יעדכן את DB)")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,40 +0,0 @@
#!/usr/bin/env python3
"""Test semantic search functions."""
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "mcp-server" / "src"))
from legal_mcp.services.db import search_similar_paragraphs, search_similar_case_law, search_precedents, init_schema
from legal_mcp.services.embeddings import embed_query
async def main():
await init_schema()
queries = [
"טענות קנייניות רוב דרוש בעלי דירות רכוש משותף",
"חניה תנועה חניות מצוקת חניה",
"היטל השבחה שמאי מכריע התערבות",
]
for query in queries:
print(f'=== שאילתה: "{query}" ===')
emb = await embed_query(query)
results = await search_precedents(emb, limit=3)
if not results:
print(" אין תוצאות")
else:
for i, r in enumerate(results):
score = r["score"]
cn = r["case_number"]
rtype = r["type"]
content = r["content"][:120].replace("\n", " ")
print(f" {i+1}. [{rtype}] {score:.3f} | {cn} | {content}")
print()
asyncio.run(main())

View File

@@ -1,574 +0,0 @@
# Block Schema — ארכיטקטורת מסמך החלטת ועדת ערר
מסמך זה מגדיר את המבנה הפורמלי של החלטת ועדת ערר לתכנון ובניה. הוא משמש כמקור סמכותי להגדרת בלוקים, משקלות, פרמטרי עיבוד, וכללי ולידציה.
**הפניה:** SKILL.md סעיפים 11-12 מכילים סיכום מהיר והנחיות תהליך. מסמך זה מכיל את ההגדרות המלאות.
---
## 1. יסודות תיאורטיים
ארכיטקטורת המסמך מבוססת על שילוב של ארבעה frameworks מוכרים:
### CREAC — מתודולוגיית כתיבה משפטית
Conclusion → Rule → Explanation → Application → Conclusion.
מקור: Columbia Law School, Legal Writing methodology.
**מיפוי:** חל על בלוק י (דיון) ובלוק יא (סיכום). בלוק י פותח במסקנה (C), מציג כלל משפטי (R), מסביר באמצעות פסיקה (E), מיישם על העובדות (A), וחוזר למסקנה (C). בלוק יא = C אחרון בלבד.
### Federal Judicial Center — Judicial Writing Manual
מגדיר תפקוד פונקציונלי לכל חלק בהחלטה שיפוטית:
- **Orientation** (אוריינטציה) — מי, מה, איפה → בלוקים א-ה
- **Framing** (מסגור) — הקשר עובדתי ותכנוני → בלוק ו
- **Argumentation** (טיעון) — עמדות הצדדים → בלוק ז
- **Procedural record** (תיעוד הליכי) — מה עשינו → בלוק ח
- **Deliberation** (דיון) — ניתוח משפטי → בלוקים ט-י
- **Disposition** (החלטה) — תוצאה אופרטיבית → בלוק יא
### DITA — Darwin Information Typing Architecture
סטנדרט OASIS להגדרת סוגי תוכן מובנים. מספק:
- **Content model** — אילו אלמנטים מותרים בכל בלוק
- **Constraints** — מה אסור (חשוב יותר ממה שמותר)
- **Specialization** — ירושה מסוג בסיסי עם התאמות
- **Relationships** — תלויות בין בלוקים
### Akoma Ntoso / LegalDocumentML
סטנדרט OASIS בינלאומי למסמכים משפטיים מובנים (UN/DESA). מספק:
- **Semantic mapping** — כל בלוק ממופה לרכיב מוכר בסטנדרט
- **Document class** — "judgment" (פסק דין / החלטה)
---
## 2. הגדרות בלוקים
### Block א: כותרת מוסדית / Institutional Header
**ID:** `block-alef`
**Akoma Ntoso:** `meta > identification`
**CREAC role:** none
**Functional purpose (JWM):** Orientation — מזהה את המוסד, התיק והגורם המחליט.
**Content model:**
- Types: template-field
- Elements: טבלה 2 טורים (מוסד | מספרי תיק)
- Sources: מערכת ניהול תיקים
**Constraints:**
- MUST: שם מוסד, מספר תיק, מספר תכנית/בקשה
- MUST NOT: תוכן מהותי כלשהו
- Dependencies: none
**Weight:** 1% (קבוע, לא משתנה בין סוגי עררים)
**Processing:**
- Generation type: template-fill
- Temperature: 0 | Thinking: off | Effort: min | Model: script
### Block ב: הרכב הוועדה / Panel Composition
**ID:** `block-bet`
**Akoma Ntoso:** `meta > references > TLCPerson`
**CREAC role:** none
**Functional purpose (JWM):** Orientation — מזהה את ההרכב המחליט. חשוב לביקורת שיפוטית (הרכב כשיר).
**Content model:**
- Types: template-field
- Elements: "בפני:" + יו"ר + חברים
- Sources: מערכת ניהול
**Constraints:**
- MUST: יו"ר + לפחות חבר אחד
- MUST NOT: תוכן מהותי
- Dependencies: none
**Weight:** 1% (קבוע)
**Processing:**
- Generation type: template-fill
- Temperature: 0 | Thinking: off | Effort: min | Model: script
### Block ג: צדדים / Parties
**ID:** `block-gimel`
**Akoma Ntoso:** `meta > references > TLCPerson` (appellants, respondents)
**CREAC role:** none
**Functional purpose (JWM):** Orientation — מזהה את הצדדים וב"כ. מגדיר את מסגרת הדיון.
**Content model:**
- Types: template-field
- Elements: עוררים + "נגד" + משיבים + ב"כ
- Sources: כתב ערר, כתב תשובה
**Constraints:**
- MUST: שם כל צד, "נגד" כמפריד
- MUST NOT: תוכן מהותי, תיאור הערר
- Dependencies: none
**Weight:** 1% (קבוע)
**Processing:**
- Generation type: template-fill
- Temperature: 0 | Thinking: off | Effort: min | Model: script
### Block ד: כותרת "החלטה" / Decision Title
**ID:** `block-dalet`
**Akoma Ntoso:** `body > judgment > header`
**CREAC role:** none
**Functional purpose (JWM):** Orientation — סימון פורמלי של תחילת ההחלטה.
**Content model:**
- Types: template-field
- Elements: מילה אחת: "החלטה"
- Sources: none
**Constraints:**
- MUST: David 16pt, bold, מרכז
- Dependencies: none
**Weight:** 0% (שורה אחת)
**Processing:**
- Generation type: template-fill
- Temperature: 0 | Thinking: off | Effort: min | Model: script
### Block ה: פתיחה / Opening
**ID:** `block-he`
**Akoma Ntoso:** `body > judgment > introduction`
**CREAC role:** C (מסקנה ראשונית — הצגת מה לפנינו)
**Functional purpose (JWM):** Orientation — מכוון את הקורא למהות הערר במשפט אחד. מגדיר "להלן" מרכזיים.
**Content model:**
- Types: narrative (1-2 סעיפים)
- Elements: numbered-para עם הגדרות "להלן"
- Sources: כתב ערר, החלטת ועדה מקומית
**Constraints:**
- MUST: "לפנינו...", הגדרת הוועדה המקומית, הגדרת התכנית/הבקשה, הגדרת המגרש
- MUST NOT: ניתוח, ערכי שיפוט, ציטוטים מצדדים
- Dependencies: block-gimel (שמות צדדים להגדרות)
**Weight:** 1% (קבוע — 1-2 סעיפים)
**Processing:**
- Generation type: paraphrase
- Temperature: 0.2 | Thinking: low | Effort: low | Model: sonnet
### Block ו: רקע עובדתי / Factual Background ("פתח דבר")
**ID:** `block-vav`
**Akoma Ntoso:** `body > judgment > background`
**CREAC role:** none (עובדות בלבד, לא ניתוח)
**Functional purpose (JWM):** Framing — מספק את התשתית העובדתית שעליה נבנה הדיון. השופט חייב להבין את המציאות בשטח לפני שקורא טענות.
**Content model:**
- Types: narrative, citation-block, image-placeholder
- Elements: numbered-para, blockquote (ציטוט מפרוטוקול), image-box
- Sources: כתבי טענות, תשריטים, פרוטוקולים, החלטות קודמות, GIS
**סדר תוכן פנימי:**
1. מקרקעין — מיקום, שטח, מאפיינים
2. סביבת מקרקעין — בנייה סמוכה, אופי
3. 📷 תמונה: מיקום GIS
4. היסטוריה תכנונית — תכניות, החלטות (עובדות יבשות בלבד)
5. מהות הבקשה/תכנית
6. 📷 תמונה: תשריט
7. ציטוט מפרוטוקול ועדה מקומית
8. החלטת הוועדה + תנאים
9. 📷 תמונה: צילום אוויר (אופציונלי)
10. הגשת הערר
**Constraints:**
- MUST: מקרקעין, מהות הבקשה, החלטת הוועדה, הגשת הערר
- MUST: לפחות 2 תמונות (מיקום + תשריט)
- MUST: ציטוט מפרוטוקול הוועדה המקומית
- ⚠️ **MUST NOT ("רקע ניטרלי"):** ציטוטים ישירים מצדדים, מילות ערך/שיפוט ("חריג", "חטא", "בעייתי"). החלטות קודמות = עובדה יבשה ("ביום X נדחתה תכנית Y"), ללא נימוקים וציטוטים מהן.
- Dependencies: block-he (הגדרות "להלן")
**Weight:**
| סוג ערר | משקל | הערות |
|---------|------|-------|
| רישוי — דחייה | 15-25% | רקע מפורט עם הקשר תכנוני |
| רישוי — קבלה | 30-40% | כולל ציטוט מפרוטוקול |
| רישוי — קבלה חלקית | 25-35% | כולל ציטוט מפרוטוקול |
| היטל השבחה | 6-18% | רקע מצומצם |
**Weight methodology:**
- Communicative weight (40%): גבוה — מספק את "התמונה" לשופט שלא מכיר את התיק
- Reader attention (20%): בינוני-גבוה — primacy effect, הקורא קשוב בהתחלה
- Judicial review (25%): גבוה — שופט בודק שהעובדות מלאות ומדויקות
- Empirical (15%): מבוסס על מדידת החלטות דפנה (3.2 ב-SKILL.md)
**Processing:**
- Generation type: reproduction (העתקה נאמנה ממקורות)
- Cognitive complexity: lookup (ארגון, לא ניתוח)
- Accuracy: high-precision
- Temperature: 0 | Thinking: off | Effort: low | Model: sonnet
### Block ז: טענות הצדדים / Parties' Claims
**ID:** `block-zayin`
**Akoma Ntoso:** `body > judgment > arguments`
**CREAC role:** none (הצגת טענות, לא ניתוח)
**Functional purpose (JWM):** Argumentation — מציג את עמדות הצדדים בנאמנות, כך שהקורא יבין את המחלוקת לפני שקורא את ההכרעה.
**Content model:**
- Types: narrative
- Elements: section-heading ("תמצית טענות הצדדים"), sub-headings (לכל צד), numbered-para
- Sources: כתב ערר, כתב תשובה — **כתבי טענות מקוריים בלבד** (לא השלמות טיעון)
**סדר קבוע:**
1. כותרת: "תמצית טענות הצדדים"
2. "טענות העוררים" (אם כמה עוררים — תתי-כותרות לכל אחד)
3. "עמדת הוועדה המקומית"
4. "עמדת מבקשי ההיתר" / "עמדת מגישי התכנית"
**Constraints:**
- MUST: כל טענה בסעיף נפרד, גוף שלישי ("העורר טוען כי...")
- MUST: כל צד בפרק נפרד, סדר קבוע
- MUST NOT: ניתוח, מסקנות, הערכת הוועדה ("טענה זו חלשה...")
- MUST NOT: תוכן מהשלמות טיעון (→ block-chet)
- Dependencies: block-vav (מספור רציף)
**Weight:**
| סוג ערר | משקל | הערות |
|---------|------|-------|
| רישוי — דחייה | 30-40% | טענות מפורטות |
| רישוי — קבלה | 20-30% | כולל השלמות |
| רישוי — קבלה חלקית | 25-30% | |
| היטל השבחה | 13-25% | |
**Weight methodology:**
- Communicative weight (40%): בינוני — הצגה, לא הכרעה
- Reader attention (20%): נמוך-בינוני — scanning attention, הקורא מחפש טענות ספציפיות
- Judicial review (25%): גבוה — שופט בודק ש"נשמעו כל הצדדים"
- Empirical (15%): מבוסס על מדידת החלטות דפנה
**Processing:**
- Generation type: paraphrase (סיכום נאמן בשפה של דפנה)
- Cognitive complexity: medium-synthesis (קיבוץ וסידור טענות)
- Accuracy: high-precision (לא לפספס טענה, לא לעוות)
- Temperature: 0.1 | Thinking: low | Effort: medium | Model: sonnet
### Block ח: הליכים בפני ועדת הערר / Proceedings
**ID:** `block-chet`
**Akoma Ntoso:** `body > judgment > proceedings` (custom extension)
**CREAC role:** none (תיעוד, לא ניתוח)
**Functional purpose (JWM):** Procedural record — מתעד שהוועדה פעלה כדין ונתנה מלוא יום בבית דין. קריטי ל"מבחן השופט" — שופט בעתמ"ם בודק שהצדדים קיבלו הזדמנות הוגנת.
**Content model:**
- Types: narrative, image-placeholder
- Elements: section-heading ("ההליכים בפני ועדת הערר"), numbered-para, image-box
- Sources: פרוטוקול דיון, תמונות סיור, החלטות ביניים, השלמות טיעון
**סדר כרונולוגי:**
1. דיון — תאריך, נוכחים
2. סיור — תאריך, תיאור
3. 📷 תמונה: צילומים מהסיור
4. השלמות טיעון — עם תוכן מפורט (כל השלמה = סעיף נפרד)
5. החלטות ביניים
6. תגובות לתגובות — כרונולוגי
7. 📷 תמונה: הדמיות/חתכים (אם צורפו)
8. עררים מקבילים (אם יש)
**Constraints:**
- MUST: תאריכים מדויקים, כרונולוגיה ברורה
- MUST: תוכן השלמות טיעון מפורט — כל השלמה בסעיף נפרד עם תמצית תוכן
- MUST NOT: ניתוח או הערכה של ההשלמות ("טענה חזקה/חלשה")
- Dependencies: block-zayin (מספור רציף)
- References: block-zayin (הפניה לטענות מקוריות כשיש חפיפה)
**Weight:**
| סוג ערר | משקל | הערות |
|---------|------|-------|
| ערר פשוט (ללא השלמות) | 3-5% | דיון + סיור בלבד |
| ערר מורכב (השלמות רבות) | 8-15% | כמו אריאלי: 31 סעיפים |
| היטל השבחה | 2-4% | בדרך כלל מינימלי |
**Weight methodology:**
- Communicative weight (40%): נמוך-בינוני — תיעוד, לא הכרעה
- Reader attention (20%): נמוך — scanning, אלא אם יש ממצאים חדשים מסיור/השלמות
- Judicial review (25%): **גבוה מאוד** — שופט בודק שנתנו procedural fairness
- Empirical (15%): מגוון רחב — תלוי בכמות ההשלמות
**Processing:**
- Generation type: reproduction + paraphrase (תאריכים מדויקים + תמצית תוכן)
- Cognitive complexity: low (סידור כרונולוגי)
- Accuracy: high-precision (תאריכים, שמות מסמכים)
- Temperature: 0 | Thinking: off | Effort: low | Model: sonnet
### Block ט: תכניות חלות / Applicable Plans (אופציונלי)
**ID:** `block-tet`
**Akoma Ntoso:** `body > judgment > motivation > background` (extended)
**CREAC role:** R (Rule — הצגת הכללים המשפטיים/תכנוניים)
**Functional purpose (JWM):** Deliberation (preliminary) — מציג את המסגרת הנורמטיבית שלאורה ייבחנו הטענות. בלוק גשר בין עובדות לניתוח.
**Content model:**
- Types: narrative, citation-block
- Elements: section-heading, numbered-para, blockquote (ציטוט מהוראות תכנית)
- Sources: הוראות תכנית (PDF), נספחי בינוי, החלטות מרכזות
**Constraints:**
- MUST: ציטוט ישיר מהוראות תכנית עם הדגשת (bold) מילים מכריעות
- MUST NOT: ניתוח מעמיק (→ block-yod), הכרעה בין פרשנויות
- Dependencies: block-chet (מספור), block-vav (הגדרות תכניות)
- Condition: **אופציונלי** — רק כשיש מורכבות תכנונית (תכניות סותרות, תמ"א 38 + שימור, פרשנות)
**Weight:**
| מתי קיים | משקל |
|----------|------|
| תמ"א 38 + שימור | 8-12% |
| פרשנות תכנית | 5-10% |
| לא קיים | 0% |
**Weight methodology:**
- Communicative weight (40%): בינוני — הנחת תשתית נורמטיבית
- Reader attention (20%): נמוך — טכני, אלא אם פרשנות שנויה במחלוקת
- Judicial review (25%): בינוני — שופט בודק שהוועדה הבינה את הדין
- Empirical (15%): אריאלי — 14 סעיפים; בית הכרם — משולב בדיון
**Processing:**
- Generation type: guided-synthesis (ציטוט + ניתוח ראשוני)
- Cognitive complexity: medium (פרשנות טקסט משפטי)
- Accuracy: precision + interpretation
- Temperature: 0.2 | Thinking: medium | Effort: medium | Model: opus
### Block י: דיון והכרעה / Discussion and Decision
**ID:** `block-yod`
**Akoma Ntoso:** `body > judgment > motivation`
**CREAC role:** **full-CREAC** — C (מסקנה בפתיחה) → R (כלל משפטי) → E (ציטוט פסיקה) → A (יישום על העובדות) → C (מסקנת ביניים)
**Functional purpose (JWM):** Deliberation — ליבת ההחלטה. כאן הוועדה מנתחת, מאזנת, ומכריעה. זהו ה-ratio decidendi.
**Content model:**
- Types: narrative, citation-block, image-placeholder
- Elements: numbered-para (אסה רציפה ללא כותרות משנה), blockquote (ציטוטי פסיקה ותכנית), image-box
- Sources: **כל** הבלוקים הקודמים + פסיקה + skill
**מבנה פנימי (לפי סוג ערר — ראה SKILL.md סעיף 7.3):**
- דחייה: שכבות הגנה (concentric circles)
- קבלה: נימוק-נימוק
- קבלה חלקית: מיפוי מתחים + ניתוח נושאי
- היטל השבחה: פתיחה ישירה עם מסקנה
**Constraints:**
- MUST: מסקנה בפתיחת הדיון (לא בסוף)
- MUST: מענה לכל טענה שהוצגה בבלוק ז
- MUST: ציטוט פסיקה בבלוקים ארוכים (200-600 מילים)
- ⚠️ **MUST NOT ("ללא כפילות"):** חזרה על עובדות/טענות מבלוקים קודמים. השתמש בהפניות: "כאמור בסעיף X לעיל", "כפי שפורט", "כפי שציינו"
- MUST NOT: כותרות משנה (חריג: נושאים נפרדים לחלוטין)
- Dependencies: **ALL** previous blocks (ה-ט)
**Weight:**
| סוג ערר | משקל | הערות |
|---------|------|-------|
| רישוי — דחייה | 37-50% | פתיחה רחבה + שכבות |
| רישוי — קבלה | 35-45% | נימוק-נימוק |
| רישוי — קבלה חלקית | 40-47% | מיפוי מתחים + ניתוח נושאי |
| היטל השבחה | 32-48% | ציטוטי פסיקה מרובים |
**Weight methodology:**
- Communicative weight (40%): **מקסימלי** — זהו ה-ratio decidendi, תכלית ההחלטה
- Reader attention (20%): **גבוה** — deep reading, הקורא מחפש את הנימוקים
- Judicial review (25%): **מקסימלי** — שופט בוחן סבירות, מידתיות, התייחסות לטענות
- Empirical (15%): 35-50% באופן עקבי בכל החלטות דפנה
**Processing:**
- Generation type: **rhetorical-construction** (בניית טיעון, איזון, רטוריקה)
- Cognitive complexity: **high-reasoning** (CREAC מלא, שכבות, חידוד)
- Accuracy: **precision + creativity** (ניתוח מדויק + ביטוי אלגנטי)
- Temperature: **0.4** | Thinking: **max (budget 16K+)** | Effort: **max** | Model: **opus בלבד**
### Block יא: סיכום / סוף דבר / Summary
**ID:** `block-yod-alef`
**Akoma Ntoso:** `body > judgment > decision`
**CREAC role:** C (Conclusion אחרון — תמצית אופרטיבית)
**Functional purpose (JWM):** Disposition — ההוראה האופרטיבית שמבצעים. זה מה שהצדדים צריכים לדעת "מה עכשיו."
**Content model:**
- Types: narrative
- Elements: section-heading ("סיכום"/"סוף דבר"), numbered-para, sub-items (א. ב. ג.)
- Sources: block-yod (מסקנות)
**מבנה לפי תוצאה (ראה SKILL.md סעיף 8):**
- דחייה: "הערר נדחה" + תתי-סעיפים + פסקה חמה (רישוי בלבד)
- קבלה: "הערר מתקבל בכפוף ל..." + פרוזה
- קבלה חלקית: "הערר מתקבל באופן חלקי" + 2-3 הוראות אופרטיביות
- היטל השבחה: יבש
**Constraints:**
- MUST: תוצאה ברורה (נדחה/מתקבל/מתקבל חלקית)
- MUST NOT (בקבלה חלקית): חזרה על נימוקים — ההנמקה כבר בדיון
- Dependencies: block-yod (מסקנות)
**Weight:**
| סוג ערר | משקל |
|---------|------|
| דחייה | 2-9% |
| קבלה | 3-5% |
| קבלה חלקית | 2-3% |
| היטל השבחה | 3-4% |
**Processing:**
- Generation type: paraphrase (עיבוד מסקנות בלוק י)
- Cognitive complexity: low
- Accuracy: high-precision (הוראות חייבות להיות חד-משמעיות)
- Temperature: 0.1 | Thinking: low | Effort: low | Model: sonnet
### Block יב: חתימות / Signatures
**ID:** `block-yod-bet`
**Akoma Ntoso:** `conclusions > signature`
**CREAC role:** none
**Functional purpose (JWM):** Authentication — אישור פורמלי של ההחלטה.
**Content model:**
- Types: template-field
- Elements: "ניתנה פה אחד" + תאריך עברי/לועזי + טבלת חתימות
- Sources: none
**Constraints:**
- MUST: "ניתנה פה אחד", תאריך, יו"ר + מזכיר/ה
- Dependencies: none
**Weight:** 1% (קבוע)
**Processing:**
- Generation type: template-fill
- Temperature: 0 | Thinking: off | Effort: min | Model: script
---
## 3. כללי גזירת פרמטרים
פרמטרי העיבוד נגזרים ממאפייני התוכן, לא נקבעים שרירותית:
### Temperature — נגזר מסוג הייצור
| Generation type | Temperature | נימוק |
|----------------|-------------|-------|
| template-fill | 0 | אין צורך בשפה — מילוי שדות |
| reproduction | 0 | נאמנות מוחלטת למקור. אפס יצירתיות |
| paraphrase | 0.1 | מרווח מינימלי לניסוח בשפה של דפנה |
| guided-synthesis | 0.2 | גמישות בארגון וחיבור מקורות, לא בתוכן |
| analytical-reasoning | 0.3-0.4 | צריך ליצור קשרים בין עקרונות משפטיים |
| rhetorical-construction | 0.4-0.5 | טווח ביטוי רחב לכתיבה משכנעת ואלגנטית |
### Thinking budget — נגזר ממורכבות קוגניטיבית
| Cognitive task | Budget | נימוק |
|---------------|--------|-------|
| template-fill / lookup | off | אין צורך בחשיבה |
| sequential-extraction | low | חילוץ מידע חד-שלבי |
| multi-source-integration | medium | צריך להצליב מקורות |
| legal-analysis-with-CREAC | max (16K+) | חשיבה רב-שלבית: מסקנה → כלל → הסבר → יישום |
### Model — נגזר מדרישת דיוק
| Accuracy profile | Model | נימוק |
|-----------------|-------|-------|
| factual-precision | sonnet | מהיר, מדויק לחילוץ עובדות |
| precision + interpretation | opus | נדרש לפרשנות תכנית / ציטוט מובנה |
| precision + creativity | opus | נדרש לניתוח משפטי מורכב ורטוריקה |
---
## 4. מתודולוגיית משקלות
משקל כל בלוק נקבע על ידי שקלול 4 גורמים:
### 4.1 Communicative Weight (40%)
מה חלקו של הבלוק בתכלית ההחלטה? ההחלטה באה לעשות דבר אחד: להכריע במחלוקת ולנמק. בלוק י (דיון) הוא ליבת התכלית. בלוקים א-ד (כותרות) הם עטיפה.
### 4.2 Reader Attention Distribution (20%)
מבוסס על מחקרי F-pattern ו-primacy/recency:
- **פתיחה** (בלוקים ה-ו): קשב גבוה (primacy effect)
- **אמצע** (בלוקים ז-ח): scanning — הקורא מחפש טענות ספציפיות
- **דיון** (בלוק י): deep reading — הקורא מחפש נימוקים
- **סיום** (בלוק יא): קשב גבוה (recency effect)
### 4.3 Judicial Review Requirement (25%)
מה שופט בבית משפט לעניינים מנהליים יבדוק ("מבחן השופט"):
- **תשתית עובדתית** (בלוק ו): מלאה ומדויקת?
- **שמיעת צדדים** (בלוקים ז-ח): נתנו מלוא יום בבית דין?
- **סבירות ומידתיות** (בלוק י): ההכרעה מנומקת ומאוזנת?
- **התייחסות לטענות** (בלוק י): כל טענה קיבלה מענה?
### 4.4 Empirical Basis (15%)
מבוסס על מדידה מהחלטות שפורסמו:
- הכט 1180-1181 (דחייה, 02.2026)
- בית הכרם 1126/25 (קבלה חלקית, 03.2026)
- אריאלי 1078+1083 (קבלה, 03.2026)
המשקלות ב-SKILL.md סעיף 3.2 (יחסי הזהב) משמשים כבסיס אמפירי שאומת על ידי שלושת הגורמים האנליטיים.
---
## 5. כללי ולידציה
### 5.1 סדר בלוקים
- בלוקים חייבים להופיע בסדר א עד יב
- בלוקים א-ה ויב נדרשים בכל החלטה
- בלוק ט אופציונלי (רק כשיש מורכבות תכנונית)
### 5.2 Content Constraints
- **רקע ניטרלי (בלוק ו):** אם סעיף מכיל ציטוט ישיר מצד או מילת שיפוט → לא שייך כאן
- **טענות מקוריות בלבד (בלוק ז):** רק מכתבי ערר/תשובה. השלמות → בלוק ח
- **ללא כפילות (בלוק י):** הפניה לבלוקים קודמים, לא חזרה. חריג: "נשוב על כך כי..." (חזרה מכוונת עם שכבה חדשה)
- **הליכים ללא הערכה (בלוק ח):** תיעוד מה הוגש, לא הערכה של חוזק הטענות
### 5.3 Weight Compliance
- משקל כל בלוק (ספירת מילים / סה"כ) צריך להיות בטווח המוגדר **±10%**
- אם בלוק י < 30% → flag: דיון לא מפותח מספיק
- אם בלוק ו > 35% → flag: רקע מנופח, בדוק שאין תוכן טענתי
### 5.4 Structural Integrity
- מספור סעיפים רציף מ-1 עד הסוף, ללא איפוס בין בלוקים
- כל הגדרת "להלן" חייבת להופיע לפני השימוש הראשון בה
- כל טענה בבלוק ז חייבת לקבל מענה בבלוק י (ישיר או "למעלה מן הצורך")
- כותרות פרקים: David 14pt, bold, קו תחתון, מרכז
- כותרות משנה: David 12pt, bold, מרכז, ללא קו תחתון
---
## 6. גרף תלויות בין בלוקים
```
א (כותרת) → עצמאי
ב (הרכב) → עצמאי
ג (צדדים) → עצמאי
ד (כותרת) → עצמאי
ה (פתיחה) → תלוי ב: ג (שמות צדדים להגדרות "להלן")
ו (רקע) → תלוי ב: ה (הגדרות). מספור ממשיך מ-ה.
ז (טענות) → תלוי ב: ו (מספור). מפנה ל: ה, ו (הגדרות)
ח (הליכים) → תלוי ב: ז (מספור). מפנה ל: ז (טענות מקוריות)
ט (תכניות) → תלוי ב: ח (מספור). אופציונלי. מפנה ל: ו (הגדרות תכניות)
י (דיון) → תלוי ב: **כל** הבלוקים ה-ט. מפנה ל: כולם.
יא (סיכום) → תלוי ב: י (מסקנות). מפנה ל: י בלבד.
יב (חתימות) → עצמאי
```

View File

@@ -0,0 +1 @@
../../../docs/block-schema.md

Binary file not shown.

View File

@@ -1,6 +1,6 @@
"use client";
import { use } from "react";
import { use, useRef, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
@@ -25,6 +25,103 @@ function ProseSection({ title, content }: { title: string; content?: string }) {
);
}
function AnalysisActions({
caseNumber,
hasAnalysis,
onUploaded,
}: {
caseNumber: string;
hasAnalysis: boolean;
onUploaded: () => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [uploadMsg, setUploadMsg] = useState<{ ok: boolean; text: string } | null>(null);
async function handleUpload(file: File) {
setUploading(true);
setUploadMsg(null);
try {
const form = new FormData();
form.append("file", file);
const res = await fetch(`/api/cases/${caseNumber}/research/analysis/upload`, {
method: "PUT",
body: form,
});
const data = await res.json();
if (!res.ok) {
setUploadMsg({ ok: false, text: data.detail || "שגיאה בהעלאה" });
return;
}
setUploadMsg({
ok: true,
text: `הקובץ הועלה בהצלחה — ${data.sections.threshold_claims} טענות סף, ${data.sections.issues} סוגיות`,
});
onUploaded();
} catch {
setUploadMsg({ ok: false, text: "שגיאת רשת" });
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
}
return (
<div className="flex items-center gap-2 flex-wrap">
{uploadMsg && (
<span className={`text-xs ${uploadMsg.ok ? "text-green-700" : "text-red-600"}`}>
{uploadMsg.text}
</span>
)}
<input
ref={fileRef}
type="file"
accept=".md"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleUpload(f);
}}
/>
<Button
variant="outline"
disabled={uploading}
onClick={() => fileRef.current?.click()}
>
{uploading ? "מעלה..." : "העלה ניתוח מעודכן"}
</Button>
{hasAnalysis && (
<Button
variant="outline"
onClick={() => {
const a = document.createElement("a");
a.href = `/api/cases/${caseNumber}/research/analysis/download`;
a.download = `analysis-${caseNumber}.md`;
a.click();
}}
>
הורד ניתוח
</Button>
)}
{hasAnalysis && (
<Button
variant="outline"
onClick={() => {
const a = document.createElement("a");
a.href = `/api/cases/${caseNumber}/research/analysis/export-docx`;
a.click();
}}
>
הורד כ-DOCX
</Button>
)}
<Button asChild variant="outline">
<Link href={`/cases/${caseNumber}`}>חזרה לתיק</Link>
</Button>
</div>
);
}
export default function ComposePage({
params,
}: {
@@ -78,24 +175,7 @@ export default function ComposePage({
</p>
)}
</div>
<div className="flex items-center gap-2">
{analysis.data && (
<Button
variant="outline"
onClick={() => {
const a = document.createElement("a");
a.href = `/api/cases/${caseNumber}/research/analysis/download`;
a.download = `analysis-${caseNumber}.md`;
a.click();
}}
>
הורד ניתוח
</Button>
)}
<Button asChild variant="outline">
<Link href={`/cases/${caseNumber}`}>חזרה לתיק</Link>
</Button>
</div>
<AnalysisActions caseNumber={caseNumber} hasAnalysis={!!analysis.data} onUploaded={() => analysis.refetch()} />
</div>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />

View File

@@ -13,9 +13,14 @@ import { WorkflowTimeline } from "@/components/cases/workflow-timeline";
import { StatusGuide } from "@/components/cases/status-guide";
import { StatusChanger } from "@/components/cases/status-changer";
import { DocumentsPanel } from "@/components/cases/documents-panel";
import { DraftsPanel } from "@/components/cases/drafts-panel";
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
import { UploadSheet } from "@/components/documents/upload-sheet";
import { expectedOutcomes } from "@/lib/schemas/case";
import { useCase } from "@/lib/api/cases";
import { useCase, useStartWorkflow } from "@/lib/api/cases";
import { toast } from "sonner";
import { Play, Loader2 } from "lucide-react";
const EXPECTED_OUTCOME_LABELS: Record<string, string> = Object.fromEntries(
expectedOutcomes.map((o) => [o.value, o.label]),
@@ -32,6 +37,8 @@ export default function CaseDetailPage({
}) {
const { caseNumber } = use(params);
const { data, isPending, error } = useCase(caseNumber);
const startWorkflow = useStartWorkflow(caseNumber);
const canStartWorkflow = data?.status === "new" || data?.status === "documents_ready";
const expectedOutcomeLabel = data?.expected_outcome
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
: null;
@@ -78,6 +85,12 @@ export default function CaseDetailPage({
</span>
)}
</TabsTrigger>
<TabsTrigger value="drafts">
טיוטות והערות
</TabsTrigger>
<TabsTrigger value="agents">
סוכנים
</TabsTrigger>
</TabsList>
<UploadSheet caseNumber={caseNumber} />
</div>
@@ -103,6 +116,29 @@ export default function CaseDetailPage({
</dl>
</div>
<div className="flex items-center gap-3 flex-wrap pt-2 border-t border-rule">
{canStartWorkflow && (
<Button
className="bg-gold-deep hover:bg-gold-deep/90 text-parchment"
disabled={startWorkflow.isPending}
onClick={() =>
startWorkflow.mutate(undefined, {
onSuccess: (res) =>
toast.success(
`תהליך הופעל — ${res.issue_identifier}`,
),
onError: (err) =>
toast.error(`שגיאה: ${err.message}`),
})
}
>
{startWorkflow.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
) : (
<Play className="w-4 h-4 me-1.5" />
)}
התחל תהליך
</Button>
)}
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
<Link href={`/cases/${caseNumber}/compose`}>
פתח בעורך ההחלטה
@@ -115,12 +151,24 @@ export default function CaseDetailPage({
<TabsContent value="documents" className="mt-5">
<DocumentsPanel data={data} />
</TabsContent>
<TabsContent value="drafts" className="mt-5">
<DraftsPanel
caseNumber={caseNumber}
status={data?.status}
/>
</TabsContent>
<TabsContent value="agents" className="mt-5">
<AgentActivityFeed caseNumber={caseNumber} />
</TabsContent>
</Tabs>
</CardContent>
</Card>
<Card className="bg-surface border-rule shadow-sm h-fit">
<CardContent className="px-6 py-5">
<CardContent className="px-6 py-5 space-y-5">
<AgentStatusWidget caseNumber={caseNumber} />
<h2 className="text-navy text-base mb-4">שלב בתהליך</h2>
<WorkflowTimeline status={data?.status} />
<StatusChanger caseNumber={caseNumber} currentStatus={data?.status} />

View File

@@ -1,329 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
useFeedbackList,
useCreateFeedback,
useResolveFeedback,
CATEGORY_LABELS,
BLOCK_LABELS,
type FeedbackCategory,
} from "@/lib/api/feedback";
import { toast } from "sonner";
const CATEGORY_COLORS: Record<FeedbackCategory, string> = {
missing_content: "bg-amber-100 text-amber-800 border-amber-200",
wrong_tone: "bg-purple-100 text-purple-800 border-purple-200",
wrong_structure: "bg-blue-100 text-blue-800 border-blue-200",
factual_error: "bg-red-100 text-red-800 border-red-200",
style: "bg-emerald-100 text-emerald-800 border-emerald-200",
other: "bg-gray-100 text-gray-800 border-gray-200",
};
export default function FeedbackPage() {
const [showResolved, setShowResolved] = useState(false);
const [filterCategory, setFilterCategory] = useState<string>("");
const { data: feedbacks, isLoading } = useFeedbackList({
category: filterCategory || undefined,
unresolved_only: !showResolved,
});
const resolveMutation = useResolveFeedback();
function handleResolve(id: string) {
resolveMutation.mutate(
{ feedbackId: id, applied_to: [] },
{
onSuccess: () => toast.success("ההערה סומנה כמטופלת"),
onError: () => toast.error("שגיאה בעדכון"),
},
);
}
return (
<AppShell>
<section className="space-y-6">
<header>
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">
בית
</Link>
<span aria-hidden> &middot; </span>
<span className="text-navy">הערות יו״ר</span>
</nav>
<h1 className="text-navy mb-0">הערות יו״ר על טיוטות</h1>
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
תיעוד הערות דפנה על טיוטות החלטות. כל הערה מנותחת ומשפיעה על שיפור
כתיבת ההחלטות העתידיות.
</p>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap">
<NewFeedbackDialog />
<select
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value)}
className="rounded-md border border-rule bg-surface px-3 py-1.5 text-sm"
>
<option value="">כל הקטגוריות</option>
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
<label className="flex items-center gap-2 text-sm text-ink-muted cursor-pointer">
<input
type="checkbox"
checked={showResolved}
onChange={(e) => setShowResolved(e.target.checked)}
className="rounded border-rule"
/>
הצג גם מטופלות
</label>
{feedbacks && (
<span className="text-sm text-ink-muted me-auto">
{feedbacks.length} הערות
</span>
)}
</div>
{/* Feedback list */}
{isLoading ? (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-8 text-center text-ink-muted">
טוען...
</CardContent>
</Card>
) : !feedbacks?.length ? (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-8 text-center text-ink-muted">
אין הערות{!showResolved ? " פתוחות" : ""}
{filterCategory ? ` בקטגוריה ${CATEGORY_LABELS[filterCategory as FeedbackCategory]}` : ""}
</CardContent>
</Card>
) : (
<div className="space-y-3">
{feedbacks.map((fb) => (
<Card
key={fb.id}
className={`bg-surface border-rule shadow-sm ${fb.resolved ? "opacity-60" : ""}`}
>
<CardHeader className="border-b pb-3">
<div className="flex items-center gap-2 flex-wrap">
<Badge
className={`text-[0.7rem] border ${CATEGORY_COLORS[fb.category]}`}
>
{CATEGORY_LABELS[fb.category]}
</Badge>
<Badge variant="outline" className="text-[0.7rem]">
{BLOCK_LABELS[fb.block_id] ?? fb.block_id}
</Badge>
{fb.case_number && (
<Link
href={`/cases/${fb.case_number}`}
className="text-[0.7rem] text-gold-deep hover:underline"
>
תיק {fb.case_number}
</Link>
)}
{fb.resolved && (
<Badge className="bg-emerald-100 text-emerald-700 text-[0.7rem] border border-emerald-200">
טופל
</Badge>
)}
<span className="text-[0.7rem] text-ink-muted me-auto">
{fb.created_at
? new Date(fb.created_at).toLocaleDateString("he-IL")
: ""}
</span>
</div>
</CardHeader>
<CardContent className="px-6 py-4 space-y-3">
<p className="text-sm leading-relaxed">{fb.feedback_text}</p>
{fb.lesson_extracted && (
<div className="bg-gold/5 border border-gold/20 rounded-md px-4 py-3">
<p className="text-[0.7rem] font-semibold text-gold-deep mb-1">
לקח שהופק:
</p>
<p className="text-sm text-ink-muted leading-relaxed">
{fb.lesson_extracted}
</p>
</div>
)}
{!fb.resolved && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => handleResolve(fb.id)}
disabled={resolveMutation.isPending}
>
סמן כמטופל
</Button>
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</section>
</AppShell>
);
}
/* ── New feedback dialog ─────────────────────────────────── */
function NewFeedbackDialog() {
const [open, setOpen] = useState(false);
const createMutation = useCreateFeedback();
const [caseNumber, setCaseNumber] = useState("");
const [blockId, setBlockId] = useState("block-yod");
const [category, setCategory] = useState<FeedbackCategory>("missing_content");
const [feedbackText, setFeedbackText] = useState("");
const [lesson, setLesson] = useState("");
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!feedbackText.trim()) return;
createMutation.mutate(
{
case_number: caseNumber || undefined,
block_id: blockId,
feedback_text: feedbackText,
category,
lesson_extracted: lesson || undefined,
},
{
onSuccess: () => {
toast.success("ההערה נרשמה בהצלחה");
setOpen(false);
setCaseNumber("");
setFeedbackText("");
setLesson("");
},
onError: () => toast.error("שגיאה ברישום ההערה"),
},
);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>+ הערה חדשה</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg" dir="rtl">
<DialogHeader>
<DialogTitle>רישום הערת יו״ר</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="fb-case">מספר תיק (אופציונלי)</Label>
<Input
id="fb-case"
value={caseNumber}
onChange={(e) => setCaseNumber(e.target.value)}
placeholder="1130-25"
/>
</div>
<div>
<Label htmlFor="fb-block">בלוק</Label>
<select
id="fb-block"
value={blockId}
onChange={(e) => setBlockId(e.target.value)}
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
>
{Object.entries(BLOCK_LABELS).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</div>
</div>
<div>
<Label htmlFor="fb-category">קטגוריה</Label>
<select
id="fb-category"
value={category}
onChange={(e) => setCategory(e.target.value as FeedbackCategory)}
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
>
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="fb-text">ההערה</Label>
<Textarea
id="fb-text"
value={feedbackText}
onChange={(e) => setFeedbackText(e.target.value)}
placeholder="מה דפנה אמרה? מה חסר, מה לא נכון, מה צריך לשנות..."
rows={4}
required
/>
</div>
<div>
<Label htmlFor="fb-lesson">לקח שהופק (אופציונלי)</Label>
<Textarea
id="fb-lesson"
value={lesson}
onChange={(e) => setLesson(e.target.value)}
placeholder="מה למדנו מההערה? מה צריך לשנות במערכת?"
rows={2}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
ביטול
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? "שומר..." : "שמור הערה"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { GoldenRatiosPanel } from "@/components/methodology/golden-ratios-panel";
import { DiscussionRulesPanel } from "@/components/methodology/discussion-rules-panel";
import { ContentChecklistsPanel } from "@/components/methodology/content-checklists-panel";
export default function MethodologyPage() {
return (
<AppShell>
<section className="space-y-6">
<div>
<h1 className="text-xl font-bold text-navy">מתודולוגיה</h1>
<p className="text-sm text-ink-muted mt-1">
הגדרות ניסוח יחסי אורך, כללי דיון, וצ׳קליסטים לפי סוג ערר
</p>
</div>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<Tabs defaultValue="ratios" dir="rtl">
<TabsList className="bg-rule-soft/60">
<TabsTrigger value="ratios">יחסי זהב</TabsTrigger>
<TabsTrigger value="rules">כללי דיון</TabsTrigger>
<TabsTrigger value="checklists">צ׳קליסטים</TabsTrigger>
</TabsList>
<TabsContent value="ratios" className="mt-5">
<GoldenRatiosPanel />
</TabsContent>
<TabsContent value="rules" className="mt-5">
<DiscussionRulesPanel />
</TabsContent>
<TabsContent value="checklists" className="mt-5">
<ContentChecklistsPanel />
</TabsContent>
</Tabs>
</CardContent>
</Card>
</section>
</AppShell>
);
}

View File

@@ -0,0 +1,252 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Plus, Trash2, Tags, Building2 } from "lucide-react";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
useTagMappings,
usePaperclipCompanies,
useAddTagMapping,
useDeleteTagMapping,
} from "@/lib/api/settings";
import { APPEAL_SUBTYPES } from "@/lib/practice-area";
import { toast } from "sonner";
const TAG_SUGGESTIONS = APPEAL_SUBTYPES.filter((s) => s.value !== "unknown");
export default function SettingsPage() {
const { data: mappings, isPending: loadingMappings } = useTagMappings();
const { data: companies, isPending: loadingCompanies } = usePaperclipCompanies();
const addMapping = useAddTagMapping();
const deleteMapping = useDeleteTagMapping();
const [tag, setTag] = useState("");
const [tagLabel, setTagLabel] = useState("");
const [companyId, setCompanyId] = useState("");
function handleTagInput(value: string) {
setTag(value);
const match = TAG_SUGGESTIONS.find((s) => s.value === value);
if (match) setTagLabel(match.label);
}
function handleAdd() {
if (!tag || !companyId) {
toast.error("יש לבחור תגית וחברה");
return;
}
const company = companies?.find((c) => c.id === companyId);
addMapping.mutate(
{
tag,
tag_label: tagLabel,
company_id: companyId,
company_name: company?.name ?? "",
},
{
onSuccess: () => {
toast.success("מיפוי נוסף בהצלחה");
setTag("");
setTagLabel("");
setCompanyId("");
},
onError: (err) => toast.error(`שגיאה: ${err.message}`),
},
);
}
function handleDelete(id: string, tag: string) {
deleteMapping.mutate(id, {
onSuccess: () => toast.success(`מיפוי "${tag}" נמחק`),
onError: (err) => toast.error(`שגיאה: ${err.message}`),
});
}
return (
<AppShell>
<section className="space-y-6">
<header>
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">
בית
</Link>
<span aria-hidden> · </span>
<span className="text-navy">הגדרות</span>
</nav>
<h1 className="text-navy mb-0">הגדרות</h1>
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
ניהול מיפוי תגיות ערר לחברות ב-Paperclip. כל תיק חדש ישויך
אוטומטית לפרויקט בחברה הנכונה לפי סוג הערר.
</p>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
{/* Companies overview */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-3 flex items-center gap-2">
<Building2 className="w-4 h-4" />
חברות ב-Paperclip
</h2>
{loadingCompanies ? (
<Skeleton className="h-12 w-full" />
) : !companies?.length ? (
<p className="text-ink-muted text-sm">לא נמצאו חברות</p>
) : (
<div className="flex flex-wrap gap-3">
{companies.map((c) => (
<div
key={c.id}
className="flex items-center gap-2 rounded-md bg-rule-soft/60 border border-rule px-4 py-2.5"
>
<span className="text-sm font-medium text-ink">{c.name}</span>
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
{c.prefix}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Tag mappings */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-4 flex items-center gap-2">
<Tags className="w-4 h-4" />
מיפוי תגיות
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
{mappings?.length ?? 0}
</Badge>
</h2>
{/* Add form */}
<div className="flex flex-wrap items-end gap-3 mb-5 p-4 rounded-md bg-rule-soft/40 border border-rule">
<div className="flex flex-col gap-1.5 min-w-[180px]">
<label className="text-[0.72rem] text-ink-muted">
תגית
</label>
<Input
list="tag-suggestions"
value={tag}
onChange={(e) => handleTagInput(e.target.value)}
placeholder="סוג ערר או תגית חופשית"
className="w-[220px]"
/>
<datalist id="tag-suggestions">
{TAG_SUGGESTIONS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</datalist>
</div>
<div className="flex flex-col gap-1.5 min-w-[140px]">
<label className="text-[0.72rem] text-ink-muted">תווית</label>
<Input
value={tagLabel}
onChange={(e) => setTagLabel(e.target.value)}
placeholder="שם לתצוגה"
className="w-[160px]"
/>
</div>
<div className="flex flex-col gap-1.5 min-w-[200px]">
<label className="text-[0.72rem] text-ink-muted">
חברה ב-Paperclip
</label>
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder="בחר חברה" />
</SelectTrigger>
<SelectContent>
{companies?.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name} ({c.prefix})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
onClick={handleAdd}
disabled={addMapping.isPending || !tag || !companyId}
size="default"
>
<Plus className="w-4 h-4" data-icon="inline-start" />
{addMapping.isPending ? "שומר..." : "הוסף מיפוי"}
</Button>
</div>
{/* Table */}
{loadingMappings ? (
<Skeleton className="h-32 w-full" />
) : !mappings?.length ? (
<p className="text-ink-muted text-sm">
אין מיפויים. הוסף מיפוי כדי שתיקים חדשים ישויכו אוטומטית
לפרויקט בחברה הנכונה.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-rule text-ink-muted text-[0.72rem] uppercase tracking-wider">
<th className="text-start py-2 px-3 font-medium">Tag</th>
<th className="text-start py-2 px-3 font-medium">Label</th>
<th className="text-start py-2 px-3 font-medium">Company</th>
<th className="py-2 px-3 w-12" />
</tr>
</thead>
<tbody>
{mappings.map((m) => (
<tr
key={m.id}
className="border-b border-rule/60 hover:bg-rule-soft/40 transition-colors"
>
<td className="py-2.5 px-3">
<Badge variant="outline" className="text-[0.75rem] font-mono">
{m.tag}
</Badge>
</td>
<td className="py-2.5 px-3 text-ink">{m.tag_label}</td>
<td className="py-2.5 px-3 text-ink">{m.company_name}</td>
<td className="py-2.5 px-3">
<Button
variant="ghost"
size="icon-xs"
onClick={() => handleDelete(m.id, m.tag)}
disabled={deleteMapping.isPending}
title="מחק מיפוי"
>
<Trash2 className="w-3.5 h-3.5 text-danger" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</section>
</AppShell>
);
}

View File

@@ -24,11 +24,11 @@ type NavItem = {
const NAV_ITEMS: NavItem[] = [
{ href: "/", label: "בית" },
{ href: "/cases/new", label: "תיק חדש" },
{ href: "/training", label: "אימון סגנון" },
{ href: "/feedback", label: "הערות יו״ר" },
{ href: "/methodology", label: "מתודולוגיה" },
{ href: "/skills", label: "מיומנויות" },
{ href: "/diagnostics", label: "אבחון" },
{ href: "/settings", label: "הגדרות" },
];
function isActive(pathname: string, href: string): boolean {

View File

@@ -0,0 +1,293 @@
"use client";
import { useRef, useState, useEffect } from "react";
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 { useAgentActivity, useSendComment } from "@/lib/api/agents";
import type { PaperclipComment } from "@/lib/api/agents";
import { toast } from "sonner";
import {
Bot,
User,
Send,
Loader2,
MessageSquare,
Clock,
} from "lucide-react";
/* ── Role → color mapping ────────────────────────────────────── */
const ROLE_COLORS: Record<string, string> = {
ceo: "bg-blue-100 text-blue-800 border-blue-200",
researcher: "bg-purple-100 text-purple-800 border-purple-200",
engineer: "bg-emerald-100 text-emerald-800 border-emerald-200",
qa: "bg-amber-100 text-amber-800 border-amber-200",
};
const ROLE_DOT: Record<string, string> = {
ceo: "bg-blue-500",
researcher: "bg-purple-500",
engineer: "bg-emerald-500",
qa: "bg-amber-500",
};
const ROLE_LABELS: Record<string, string> = {
ceo: "מנהל",
researcher: "חוקר",
engineer: "מהנדס",
qa: "בודק איכות",
general: "כללי",
};
const ISSUE_STATUS_LABELS: Record<string, string> = {
backlog: "ממתין",
todo: "לביצוע",
in_progress: "בביצוע",
in_review: "בבדיקה",
done: "הושלם",
cancelled: "בוטל",
blocked: "חסום",
};
function roleColor(role: string | null) {
return ROLE_COLORS[role ?? ""] ?? "bg-gray-100 text-gray-700 border-gray-200";
}
function roleDot(role: string | null) {
return ROLE_DOT[role ?? ""] ?? "bg-gray-400";
}
function roleLabel(role: string | null) {
return ROLE_LABELS[role ?? ""] ?? role ?? "";
}
function issueStatusLabel(status: string) {
return ISSUE_STATUS_LABELS[status] ?? status;
}
/* ── Time formatting ─────────────────────────────────────────── */
function timeAgo(iso: string | null): string {
if (!iso) return "";
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "עכשיו";
if (mins < 60) return `לפני ${mins} דק׳`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `לפני ${hours} שע׳`;
const days = Math.floor(hours / 24);
return `לפני ${days} ימים`;
}
/* ── Issue identifier → find matching identifier ─────────────── */
function issueIdentifier(
comment: PaperclipComment,
issueMap: Map<string, string>,
): string {
return issueMap.get(comment.issue_id) ?? "";
}
/* ── Comment card ────────────────────────────────────────────── */
function CommentCard({
comment,
issueMap,
}: {
comment: PaperclipComment;
issueMap: Map<string, string>;
}) {
const isAgent = !!comment.author_agent_id;
const label = isAgent ? comment.agent_name ?? "סוכן" : "חיים";
const identifier = issueIdentifier(comment, issueMap);
return (
<div className="group relative flex gap-3 py-3 px-2 rounded-lg hover:bg-sand-soft/50 transition-colors">
{/* Avatar */}
<div className="flex-shrink-0 pt-0.5">
{isAgent ? (
<div
className={`w-8 h-8 rounded-full flex items-center justify-center ${roleColor(comment.agent_role)}`}
>
<Bot className="w-4 h-4" />
</div>
) : (
<div className="w-8 h-8 rounded-full flex items-center justify-center bg-gold-soft text-gold-deep border border-gold">
<User className="w-4 h-4" />
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-sm font-semibold text-navy">{label}</span>
{isAgent && comment.agent_role && (
<span
className={`inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded-full border ${roleColor(comment.agent_role)}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${roleDot(comment.agent_role)}`} />
{roleLabel(comment.agent_role)}
</span>
)}
{identifier && (
<Badge variant="outline" className="text-[10px] font-mono">
{identifier}
</Badge>
)}
<span className="text-[11px] text-ink-faint mr-auto flex items-center gap-1">
<Clock className="w-3 h-3" />
{timeAgo(comment.created_at)}
</span>
</div>
{/* Body */}
<div className="text-sm">
<Markdown content={comment.body} />
</div>
</div>
</div>
);
}
/* ── Main Feed ───────────────────────────────────────────────── */
export function AgentActivityFeed({
caseNumber,
}: {
caseNumber: string;
}) {
const { data, isLoading, error } = useAgentActivity(caseNumber);
const sendComment = useSendComment(caseNumber);
const [body, setBody] = useState("");
const endRef = useRef<HTMLDivElement>(null);
// Build issue_id → identifier map
const issueMap = new Map<string, string>();
if (data?.issues) {
for (const iss of data.issues) {
issueMap.set(iss.id, iss.identifier);
}
}
// Auto-scroll on new comments
const commentCount = data?.comments?.length ?? 0;
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [commentCount]);
const handleSend = () => {
if (!body.trim()) return;
sendComment.mutate(
{ body: body.trim() },
{
onSuccess: (res) => {
setBody("");
toast.success(`נשלח ל-${res.issue_identifier}`);
},
onError: () => toast.error("שגיאה בשליחת ההודעה"),
},
);
};
// ── Empty / loading states ──
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-ink-faint">
<Loader2 className="w-5 h-5 animate-spin ml-2" />
<span>טוען פעילות סוכנים...</span>
</div>
);
}
if (error) {
return (
<div className="text-center py-12 text-red-500 text-sm">
שגיאה בטעינת פעילות סוכנים
</div>
);
}
if (!data?.issues?.length) {
return (
<div className="text-center py-12 space-y-2">
<MessageSquare className="w-10 h-10 mx-auto text-ink-faint/40" />
<p className="text-sm text-ink-faint">
התהליך טרם הופעל. לחץ &quot;התחל תהליך&quot; בלשונית סקירה.
</p>
</div>
);
}
const comments = data.comments ?? [];
return (
<div className="flex flex-col h-full">
{/* 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>
))}
</div>
{/* Comments stream */}
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
{comments.length === 0 ? (
<div className="text-center py-8 text-ink-faint text-sm">
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
הסוכנים התחילו לעבוד, ממתין לדיווח ראשון...
</div>
) : (
comments.map((c) => (
<CommentCard key={c.id} comment={c} issueMap={issueMap} />
))
)}
<div ref={endRef} />
</div>
{/* Comment input */}
<div className="border-t border-rule pt-3 mt-3 space-y-2">
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="כתוב הוראה לסוכנים..."
className="min-h-[60px] resize-none text-sm"
dir="rtl"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSend();
}
}}
/>
<div className="flex items-center justify-between">
<span className="text-[11px] text-ink-faint">
ההודעה תנותב דרך סוכן ה-CEO &middot; Ctrl+Enter לשליחה
</span>
<Button
size="sm"
onClick={handleSend}
disabled={!body.trim() || sendComment.isPending}
>
{sendComment.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4 ml-1" />
)}
שלח
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { useAgentActivity } from "@/lib/api/agents";
import type { PaperclipAgent } from "@/lib/api/agents";
import { Bot } from "lucide-react";
/* ── Status dot colors ───────────────────────────────────────── */
const STATUS_DOT: Record<string, string> = {
active: "bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.6)]",
running: "bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.6)]",
idle: "bg-gray-300",
error: "bg-red-500",
};
const STATUS_LABEL: Record<string, string> = {
active: "פעיל",
running: "פעיל",
idle: "ממתין",
error: "שגיאה",
};
function statusDot(status: string) {
return STATUS_DOT[status] ?? STATUS_DOT.idle;
}
/* ── Agent row ───────────────────────────────────────────────── */
function AgentRow({ agent }: { agent: PaperclipAgent }) {
return (
<div className="flex items-center gap-2 py-1">
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${statusDot(agent.status)}`}
/>
<span className="text-xs text-ink truncate">{agent.name}</span>
<span className="text-[10px] text-ink-faint mr-auto">
{STATUS_LABEL[agent.status] ?? agent.status}
</span>
</div>
);
}
/* ── Widget ───────────────────────────────────────────────────── */
export function AgentStatusWidget({
caseNumber,
}: {
caseNumber: string;
}) {
const { data } = useAgentActivity(caseNumber);
// Don't render if no Paperclip project yet
if (!data?.issues?.length) return null;
const agents = data.agents ?? [];
const activeCount = agents.filter((a) => a.status === "active" || a.status === "running").length;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs font-medium text-navy">
<Bot className="w-3.5 h-3.5" />
<span>סוכנים</span>
</div>
{agents.length > 0 && (
<span className="text-[10px] text-ink-faint">
{activeCount} פעילים מתוך {agents.length}
</span>
)}
</div>
<div className="space-y-0.5">
{agents.map((agent) => (
<AgentRow key={agent.id} agent={agent} />
))}
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/cases/status-badge";
import { SyncIndicator } from "@/components/cases/sync-indicator";
import {
PRACTICE_AREA_LABELS,
APPEAL_SUBTYPE_LABELS,
@@ -71,6 +72,10 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
עודכן
</dt>
<dd className="text-ink-soft tabular-nums">{formatDate(data?.updated_at)}</dd>
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
סנכרון
</dt>
<dd><SyncIndicator caseNumber={data?.case_number} /></dd>
</dl>
</div>
</CardContent>

View File

@@ -0,0 +1,570 @@
"use client";
import { useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
useExports,
useExportDocx,
useUploadDraft,
useMarkFinal,
useDeleteDraft,
useActiveDraft,
} from "@/lib/api/exports";
import {
useCaseFeedback,
useCreateFeedback,
useResolveFeedback,
CATEGORY_LABELS,
CATEGORY_COLORS,
BLOCK_LABELS,
type FeedbackCategory,
} from "@/lib/api/feedback";
import type { CaseStatus } from "@/lib/api/cases";
import { toast } from "sonner";
import {
FileText,
Download,
Upload,
Award,
Loader2,
FileOutput,
Plus,
Trash2,
} from "lucide-react";
/* Statuses at which a draft is considered ready */
const DRAFT_READY: CaseStatus[] = [
"drafted",
"exported",
"reviewed",
"final",
];
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(0)} KB`;
return `${(kb / 1024).toFixed(1)} MB`;
}
function formatDate(epoch: number): string {
return new Date(epoch * 1000).toLocaleDateString("he-IL", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/* ── Main component ─────────────────────────────────── */
export function DraftsPanel({
caseNumber,
status,
}: {
caseNumber: string;
status?: CaseStatus;
}) {
const { data: exports, isLoading: exportsLoading } = useExports(caseNumber);
const { data: feedbacks, isLoading: feedbackLoading } =
useCaseFeedback(caseNumber);
const { data: activeDraft } = useActiveDraft(caseNumber);
const exportDocx = useExportDocx(caseNumber);
const uploadDraft = useUploadDraft(caseNumber);
const markFinal = useMarkFinal(caseNumber);
const deleteDraft = useDeleteDraft(caseNumber);
const resolveMutation = useResolveFeedback();
const fileRef = useRef<HTMLInputElement>(null);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const isDraftReady = status && DRAFT_READY.includes(status);
const openFeedbacks = feedbacks?.filter((f) => !f.resolved) ?? [];
// Determine draft label based on *actual* v-numbers in filenames (not counts).
// "(מתוקנת)" suffix appears when there's at least one עריכה-* file.
const draftLabel = (() => {
if (!exports?.length) return "טיוטה מוכנה לעיון";
const drafts = exports.filter((f) => f.filename.startsWith("טיוטה-"));
const revisions = exports.filter((f) => f.filename.startsWith("עריכה-"));
if (!drafts.length) return "טיוטה מוכנה לעיון";
const versions = drafts
.map((f) => {
const m = f.filename.match(/v(\d+)/);
return m ? parseInt(m[1], 10) : 0;
})
.filter((n) => n > 0);
const maxVer = versions.length ? Math.max(...versions) : drafts.length;
const suffix = revisions.length > 0 ? " (מתוקנת)" : "";
return `טיוטה v${maxVer}${suffix} מוכנה לעיון`;
})();
function handleUpload(file: File) {
uploadDraft.mutate(file, {
onSuccess: (data) => {
const added = data.bookmarks_added?.length ?? 0;
const missing = data.missing_blocks?.length ?? 0;
if (data.apply_status === "completed" || data.apply_status === "ok") {
if (added > 0) {
toast.success(`הועלה: ${data.filename} — זוהו ${added} בלוקים`);
} else {
toast.success(`הועלה: ${data.filename}`);
}
if (missing > 0) {
toast.warning(
`שימו לב: ${missing} בלוקים לא זוהו — ייתכנו בעיות בתיקונים עתידיים`,
);
}
} else {
toast.error(`הועלה אך השילוב נכשל: ${data.apply_status ?? "שגיאה"}`);
}
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : "שגיאה בהעלאה"),
});
}
function handleExport() {
exportDocx.mutate(undefined, {
onSuccess: () => toast.success("הטיוטה יוצאה בהצלחה"),
onError: () => toast.error("שגיאה בייצוא"),
});
}
function handleMarkFinal(filename: string) {
markFinal.mutate(filename, {
onSuccess: () => toast.success("סומן כסופי"),
onError: () => toast.error("שגיאה בסימון"),
});
}
function handleResolve(id: string) {
resolveMutation.mutate(
{ feedbackId: id, applied_to: [] },
{
onSuccess: () => toast.success("ההערה סומנה כמטופלת"),
onError: () => toast.error("שגיאה בעדכון"),
},
);
}
return (
<div className="space-y-6">
{/* ── Banner ── */}
{isDraftReady && (
<div className="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">
<span>מקור האמת:</span>
<Badge variant="outline" className="bg-surface">
{activeDraft.filename}
</Badge>
</div>
)}
{/* ── Exports list ── */}
<section>
<div className="flex items-center justify-between mb-3">
<h3 className="text-navy text-base">קבצי טיוטה</h3>
<div className="flex items-center gap-2">
<input
ref={fileRef}
type="file"
accept=".docx"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleUpload(f);
if (fileRef.current) fileRef.current.value = "";
}}
/>
<Button
variant="outline"
size="sm"
onClick={() => fileRef.current?.click()}
disabled={uploadDraft.isPending}
>
{uploadDraft.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
) : (
<Upload className="w-4 h-4 me-1.5" />
)}
העלה גרסה מתוקנת
</Button>
</div>
</div>
{exportsLoading ? (
<p className="text-sm text-ink-muted">טוען...</p>
) : !exports?.length ? (
<p className="text-sm text-ink-muted">
אין טיוטות עדיין.{" "}
{!isDraftReady && "הטיוטה תופיע כאן כשתהיה מוכנה."}
</p>
) : (
<div className="rounded-lg border border-rule overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-rule-soft/40 text-ink-muted text-[0.75rem]">
<th className="text-start px-4 py-2 font-medium">File</th>
<th className="text-start px-4 py-2 font-medium">Size</th>
<th className="text-start px-4 py-2 font-medium">Date</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody>
{exports.map((file) => (
<tr
key={file.filename}
className="border-t border-rule hover:bg-rule-soft/20"
>
<td className="px-4 py-2.5 flex items-center gap-2">
<span>{file.filename}</span>
{file.is_final && (
<Badge className="bg-success-bg text-success border-success/40 text-[0.65rem]">
<Award className="w-3 h-3 me-0.5" />
סופי
</Badge>
)}
</td>
<td className="px-4 py-2.5 text-ink-muted tabular-nums">
{formatSize(file.size)}
</td>
<td className="px-4 py-2.5 text-ink-muted tabular-nums">
{formatDate(file.created_at)}
</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-1 justify-end">
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() =>
window.open(
`/api/cases/${caseNumber}/exports/${file.filename}/download`,
"_blank",
)
}
>
<Download className="w-3.5 h-3.5 me-1" />
הורד
</Button>
{!file.is_final && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-ink-muted"
onClick={() => handleMarkFinal(file.filename)}
disabled={markFinal.isPending}
>
<Award className="w-3.5 h-3.5 me-1" />
סמן כסופי
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => setDeleteTarget(file.filename)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Delete confirmation dialog */}
<Dialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<DialogContent className="sm:max-w-sm" dir="rtl">
<DialogHeader>
<DialogTitle>מחיקת טיוטה</DialogTitle>
</DialogHeader>
<p className="text-sm text-ink-muted">
למחוק את הקובץ{" "}
<span className="font-medium text-ink">{deleteTarget}</span>?
<br />
פעולה זו לא ניתנת לביטול.
</p>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={() => setDeleteTarget(null)}
>
ביטול
</Button>
<Button
variant="destructive"
size="sm"
disabled={deleteDraft.isPending}
onClick={() => {
if (!deleteTarget) return;
deleteDraft.mutate(deleteTarget, {
onSuccess: () => {
toast.success("הקובץ נמחק");
setDeleteTarget(null);
},
onError: () => toast.error("שגיאה במחיקה"),
});
}}
>
{deleteDraft.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1" />
) : (
<Trash2 className="w-4 h-4 me-1" />
)}
מחק
</Button>
</div>
</DialogContent>
</Dialog>
</section>
{/* ── Chair feedback ── */}
<section>
<div className="flex items-center justify-between mb-3">
<h3 className="text-navy text-base">
הערות יו״ר
{openFeedbacks.length > 0 && (
<Badge className="ms-2 bg-red-100 text-red-700 border-red-200 text-[0.65rem]">
{openFeedbacks.length} פתוחות
</Badge>
)}
</h3>
<NewCaseFeedbackDialog caseNumber={caseNumber} />
</div>
{feedbackLoading ? (
<p className="text-sm text-ink-muted">טוען...</p>
) : !feedbacks?.length ? (
<p className="text-sm text-ink-muted">אין הערות לתיק זה.</p>
) : (
<div className="space-y-2">
{feedbacks.map((fb) => (
<div
key={fb.id}
className={`rounded-lg border border-rule bg-surface px-4 py-3 space-y-2 ${
fb.resolved ? "opacity-50" : ""
}`}
>
<div className="flex items-center gap-2 flex-wrap">
<Badge
className={`text-[0.65rem] border ${CATEGORY_COLORS[fb.category]}`}
>
{CATEGORY_LABELS[fb.category]}
</Badge>
<Badge variant="outline" className="text-[0.65rem]">
{BLOCK_LABELS[fb.block_id] ?? fb.block_id}
</Badge>
{fb.resolved && (
<Badge className="bg-emerald-100 text-emerald-700 text-[0.65rem] border border-emerald-200">
טופל
</Badge>
)}
<span className="text-[0.65rem] text-ink-muted me-auto">
{fb.created_at
? new Date(fb.created_at).toLocaleDateString("he-IL")
: ""}
</span>
</div>
<p className="text-sm leading-relaxed">{fb.feedback_text}</p>
{fb.lesson_extracted && (
<div className="bg-gold/5 border border-gold/20 rounded-md px-3 py-2">
<p className="text-[0.65rem] font-semibold text-gold-deep mb-0.5">
לקח שהופק:
</p>
<p className="text-sm text-ink-muted leading-relaxed">
{fb.lesson_extracted}
</p>
</div>
)}
{!fb.resolved && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
className="h-7 text-[0.75rem]"
onClick={() => handleResolve(fb.id)}
disabled={resolveMutation.isPending}
>
סמן כמטופל
</Button>
</div>
)}
</div>
))}
</div>
)}
</section>
</div>
);
}
/* ── New feedback dialog (case-scoped) ─────────────── */
function NewCaseFeedbackDialog({ caseNumber }: { caseNumber: string }) {
const [open, setOpen] = useState(false);
const createMutation = useCreateFeedback();
const [blockId, setBlockId] = useState("block-yod");
const [category, setCategory] = useState<FeedbackCategory>("missing_content");
const [feedbackText, setFeedbackText] = useState("");
const [lesson, setLesson] = useState("");
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!feedbackText.trim()) return;
createMutation.mutate(
{
case_number: caseNumber,
block_id: blockId,
feedback_text: feedbackText,
category,
lesson_extracted: lesson || undefined,
},
{
onSuccess: () => {
toast.success("ההערה נרשמה בהצלחה");
setOpen(false);
setFeedbackText("");
setLesson("");
},
onError: () => toast.error("שגיאה ברישום ההערה"),
},
);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="w-3.5 h-3.5 me-1" />
הערה חדשה
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg" dir="rtl">
<DialogHeader>
<DialogTitle>הערת יו״ר תיק {caseNumber}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="fb-block">בלוק</Label>
<select
id="fb-block"
value={blockId}
onChange={(e) => setBlockId(e.target.value)}
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
>
{Object.entries(BLOCK_LABELS).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="fb-category">קטגוריה</Label>
<select
id="fb-category"
value={category}
onChange={(e) =>
setCategory(e.target.value as FeedbackCategory)
}
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
>
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</div>
</div>
<div>
<Label htmlFor="fb-text">ההערה</Label>
<Textarea
id="fb-text"
value={feedbackText}
onChange={(e) => setFeedbackText(e.target.value)}
placeholder="מה דפנה אמרה? מה חסר, מה לא נכון, מה צריך לשנות..."
rows={4}
required
/>
</div>
<div>
<Label htmlFor="fb-lesson">לקח שהופק (אופציונלי)</Label>
<Textarea
id="fb-lesson"
value={lesson}
onChange={(e) => setLesson(e.target.value)}
placeholder="מה למדנו מההערה? מה צריך לשנות במערכת?"
rows={2}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
ביטול
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? "שומר..." : "שמור הערה"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -18,7 +18,7 @@ const TONE_STYLES: Record<Bucket["tone"], string> = {
function bucketize(cases: Case[] | undefined): Bucket[] {
const c = cases ?? [];
const inProgress = c.filter((x) =>
["processing", "documents_ready", "outcome_set", "brainstorming", "direction_approved"].includes(x.status),
["processing", "documents_ready", "analyst_verified", "research_complete", "outcome_set", "brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing"].includes(x.status),
).length;
const drafting = c.filter((x) =>
["drafting", "qa_review", "drafted"].includes(x.status),

View File

@@ -2,7 +2,8 @@ import { Badge } from "@/components/ui/badge";
import {
FilePlus2, Upload, Loader2, FileCheck, Target,
Lightbulb, Compass, PenLine, SearchCheck, FileText,
FileOutput, CheckCircle2, Award,
FileOutput, CheckCircle2, Award, ShieldCheck, BookOpen,
Microscope, PlayCircle,
} from "lucide-react";
import type { CaseStatus } from "@/lib/api/cases";
import type { LucideIcon } from "lucide-react";
@@ -12,11 +13,15 @@ const STATUS_LABELS: Record<CaseStatus, string> = {
uploading: "מעלה",
processing: "בעיבוד",
documents_ready: "מסמכים מוכנים",
analyst_verified: "ניתוח אומת",
research_complete: "מחקר הושלם",
outcome_set: "תוצאה נקבעה",
brainstorming: "סיעור מוחות",
direction_approved: "כיוון אושר",
analysis_enriched: "ניתוח הועמק",
ready_for_writing: "מוכן לכתיבה",
drafting: "בכתיבה",
qa_review: "QA",
qa_review: "בדיקת איכות",
drafted: "טיוטה",
exported: "יוצא",
reviewed: "נבדק",
@@ -28,9 +33,13 @@ const STATUS_ICONS: Record<CaseStatus, LucideIcon> = {
uploading: Upload,
processing: Loader2,
documents_ready: FileCheck,
analyst_verified: ShieldCheck,
research_complete: BookOpen,
outcome_set: Target,
brainstorming: Lightbulb,
direction_approved: Compass,
analysis_enriched: Microscope,
ready_for_writing: PlayCircle,
drafting: PenLine,
qa_review: SearchCheck,
drafted: FileText,
@@ -44,12 +53,16 @@ const STATUS_DESCRIPTIONS: Record<CaseStatus, string> = {
uploading: "מסמכים בתהליך העלאה לשרת",
processing: "המערכת מעבדת ומנתחת את המסמכים",
documents_ready: "כל המסמכים עובדו ומוכנים לעבודה",
analyst_verified: "ניתוח ראשוני אומת — ממתין למחקר תקדימים",
research_complete: "מחקר תקדימים הושלם — ממתין לבחירת תוצאה",
outcome_set: "נקבעה תוצאה צפויה לערר",
brainstorming: "ניתוח כיוונים אפשריים להחלטה",
direction_approved: "כיוון ההחלטה אושר — ניתן להתחיל כתיבה",
direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח",
analysis_enriched: "ניתוח הועמק ופסיקה אומתה — מוכן לכתיבה",
ready_for_writing: "הכל מוכן — ממתין לכותב ההחלטה",
drafting: "טיוטת ההחלטה בתהליך כתיבה",
qa_review: "הטיוטה בבדיקת איכות אוטומטית",
drafted: "טיוטה ראשונה מוכנה לעיון",
drafted: "טיוטה מוכנה לעיון",
exported: "ההחלטה יוצאה לקובץ DOCX",
reviewed: "ההחלטה נבדקה ע\"י היו\"ר",
final: "החלטה סופית — מוכנה להגשה",
@@ -66,9 +79,13 @@ const STATUS_TONE: Record<CaseStatus, string> = {
uploading: "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-info-bg text-info border-info/40",
research_complete:"bg-info-bg text-info border-info/40",
outcome_set: "bg-info-bg text-info border-info/40",
brainstorming: "bg-gold-wash text-gold-deep border-gold/40",
direction_approved:"bg-gold-wash text-gold-deep border-gold/50",
analysis_enriched:"bg-gold-wash text-gold-deep border-gold/50",
ready_for_writing:"bg-gold-wash text-gold-deep border-gold/50",
drafting: "bg-warn-bg text-warn border-warn/40",
qa_review: "bg-warn-bg text-warn border-warn/40",
drafted: "bg-warn-bg text-warn border-warn/50",

View File

@@ -17,8 +17,8 @@ import { useUpdateCase, type CaseStatus } from "@/lib/api/cases";
const ALL_STATUSES: CaseStatus[] = [
"new", "uploading", "processing",
"documents_ready", "outcome_set",
"brainstorming", "direction_approved",
"documents_ready", "analyst_verified", "research_complete", "outcome_set",
"brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing",
"drafting", "qa_review", "drafted",
"exported", "reviewed", "final",
];

View File

@@ -13,8 +13,8 @@ type GroupKey = "intake" | "prep" | "thinking" | "writing" | "done";
const GROUP_OF: Record<CaseStatus, GroupKey> = {
new: "intake", uploading: "intake", processing: "intake",
documents_ready: "prep", outcome_set: "prep",
brainstorming: "thinking", direction_approved: "thinking",
documents_ready: "prep", analyst_verified: "prep", research_complete: "prep", outcome_set: "prep",
brainstorming: "thinking", direction_approved: "thinking", analysis_enriched: "thinking", ready_for_writing: "thinking",
drafting: "writing", qa_review: "writing", drafted: "writing",
exported: "done", reviewed: "done", final: "done",
};

View File

@@ -18,8 +18,8 @@ type PhaseGroup = {
const PHASE_GROUPS: PhaseGroup[] = [
{ label: "קליטה ועיבוד", statuses: ["new", "uploading", "processing"] },
{ label: "הכנת תיק", statuses: ["documents_ready", "outcome_set"] },
{ label: "ניתוח וכיוון", statuses: ["brainstorming", "direction_approved"] },
{ label: "הכנת תיק", statuses: ["documents_ready", "analyst_verified", "research_complete", "outcome_set"] },
{ label: "ניתוח וכיוון", statuses: ["brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing"] },
{ label: "כתיבת טיוטה", statuses: ["drafting", "qa_review", "drafted"] },
{ label: "סגירה", statuses: ["exported", "reviewed", "final"] },
];

View File

@@ -0,0 +1,62 @@
"use client";
import { useGitStatus } from "@/lib/api/cases";
import { CheckCircle2, AlertCircle, Clock, CloudOff } from "lucide-react";
function formatRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "עכשיו";
if (mins < 60) return `לפני ${mins} דק׳`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `לפני ${hours} שע׳`;
const days = Math.floor(hours / 24);
return `לפני ${days} ימים`;
}
export function SyncIndicator({ caseNumber }: { caseNumber?: string }) {
const { data, isLoading } = useGitStatus(caseNumber);
if (isLoading || !data) return null;
if (data.error === "no_repo") {
return (
<span className="inline-flex items-center gap-1 text-[0.7rem] text-ink-muted/60" title="אין ריפו מקומי">
<CloudOff className="w-3 h-3" />
</span>
);
}
const synced = data.synced;
const pending = data.dirty_files + data.commits_ahead;
let Icon = synced ? CheckCircle2 : pending > 0 ? Clock : AlertCircle;
let color = synced
? "text-success"
: pending > 0
? "text-warn"
: "text-ink-muted";
let label = synced
? "מסונכרן"
: pending > 0
? `${pending} שינויים ממתינים`
: "לא מחובר";
if (!data.has_remote) {
Icon = CloudOff;
color = "text-ink-muted/60";
label = "אין remote";
}
const time = data.last_commit_time ? formatRelative(data.last_commit_time) : null;
const tooltip = [label, time ? `commit אחרון: ${time}` : null, data.last_commit_msg]
.filter(Boolean)
.join("\n");
return (
<span className={`inline-flex items-center gap-1 text-[0.7rem] ${color}`} title={tooltip}>
<Icon className="w-3 h-3" />
<span className="hidden sm:inline">{time ?? label}</span>
</span>
);
}

View File

@@ -23,8 +23,8 @@ type Phase = {
const PHASES: Phase[] = [
{ key: "intake", label: "קליטה ועיבוד", icon: FolderInput, statuses: ["new", "uploading", "processing"] },
{ key: "prep", label: "הכנת תיק", icon: ClipboardList, statuses: ["documents_ready", "outcome_set"] },
{ key: "thinking", label: "ניתוח וכיוון", icon: Brain, statuses: ["brainstorming", "direction_approved"] },
{ key: "prep", label: "הכנת תיק", icon: ClipboardList, statuses: ["documents_ready", "analyst_verified", "research_complete", "outcome_set"] },
{ key: "thinking", label: "ניתוח וכיוון", icon: Brain, statuses: ["brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing"] },
{ key: "writing", label: "כתיבת טיוטה", icon: PenLine, statuses: ["drafting", "qa_review", "drafted"] },
{ key: "done", label: "סגירה", icon: CheckCircle2, statuses: ["exported", "reviewed", "final"] },
];

View File

@@ -0,0 +1,181 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Markdown } from "@/components/ui/markdown";
import {
useMethodology,
useUpdateMethodology,
useResetMethodology,
} from "@/lib/api/methodology";
import { toast } from "sonner";
import { Save, RotateCcw, Eye, EyeOff, Loader2 } from "lucide-react";
const CHECKLIST_LABELS: Record<string, string> = {
licensing_substantive: "ערר רישוי מהותי",
licensing_threshold: "ערר רישוי סף/סמכות",
licensing_property: "ערר רישוי קנייני",
tama38: "תמ\"א 38",
betterment_levy: "היטל השבחה",
};
const CHECKLIST_ORDER = [
"licensing_substantive",
"licensing_threshold",
"licensing_property",
"tama38",
"betterment_levy",
];
type ChecklistItem = {
key: string;
label: string;
original: string;
draft: string;
isOverride: boolean;
dirty: boolean;
};
export function ContentChecklistsPanel() {
const { data, isLoading } = useMethodology<string>("content_checklists");
const update = useUpdateMethodology("content_checklists");
const reset = useResetMethodology("content_checklists");
const [items, setItems] = useState<ChecklistItem[]>([]);
const [active, setActive] = useState(CHECKLIST_ORDER[0]);
const [preview, setPreview] = useState(false);
useEffect(() => {
if (!data?.items) return;
setItems(
CHECKLIST_ORDER
.filter((k) => k in data.items)
.map((key) => ({
key,
label: CHECKLIST_LABELS[key] ?? key,
original: data.items[key].value,
draft: data.items[key].value,
isOverride: data.items[key].is_override,
dirty: false,
})),
);
}, [data]);
const current = items.find((i) => i.key === active);
const updateDraft = (text: string) => {
setItems((prev) =>
prev.map((i) =>
i.key === active
? { ...i, draft: text, dirty: text !== i.original }
: i,
),
);
};
const handleSave = () => {
if (!current) return;
update.mutate(
{ key: current.key, value: current.draft },
{
onSuccess: () => toast.success(`${current.label} נשמר`),
onError: () => toast.error("שגיאה בשמירה"),
},
);
};
const handleReset = () => {
if (!current) return;
reset.mutate(current.key, {
onSuccess: () => toast.success(`${current.label} אופס`),
onError: () => toast.error("שגיאה באיפוס"),
});
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-ink-faint">
<Loader2 className="w-5 h-5 animate-spin ml-2" />
טוען...
</div>
);
}
return (
<div className="space-y-4">
{/* Tab selector */}
<div className="flex gap-2 flex-wrap">
{items.map((item) => (
<Button
key={item.key}
size="sm"
variant={active === item.key ? "default" : "outline"}
onClick={() => { setActive(item.key); setPreview(false); }}
className="text-xs"
>
{item.label}
{item.isOverride && (
<Badge variant="secondary" className="text-[9px] mr-1.5 px-1">
מותאם
</Badge>
)}
</Button>
))}
</div>
{/* Editor / Preview */}
{current && (
<Card className="border-rule">
<CardContent className="px-5 py-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-navy">{current.label}</h3>
<Button
size="sm"
variant="ghost"
onClick={() => setPreview(!preview)}
className="text-xs"
>
{preview ? <EyeOff className="w-3.5 h-3.5 ml-1" /> : <Eye className="w-3.5 h-3.5 ml-1" />}
{preview ? "עריכה" : "תצוגה מקדימה"}
</Button>
</div>
{preview ? (
<div className="border border-rule rounded-md p-4 bg-sand-soft/30 max-h-[500px] overflow-y-auto">
<Markdown content={current.draft} />
</div>
) : (
<Textarea
value={current.draft}
onChange={(e) => updateDraft(e.target.value)}
className="min-h-[400px] font-mono text-sm leading-relaxed"
dir="rtl"
/>
)}
<div className="flex items-center gap-2">
<Button size="sm" disabled={!current.dirty || update.isPending} onClick={handleSave}>
{update.isPending ? <Loader2 className="w-3 h-3 animate-spin ml-1" /> : <Save className="w-3 h-3 ml-1" />}
שמור
</Button>
{current.isOverride && (
<Button size="sm" variant="outline" disabled={reset.isPending} onClick={handleReset}>
<RotateCcw className="w-3 h-3 ml-1" />
איפוס לברירת מחדל
</Button>
)}
<Badge
variant={current.isOverride ? "default" : "secondary"}
className="text-[10px] mr-auto"
>
{current.isOverride ? "מותאם" : "ברירת מחדל"}
</Badge>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,188 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
useMethodology,
useUpdateMethodology,
useResetMethodology,
} from "@/lib/api/methodology";
import { toast } from "sonner";
import { Save, RotateCcw, Plus, Trash2, Loader2, ChevronDown, ChevronUp } from "lucide-react";
const OUTCOME_LABELS: Record<string, string> = {
universal: "כללי (לכל סוגי התוצאות)",
rejection: "דחייה",
full_acceptance: "קבלה מלאה",
partial_acceptance: "קבלה חלקית",
betterment_levy: "היטל השבחה",
};
type RulesSection = {
key: string;
label: string;
original: string[];
draft: string[];
isOverride: boolean;
dirty: boolean;
expanded: boolean;
};
export function DiscussionRulesPanel() {
const { data, isLoading } = useMethodology<string[]>("discussion_rules");
const update = useUpdateMethodology("discussion_rules");
const reset = useResetMethodology("discussion_rules");
const [sections, setSections] = useState<RulesSection[]>([]);
useEffect(() => {
if (!data?.items) return;
const order = ["universal", "rejection", "partial_acceptance", "full_acceptance", "betterment_levy"];
setSections(
order
.filter((k) => k in data.items)
.map((key) => ({
key,
label: OUTCOME_LABELS[key] ?? key,
original: data.items[key].value,
draft: [...data.items[key].value],
isOverride: data.items[key].is_override,
dirty: false,
expanded: key === "universal",
})),
);
}, [data]);
const toggle = (idx: number) => {
setSections((prev) =>
prev.map((s, i) => (i === idx ? { ...s, expanded: !s.expanded } : s)),
);
};
const updateRule = (sIdx: number, rIdx: number, text: string) => {
setSections((prev) =>
prev.map((s, i) => {
if (i !== sIdx) return s;
const draft = [...s.draft];
draft[rIdx] = text;
return { ...s, draft, dirty: JSON.stringify(draft) !== JSON.stringify(s.original) };
}),
);
};
const addRule = (sIdx: number) => {
setSections((prev) =>
prev.map((s, i) => {
if (i !== sIdx) return s;
const draft = [...s.draft, ""];
return { ...s, draft, dirty: true };
}),
);
};
const removeRule = (sIdx: number, rIdx: number) => {
setSections((prev) =>
prev.map((s, i) => {
if (i !== sIdx) return s;
const draft = s.draft.filter((_, j) => j !== rIdx);
return { ...s, draft, dirty: JSON.stringify(draft) !== JSON.stringify(s.original) };
}),
);
};
const handleSave = (sec: RulesSection) => {
const cleaned = sec.draft.filter((s) => s.trim());
update.mutate(
{ key: sec.key, value: cleaned },
{
onSuccess: () => toast.success(`${sec.label} נשמר`),
onError: () => toast.error("שגיאה בשמירה"),
},
);
};
const handleReset = (sec: RulesSection) => {
reset.mutate(sec.key, {
onSuccess: () => toast.success(`${sec.label} אופס`),
onError: () => toast.error("שגיאה באיפוס"),
});
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-ink-faint">
<Loader2 className="w-5 h-5 animate-spin ml-2" />
טוען...
</div>
);
}
return (
<div className="space-y-3">
{sections.map((sec, si) => (
<Card key={sec.key} className="border-rule">
<CardContent className="px-5 py-0">
{/* Accordion header */}
<button
className="flex items-center justify-between w-full py-3 text-right"
onClick={() => toggle(si)}
>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-navy">{sec.label}</h3>
<Badge variant={sec.isOverride ? "default" : "secondary"} className="text-[10px]">
{sec.isOverride ? "מותאם" : "ברירת מחדל"}
</Badge>
<span className="text-[11px] text-ink-faint">{sec.draft.length} כללים</span>
</div>
{sec.expanded ? <ChevronUp className="w-4 h-4 text-ink-faint" /> : <ChevronDown className="w-4 h-4 text-ink-faint" />}
</button>
{/* Expanded content */}
{sec.expanded && (
<div className="pb-4 space-y-2">
{sec.draft.map((rule, ri) => (
<div key={ri} className="flex gap-2">
<Textarea
value={rule}
onChange={(e) => updateRule(si, ri, e.target.value)}
className="min-h-[60px] text-sm flex-1"
dir="rtl"
/>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-red-400 hover:text-red-600 flex-shrink-0 mt-1"
onClick={() => removeRule(si, ri)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
))}
<Button size="sm" variant="outline" onClick={() => addRule(si)}>
<Plus className="w-3 h-3 ml-1" />
הוסף כלל
</Button>
<div className="flex items-center gap-2 pt-2 border-t border-rule/50">
<Button size="sm" disabled={!sec.dirty || update.isPending} onClick={() => handleSave(sec)}>
{update.isPending ? <Loader2 className="w-3 h-3 animate-spin ml-1" /> : <Save className="w-3 h-3 ml-1" />}
שמור
</Button>
{sec.isOverride && (
<Button size="sm" variant="outline" disabled={reset.isPending} onClick={() => handleReset(sec)}>
<RotateCcw className="w-3 h-3 ml-1" />
איפוס
</Button>
)}
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,177 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
useMethodology,
useUpdateMethodology,
useResetMethodology,
type GoldenRatios,
} from "@/lib/api/methodology";
import { toast } from "sonner";
import { Save, RotateCcw, Loader2 } from "lucide-react";
const OUTCOME_LABELS: Record<string, string> = {
rejection: "דחייה",
full_acceptance: "קבלה מלאה",
partial_acceptance: "קבלה חלקית",
betterment_levy: "היטל השבחה",
};
const SECTION_LABELS: Record<string, string> = {
background: "רקע",
claims: "טענות",
discussion: "דיון",
summary: "סיכום",
};
type RatioCard = {
key: string;
label: string;
original: GoldenRatios;
draft: GoldenRatios;
isOverride: boolean;
dirty: boolean;
};
export function GoldenRatiosPanel() {
const { data, isLoading } = useMethodology<GoldenRatios>("golden_ratios");
const update = useUpdateMethodology("golden_ratios");
const reset = useResetMethodology("golden_ratios");
const [cards, setCards] = useState<RatioCard[]>([]);
// Sync from server
useEffect(() => {
if (!data?.items) return;
setCards(
Object.entries(data.items).map(([key, item]) => ({
key,
label: OUTCOME_LABELS[key] ?? key,
original: item.value,
draft: structuredClone(item.value),
isOverride: item.is_override,
dirty: false,
})),
);
}, [data]);
const setRange = (cardIdx: number, section: string, idx: 0 | 1, val: number) => {
setCards((prev) =>
prev.map((c, i) => {
if (i !== cardIdx) return c;
const next = { ...c, draft: { ...c.draft, [section]: [...c.draft[section]] as [number, number] } };
next.draft[section][idx] = val;
next.dirty = JSON.stringify(next.draft) !== JSON.stringify(next.original);
return next;
}),
);
};
const handleSave = (card: RatioCard) => {
update.mutate(
{ key: card.key, value: card.draft },
{
onSuccess: () => toast.success(`${card.label} נשמר`),
onError: () => toast.error("שגיאה בשמירה"),
},
);
};
const handleReset = (card: RatioCard) => {
reset.mutate(card.key, {
onSuccess: () => toast.success(`${card.label} אופס לברירת מחדל`),
onError: () => toast.error("שגיאה באיפוס"),
});
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-ink-faint">
<Loader2 className="w-5 h-5 animate-spin ml-2" />
טוען...
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-2">
{cards.map((card, ci) => (
<Card key={card.key} className="border-rule">
<CardContent className="px-5 py-4 space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-navy">{card.label}</h3>
<Badge variant={card.isOverride ? "default" : "secondary"} className="text-[10px]">
{card.isOverride ? "מותאם" : "ברירת מחדל"}
</Badge>
</div>
{/* Table */}
<table className="w-full text-sm">
<thead>
<tr className="text-ink-faint text-[11px]">
<th className="text-right py-1">Section</th>
<th className="text-center py-1">Min %</th>
<th className="text-center py-1">Max %</th>
</tr>
</thead>
<tbody>
{Object.entries(SECTION_LABELS).map(([sec, label]) => (
<tr key={sec} className="border-t border-rule/50">
<td className="py-1.5 text-ink">{label}</td>
<td className="py-1.5 px-1">
<Input
type="number"
min={0}
max={100}
value={card.draft[sec]?.[0] ?? 0}
onChange={(e) => setRange(ci, sec, 0, Number(e.target.value))}
className="h-7 w-16 text-center text-xs mx-auto"
/>
</td>
<td className="py-1.5 px-1">
<Input
type="number"
min={0}
max={100}
value={card.draft[sec]?.[1] ?? 0}
onChange={(e) => setRange(ci, sec, 1, Number(e.target.value))}
className="h-7 w-16 text-center text-xs mx-auto"
/>
</td>
</tr>
))}
</tbody>
</table>
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
<Button
size="sm"
disabled={!card.dirty || update.isPending}
onClick={() => handleSave(card)}
>
{update.isPending ? <Loader2 className="w-3 h-3 animate-spin ml-1" /> : <Save className="w-3 h-3 ml-1" />}
שמור
</Button>
{card.isOverride && (
<Button
size="sm"
variant="outline"
disabled={reset.isPending}
onClick={() => handleReset(card)}
>
<RotateCcw className="w-3 h-3 ml-1" />
איפוס
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,87 @@
/**
* Paperclip agent activity hooks — mirror agent work into Legal-AI UI.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "./client";
// ── Types ────────────────────────────────────────────────────────
export type PaperclipIssue = {
id: string;
title: string;
status: string;
identifier: string;
priority: string;
assignee_name: string | null;
started_at: string | null;
completed_at: string | null;
created_at: string | null;
company_id: string;
};
export type PaperclipComment = {
id: string;
issue_id: string;
body: string;
created_at: string | null;
author_agent_id: string | null;
author_user_id: string | null;
agent_name: string | null;
agent_role: string | null;
agent_icon: string | null;
};
export type PaperclipAgent = {
id: string;
name: string;
role: string;
title: string | null;
status: string;
icon: string | null;
last_heartbeat_at: string | null;
};
export type AgentActivityResponse = {
issues: PaperclipIssue[];
comments: PaperclipComment[];
agents: PaperclipAgent[];
};
// ── Query Keys ───────────────────────────────────────────────────
export const agentKeys = {
activity: (caseNumber: string) =>
["agents", "activity", caseNumber] as const,
};
// ── Hooks ────────────────────────────────────────────────────────
export function useAgentActivity(caseNumber: string | undefined) {
return useQuery({
queryKey: agentKeys.activity(caseNumber ?? ""),
queryFn: ({ signal }) =>
apiRequest<AgentActivityResponse>(
`/api/cases/${caseNumber}/agents`,
{ signal },
),
enabled: !!caseNumber,
refetchInterval: 10_000,
});
}
export function useSendComment(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: (vars: { body: string; issue_id?: string }) =>
apiRequest<{ comment_id: string; issue_id: string; issue_identifier: string }>(
`/api/cases/${caseNumber}/agents/comment`,
{ method: "POST", body: vars },
),
onSuccess: () => {
if (caseNumber) {
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
}
},
});
}

View File

@@ -18,9 +18,13 @@ export type CaseStatus =
| "uploading"
| "processing"
| "documents_ready"
| "analyst_verified"
| "research_complete"
| "outcome_set"
| "brainstorming"
| "direction_approved"
| "analysis_enriched"
| "ready_for_writing"
| "drafting"
| "qa_review"
| "drafted"
@@ -141,6 +145,54 @@ export function useUpdateCase(caseNumber: string | undefined) {
});
}
export type GitSyncStatus = {
synced: boolean;
has_remote: boolean;
remote_url?: string | null;
dirty_files: number;
commits_ahead: number;
last_commit_time?: string | null;
last_commit_msg?: string | null;
error?: string;
};
export function useGitStatus(caseNumber: string | undefined) {
return useQuery({
queryKey: [...casesKeys.all, "git-status", caseNumber ?? ""] as const,
queryFn: ({ signal }) =>
apiRequest<GitSyncStatus>(`/api/cases/${caseNumber}/git-status`, { signal }),
enabled: Boolean(caseNumber),
staleTime: 30_000,
refetchInterval: 60_000,
});
}
export type StartWorkflowResult = {
case_number: string;
status: string;
issue_id: string;
issue_identifier: string;
project_url: string;
wakeup: Record<string, unknown>;
};
export function useStartWorkflow(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: () =>
apiRequest<StartWorkflowResult>(
`/api/cases/${caseNumber}/start-workflow`,
{ method: "POST" },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: casesKeys.all });
if (caseNumber) {
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
}
},
});
}
export function useWorkflowStatus(caseNumber: string | undefined) {
return useQuery({
queryKey: [...casesKeys.all, "workflow", caseNumber ?? ""] as const,

View File

@@ -0,0 +1,195 @@
/**
* Exports domain hooks — draft DOCX files for a case.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "./client";
import { casesKeys } from "./cases";
export type ExportFile = {
filename: string;
size: number;
created_at: number;
is_final: boolean;
};
export type ActiveDraft = {
active_draft_path: string | null;
filename: string | null;
exists: boolean;
};
export type Revision = {
id: string;
type: "insert_after" | "insert_before" | "replace" | "delete";
anchor_bookmark: string;
content?: string;
style?: "body" | "heading" | "quote" | "bold";
reason?: string;
};
export type UploadResult = {
filename: string;
size: number;
version: number;
active_draft?: string;
bookmarks_added?: string[];
missing_blocks?: string[];
apply_status?: string;
};
export type ReviseResult = {
status: string;
output_path: string;
version: number;
applied: number;
failed: number;
results: { id: string; status: string; error?: string }[];
};
export const exportsKeys = {
all: ["exports"] as const,
list: (caseNumber: string) =>
[...exportsKeys.all, "list", caseNumber] as const,
activeDraft: (caseNumber: string) =>
[...exportsKeys.all, "active-draft", caseNumber] as const,
bookmarks: (caseNumber: string) =>
[...exportsKeys.all, "bookmarks", caseNumber] as const,
};
export function useExports(caseNumber: string | undefined) {
return useQuery({
queryKey: exportsKeys.list(caseNumber ?? ""),
queryFn: ({ signal }) =>
apiRequest<ExportFile[]>(`/api/cases/${caseNumber}/exports`, { signal }),
enabled: Boolean(caseNumber),
staleTime: 5_000,
refetchInterval: 5_000,
});
}
export function useExportDocx(caseNumber: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () =>
apiRequest<{ status: string; path: string; message: string }>(
`/api/cases/${caseNumber}/export-docx`,
{ method: "POST" },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: exportsKeys.list(caseNumber) });
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
},
});
}
export function useUploadDraft(caseNumber: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (file: File): Promise<UploadResult> => {
const form = new FormData();
form.append("file", file);
const res = await fetch(`/api/cases/${caseNumber}/exports/upload`, {
method: "POST",
body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "שגיאה בהעלאה" }));
throw new Error(err.detail ?? "שגיאה בהעלאה");
}
return res.json() as Promise<UploadResult>;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: exportsKeys.list(caseNumber) });
qc.invalidateQueries({ queryKey: exportsKeys.activeDraft(caseNumber) });
qc.invalidateQueries({ queryKey: exportsKeys.bookmarks(caseNumber) });
},
});
}
export function useActiveDraft(caseNumber: string | undefined) {
return useQuery({
queryKey: exportsKeys.activeDraft(caseNumber ?? ""),
queryFn: ({ signal }) =>
apiRequest<ActiveDraft>(`/api/cases/${caseNumber}/active-draft`, { signal }),
enabled: Boolean(caseNumber),
staleTime: 5_000,
});
}
export function useBookmarks(caseNumber: string | undefined) {
return useQuery({
queryKey: exportsKeys.bookmarks(caseNumber ?? ""),
queryFn: ({ signal }) =>
apiRequest<{
status: string;
active_draft_path?: string;
bookmarks?: string[];
}>(`/api/cases/${caseNumber}/exports/bookmarks`, { signal }),
enabled: Boolean(caseNumber),
staleTime: 10_000,
});
}
export function useReviseDraft(caseNumber: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: { revisions: Revision[]; author?: string }) =>
apiRequest<ReviseResult>(`/api/cases/${caseNumber}/exports/revise`, {
method: "POST",
body: payload,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: exportsKeys.list(caseNumber) });
qc.invalidateQueries({ queryKey: exportsKeys.activeDraft(caseNumber) });
},
});
}
export function useRetrofit(caseNumber: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (filename: string) =>
apiRequest<{
status: string;
active_draft_path: string;
bookmarks_added: string[];
missing_blocks: string[];
}>(`/api/cases/${caseNumber}/exports/${filename}/retrofit`, {
method: "POST",
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: exportsKeys.activeDraft(caseNumber) });
qc.invalidateQueries({ queryKey: exportsKeys.bookmarks(caseNumber) });
},
});
}
export function useDeleteDraft(caseNumber: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (filename: string) =>
apiRequest<{ deleted: boolean; filename: string }>(
`/api/cases/${caseNumber}/exports/${filename}`,
{ method: "DELETE" },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: exportsKeys.list(caseNumber) });
},
});
}
export function useMarkFinal(caseNumber: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (filename: string) =>
apiRequest<{ final_filename: string; status: string }>(
`/api/cases/${caseNumber}/exports/${filename}/mark-final`,
{ method: "POST" },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: exportsKeys.list(caseNumber) });
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
},
});
}

View File

@@ -56,6 +56,19 @@ export function useFeedbackList(filters: {
});
}
/** Feedback filtered by case number */
export function useCaseFeedback(caseNumber: string | undefined) {
const params = caseNumber ? `?case_number=${caseNumber}` : "";
return useQuery({
queryKey: [...feedbackKeys.all, "case", caseNumber ?? ""] as const,
queryFn: ({ signal }) =>
apiRequest<ChairFeedback[]>(`/api/feedback${params}`, { signal }),
enabled: Boolean(caseNumber),
staleTime: 5_000,
refetchInterval: 5_000,
});
}
export function useCreateFeedback() {
const qc = useQueryClient();
return useMutation({
@@ -100,6 +113,16 @@ export const CATEGORY_LABELS: Record<FeedbackCategory, string> = {
other: "אחר",
};
/** Tailwind color classes per category */
export const CATEGORY_COLORS: Record<FeedbackCategory, string> = {
missing_content: "bg-amber-100 text-amber-800 border-amber-200",
wrong_tone: "bg-purple-100 text-purple-800 border-purple-200",
wrong_structure: "bg-blue-100 text-blue-800 border-blue-200",
factual_error: "bg-red-100 text-red-800 border-red-200",
style: "bg-emerald-100 text-emerald-800 border-emerald-200",
other: "bg-gray-100 text-gray-800 border-gray-200",
};
/** Block ID labels */
export const BLOCK_LABELS: Record<string, string> = {
"block-he": "ה — פתיחה",

View File

@@ -0,0 +1,71 @@
/**
* Methodology settings hooks — view and edit golden ratios,
* discussion rules, and content checklists.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "./client";
// ── Types ────────────────────────────────────────────────────────
export type MethodologyItem<T = unknown> = {
value: T;
is_override: boolean;
updated_at: string | null;
};
export type MethodologyResponse<T = unknown> = {
items: Record<string, MethodologyItem<T>>;
};
/** Golden ratio per section: [min%, max%] */
export type GoldenRatios = Record<string, [number, number]>;
// ── Query Keys ───────────────────────────────────────────────────
export const methodologyKeys = {
all: ["methodology"] as const,
category: (cat: string) => [...methodologyKeys.all, cat] as const,
};
// ── Hooks ────────────────────────────────────────────────────────
export function useMethodology<T = unknown>(category: string) {
return useQuery({
queryKey: methodologyKeys.category(category),
queryFn: ({ signal }) =>
apiRequest<MethodologyResponse<T>>(
`/api/methodology/${category}`,
{ signal },
),
staleTime: 30_000,
});
}
export function useUpdateMethodology(category: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ key, value }: { key: string; value: unknown }) =>
apiRequest<{ key: string; value: unknown; is_override: boolean }>(
`/api/methodology/${category}/${key}`,
{ method: "PUT", body: { value } },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: methodologyKeys.category(category) });
},
});
}
export function useResetMethodology(category: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (key: string) =>
apiRequest<{ key: string; value: unknown; is_override: boolean }>(
`/api/methodology/${category}/${key}`,
{ method: "DELETE" },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: methodologyKeys.category(category) });
},
});
}

View File

@@ -0,0 +1,57 @@
/**
* Settings hooks: tag → Paperclip company mappings.
*/
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "./client";
export type PaperclipCompany = {
id: string;
name: string;
prefix: string;
};
export type TagMapping = {
id: string;
tag: string;
tag_label: string;
company_id: string;
company_name: string;
created_at: string;
};
export function usePaperclipCompanies() {
return useQuery({
queryKey: ["settings", "paperclip-companies"] as const,
queryFn: ({ signal }) =>
apiRequest<PaperclipCompany[]>("/api/settings/paperclip-companies", { signal }),
staleTime: 60_000,
});
}
export function useTagMappings() {
return useQuery({
queryKey: ["settings", "tag-mappings"] as const,
queryFn: ({ signal }) =>
apiRequest<TagMapping[]>("/api/settings/tag-mappings", { signal }),
staleTime: 10_000,
});
}
export function useAddTagMapping() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { tag: string; tag_label: string; company_id: string; company_name: string }) =>
apiRequest<TagMapping>("/api/settings/tag-mappings", { method: "POST", body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["settings", "tag-mappings"] }),
});
}
export function useDeleteTagMapping() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiRequest<{ ok: boolean }>(`/api/settings/tag-mappings/${id}`, { method: "DELETE" }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["settings", "tag-mappings"] }),
});
}

View File

@@ -22,7 +22,7 @@ import zipfile
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from typing import Any
from pydantic import BaseModel
import asyncpg
@@ -35,7 +35,17 @@ from legal_mcp.tools import cases as cases_tools, search as search_tools, workfl
_web_dir = Path(__file__).resolve().parent
sys.path.insert(0, str(_web_dir.parent))
from web.gitea_client import create_repo, setup_remote_and_push
from web.paperclip_client import create_project as pc_create_project, get_project_url
from web.paperclip_client import (
create_project as pc_create_project,
create_workflow_issue as pc_create_workflow_issue,
get_agents_for_case as pc_get_agents_for_case,
get_agents_for_company as pc_get_agents,
get_case_issues as pc_get_case_issues,
get_issue_comments as pc_get_issue_comments,
get_project_url,
post_comment as pc_post_comment,
wake_ceo_agent as pc_wake_ceo,
)
logger = logging.getLogger(__name__)
@@ -58,20 +68,12 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="העלאת מסמכים משפטיים", lifespan=lifespan)
STATIC_DIR = Path(__file__).parent / "static"
# ── API Endpoints ──────────────────────────────────────────────────
@app.get("/")
async def index():
return FileResponse(STATIC_DIR / "index.html")
@app.get("/design-system.css")
async def design_system_css():
return FileResponse(STATIC_DIR / "design-system.css", media_type="text/css")
return {"status": "ok", "frontend": "https://legal-ai-next.nautilus.marcusgroup.org"}
@app.post("/api/upload")
@@ -1102,7 +1104,91 @@ async def api_case_create(req: CaseCreateRequest):
practice_area=req.practice_area,
appeal_subtype=req.appeal_subtype,
)
return json.loads(result)
parsed = json.loads(result)
# Auto-create Paperclip project for the new case
appeal_type = req.appeal_subtype or "רישוי"
try:
pc_result = await pc_create_project(
case_number=req.case_number,
title=req.title,
appeal_type=appeal_type,
)
parsed["paperclip"] = pc_result
logger.info("Auto-created Paperclip project for case %s: %s", req.case_number, pc_result.get("url"))
except Exception as e:
logger.warning("Failed to auto-create Paperclip project for case %s: %s", req.case_number, e)
parsed["paperclip_error"] = str(e)
return parsed
@app.get("/api/cases/{case_number}/git-status")
async def api_case_git_status(case_number: str):
"""Git sync status for a case repo."""
case_dir = config.find_case_dir(case_number)
git_dir = case_dir / ".git"
if not git_dir.exists():
return {"synced": False, "error": "no_repo"}
env = {
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"HOME": os.environ.get("HOME", "/root"),
"GIT_TERMINAL_PROMPT": "0",
"GIT_CONFIG_GLOBAL": "/dev/null",
}
# Ensure git trusts the case directory regardless of ownership
env["GIT_CONFIG_COUNT"] = "1"
env["GIT_CONFIG_KEY_0"] = "safe.directory"
env["GIT_CONFIG_VALUE_0"] = str(case_dir)
# Last commit info
log = subprocess.run(
["git", "log", "-1", "--format=%H%n%aI%n%s"],
cwd=case_dir, capture_output=True, text=True, env=env,
)
lines = log.stdout.strip().splitlines() if log.returncode == 0 else []
last_commit_time = lines[1] if len(lines) > 1 else None
last_commit_msg = lines[2] if len(lines) > 2 else None
# Dirty files count
status = subprocess.run(
["git", "status", "--porcelain"],
cwd=case_dir, capture_output=True, text=True, env=env,
)
dirty = len([l for l in status.stdout.splitlines() if l.strip()]) if status.returncode == 0 else 0
# Check if remote exists and if we're ahead
has_remote = False
ahead = 0
remote_url = None
remote_check = subprocess.run(
["git", "remote", "get-url", "origin"],
cwd=case_dir, capture_output=True, text=True, env=env,
)
if remote_check.returncode == 0:
has_remote = True
# Sanitize token from URL
raw = remote_check.stdout.strip()
remote_url = raw.split("@")[-1] if "@" in raw else raw
ahead_check = subprocess.run(
["git", "rev-list", "HEAD", "--not", "--remotes", "--count"],
cwd=case_dir, capture_output=True, text=True, env=env,
)
if ahead_check.returncode == 0:
ahead = int(ahead_check.stdout.strip() or "0")
synced = has_remote and dirty == 0 and ahead == 0
return {
"synced": synced,
"has_remote": has_remote,
"remote_url": remote_url,
"dirty_files": dirty,
"commits_ahead": ahead,
"last_commit_time": last_commit_time,
"last_commit_msg": last_commit_msg,
}
@app.get("/api/cases/{case_number}/details")
@@ -1633,6 +1719,109 @@ async def api_research_analysis_download(case_number: str):
)
@app.get("/api/cases/{case_number}/research/analysis/export-docx")
async def api_research_analysis_export_docx(case_number: str):
"""Export the legal analysis as a DOCX using דפנה's decision template styles."""
from legal_mcp.services.analysis_docx_exporter import build_analysis_docx
try:
path = await build_analysis_docx(case_number)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except Exception as e:
logger.exception("Failed to export analysis DOCX for %s", case_number)
raise HTTPException(500, f"שגיאה בייצוא: {e}")
return FileResponse(
path,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
filename=path.name,
)
@app.put("/api/cases/{case_number}/research/analysis/upload")
async def api_research_analysis_upload(
case_number: str,
file: UploadFile = File(...),
):
"""Upload an updated analysis-and-research.md file.
Validates that:
1. The file is markdown (text)
2. It can be parsed by the research_md parser
3. It contains at least one structural section (issues or threshold_claims)
4. The case number in the file matches the URL
On success, backs up the existing file and replaces it.
"""
if not file.filename or not file.filename.endswith(".md"):
raise HTTPException(400, "הקובץ חייב להיות בפורמט Markdown (.md)")
content = await file.read()
if len(content) > 5 * 1024 * 1024:
raise HTTPException(400, "הקובץ גדול מדי — מקסימום 5MB")
try:
text = content.decode("utf-8")
except UnicodeDecodeError:
raise HTTPException(400, "הקובץ חייב להיות בקידוד UTF-8")
if len(text.strip()) < 100:
raise HTTPException(400, "הקובץ ריק מדי — נראה שחסר תוכן")
# Write to a temp file so parse() can work on it
dest = _research_file_path(case_number)
tmp = dest.with_suffix(".md.upload-tmp")
try:
dest.parent.mkdir(parents=True, exist_ok=True)
tmp.write_text(text, encoding="utf-8")
parsed = research_md.parse(tmp)
except Exception as e:
tmp.unlink(missing_ok=True)
raise HTTPException(
400,
f"שגיאה בפרסור הקובץ — המבנה לא תקין: {e}",
)
# Validate structure
issues = parsed.get("issues", [])
thresholds = parsed.get("threshold_claims", [])
if not issues and not thresholds:
tmp.unlink(missing_ok=True)
raise HTTPException(
400,
"הקובץ חייב להכיל לפחות סעיף אחד של טענות סף או סוגיות להכרעה",
)
# Validate case number matches
file_case = parsed.get("header", {}).get("case_number", "")
if file_case and file_case != case_number:
tmp.unlink(missing_ok=True)
raise HTTPException(
400,
f"מספר התיק בקובץ ({file_case}) לא תואם לתיק הנוכחי ({case_number})",
)
# Backup existing file
if dest.exists():
backup_dir = dest.parent / "backup"
backup_dir.mkdir(exist_ok=True)
ts = time.strftime("%Y%m%d-%H%M%S")
backup_path = backup_dir / f"analysis-and-research-{ts}.md"
shutil.copy2(dest, backup_path)
# Replace with uploaded file
tmp.replace(dest)
return {
"status": "ok",
"sections": {
"threshold_claims": len(thresholds),
"issues": len(issues),
"has_conclusions": bool(parsed.get("conclusions", "").strip()),
},
"file_size": len(content),
}
class ChairPositionRequest(BaseModel):
section_id: str
position: str = ""
@@ -1806,9 +1995,25 @@ async def api_download_export(case_number: str, filename: str):
)
@app.delete("/api/cases/{case_number}/exports/{filename}")
async def api_delete_export(case_number: str, filename: str):
"""Delete an exported draft file."""
export_dir = config.find_case_dir(case_number) / "exports"
path = export_dir / filename
if not path.exists() or not path.parent.samefile(export_dir):
raise HTTPException(404, "קובץ לא נמצא")
path.unlink()
return {"deleted": True, "filename": filename}
@app.post("/api/cases/{case_number}/exports/upload")
async def api_upload_export(case_number: str, file: UploadFile = File(...)):
"""Upload a revised version of a draft."""
"""Upload a revised version of a draft.
After saving, the file is automatically registered as the case's
active_draft (source of truth) and bookmarks are retrofitted so that
future revise_draft calls can anchor Track Changes to the 12 blocks.
"""
case = await db.get_case_by_number(case_number)
if not case:
raise HTTPException(404, f"תיק {case_number} לא נמצא")
@@ -1840,10 +2045,85 @@ async def api_upload_export(case_number: str, file: UploadFile = File(...)):
dest = export_dir / f"עריכה-v{next_ver}.docx"
dest.write_bytes(content)
# Auto-register as active_draft + retrofit bookmarks
auto_result: dict = {"status": "ok"}
try:
raw = await drafting_tools.apply_user_edit(case_number, dest.name)
auto_result = json.loads(raw)
except Exception as e:
auto_result = {"status": "error", "message": str(e)}
return {
"filename": dest.name,
"size": len(content),
"version": next_ver,
"active_draft": auto_result.get("active_draft_path"),
"bookmarks_added": auto_result.get("bookmarks_added", []),
"missing_blocks": auto_result.get("missing_blocks", []),
"apply_status": auto_result.get("status", "error"),
}
class ReviseRequest(BaseModel):
revisions: list[dict]
author: str = "מערכת AI"
@app.post("/api/cases/{case_number}/exports/revise")
async def api_revise_draft(case_number: str, req: ReviseRequest):
"""Apply a batch of Track Changes revisions to the active draft."""
raw = await drafting_tools.revise_draft(
case_number,
json.dumps(req.revisions, ensure_ascii=False),
req.author,
)
try:
data = json.loads(raw)
except json.JSONDecodeError:
raise HTTPException(500, raw)
if data.get("status") == "error":
raise HTTPException(400, data.get("message", "revise failed"))
return data
@app.get("/api/cases/{case_number}/exports/bookmarks")
async def api_list_bookmarks(case_number: str):
"""List bookmarks in the case's active draft (anchors for revisions)."""
raw = await drafting_tools.list_bookmarks(case_number)
try:
data = json.loads(raw)
except json.JSONDecodeError:
raise HTTPException(500, raw)
return data
@app.post("/api/cases/{case_number}/exports/{filename}/retrofit")
async def api_retrofit_bookmarks(case_number: str, filename: str):
"""Manually trigger retrofit of bookmarks on an existing file."""
raw = await drafting_tools.apply_user_edit(case_number, filename)
try:
data = json.loads(raw)
except json.JSONDecodeError:
raise HTTPException(500, raw)
if data.get("status") == "error":
raise HTTPException(400, data.get("message", "retrofit failed"))
return data
@app.get("/api/cases/{case_number}/active-draft")
async def api_get_active_draft(case_number: str):
"""Get the current active_draft_path for a case."""
case = await db.get_case_by_number(case_number)
if not case:
raise HTTPException(404, f"תיק {case_number} לא נמצא")
path = await db.get_active_draft_path(UUID(case["id"]))
if not path:
return {"active_draft_path": None, "filename": None, "exists": False}
filename = Path(path).name
return {
"active_draft_path": path,
"filename": filename,
"exists": Path(path).exists(),
}
@@ -1989,16 +2269,278 @@ async def api_paperclip_create_project(req: PaperclipProjectRequest):
return project
@app.post("/api/cases/{case_number}/start-workflow")
async def api_start_workflow(case_number: str):
"""Start the CEO agent workflow for a case.
Creates a workflow issue in Paperclip and wakes the CEO agent.
Only works when case status is 'new' or 'documents_ready'.
"""
# 1. Verify case exists and status is appropriate
case_raw = await cases_tools.case_get(case_number)
case_data = json.loads(case_raw)
if "error" in case_data:
raise HTTPException(404, f"תיק {case_number} לא נמצא")
status = case_data.get("status", "")
allowed = {"new", "documents_ready"}
if status not in allowed:
raise HTTPException(
409,
f"לא ניתן להתחיל תהליך — סטטוס נוכחי: {status}. נדרש: {', '.join(allowed)}",
)
# 2. Create workflow issue in Paperclip
try:
issue = await pc_create_workflow_issue(case_number, case_data.get("title", ""))
except ValueError as e:
raise HTTPException(404, str(e))
except Exception as e:
raise HTTPException(502, f"שגיאת Paperclip: {e}")
# 3. Wake the CEO agent
try:
wakeup = await pc_wake_ceo(issue["issue_id"], case_number, issue.get("company_id", ""))
except Exception as e:
logger.warning("CEO wakeup failed for case %s: %s", case_number, e)
wakeup = {"error": str(e)}
# 4. Update case status to processing
await cases_tools.case_update(case_number, status="processing")
return {
"case_number": case_number,
"status": "processing",
"issue_id": issue["issue_id"],
"issue_identifier": issue["identifier"],
"project_url": issue["project_url"],
"wakeup": wakeup,
}
# ── Agent Activity Mirror ─────────────────────────────────────────
@app.get("/api/cases/{case_number}/agents")
async def api_case_agents(case_number: str):
"""Get all Paperclip agent activity for a case: issues, comments, agent status."""
issues = await pc_get_case_issues(case_number)
if not issues:
return {"issues": [], "comments": [], "agents": []}
issue_ids = [i["id"] for i in issues]
company_id = issues[0]["company_id"]
comments, agents = await pc_get_issue_comments(issue_ids), await pc_get_agents_for_case(company_id, issue_ids)
return {"issues": issues, "comments": comments, "agents": agents}
class AgentCommentRequest(BaseModel):
body: str
issue_id: str | None = None
@app.post("/api/cases/{case_number}/agents/comment")
async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
"""Post a comment on a Paperclip issue linked to a case.
If issue_id is omitted, the most recent non-done issue is used.
"""
issues = await pc_get_case_issues(case_number)
if not issues:
raise HTTPException(404, f"לא נמצא פרויקט Paperclip לתיק {case_number}")
if req.issue_id:
target = next((i for i in issues if i["id"] == req.issue_id), None)
if not target:
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
else:
# Pick the most recent non-done issue, or the last one
active = [i for i in issues if i["status"] != "done"]
target = active[-1] if active else issues[-1]
result = await pc_post_comment(target["id"], target["company_id"], req.body)
# Find the identifier for the response
result["issue_identifier"] = target.get("identifier", "")
return result
# ── Settings: Tag → Company Mappings ──────────────────────────────
@app.get("/api/settings/paperclip-companies")
async def api_paperclip_companies():
"""List all companies from Paperclip's DB."""
pc_url = os.environ.get(
"PAPERCLIP_DB_URL", "postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip"
)
try:
conn = await asyncpg.connect(pc_url)
try:
rows = await conn.fetch(
"SELECT id, name, issue_prefix FROM companies ORDER BY name"
)
return [{"id": str(r["id"]), "name": r["name"], "prefix": r.get("issue_prefix", "")} for r in rows]
finally:
await conn.close()
except Exception as e:
raise HTTPException(502, f"Cannot reach Paperclip DB: {e}")
@app.get("/api/settings/tag-mappings")
async def api_get_tag_mappings():
"""Get all tag → company mappings."""
pool = await db.get_pool()
rows = await pool.fetch(
"SELECT id, tag, tag_label, company_id, company_name, created_at FROM tag_company_mappings ORDER BY tag"
)
return [dict(r) for r in rows]
class TagMappingRequest(BaseModel):
tag: str
tag_label: str = ""
company_id: str
company_name: str = ""
@app.post("/api/settings/tag-mappings")
async def api_add_tag_mapping(req: TagMappingRequest):
"""Add a tag → company mapping."""
pool = await db.get_pool()
try:
row = await pool.fetchrow(
"""INSERT INTO tag_company_mappings (tag, tag_label, company_id, company_name)
VALUES ($1, $2, $3, $4)
ON CONFLICT (tag, company_id) DO UPDATE SET tag_label = $2, company_name = $4
RETURNING id, tag, tag_label, company_id, company_name""",
req.tag, req.tag_label, req.company_id, req.company_name,
)
return dict(row)
except Exception as e:
raise HTTPException(400, str(e))
@app.delete("/api/settings/tag-mappings/{mapping_id}")
async def api_delete_tag_mapping(mapping_id: str):
"""Delete a tag → company mapping."""
pool = await db.get_pool()
result = await pool.execute("DELETE FROM tag_company_mappings WHERE id = $1::uuid", mapping_id)
if result == "DELETE 0":
raise HTTPException(404, "Mapping not found")
return {"ok": True}
# ── Methodology Settings ───────────────────────────────────────────
from legal_mcp.services.lessons import (
GOLDEN_RATIOS,
DISCUSSION_RULES,
CONTENT_CHECKLISTS,
)
_METHODOLOGY_DEFAULTS: dict[str, dict] = {
"golden_ratios": {k: {s: list(v) for s, v in sec.items()} for k, sec in GOLDEN_RATIOS.items()},
"discussion_rules": dict(DISCUSSION_RULES),
"content_checklists": dict(CONTENT_CHECKLISTS),
}
_VALID_CATEGORIES = set(_METHODOLOGY_DEFAULTS.keys())
@app.get("/api/methodology/{category}")
async def api_get_methodology(category: str):
"""Get methodology settings with DB overrides merged over defaults."""
if category not in _VALID_CATEGORIES:
raise HTTPException(400, f"Unknown category: {category}. Valid: {sorted(_VALID_CATEGORIES)}")
defaults = _METHODOLOGY_DEFAULTS[category]
pool = await db.get_pool()
rows = await pool.fetch(
"SELECT rule_key, rule_value, created_at FROM appeal_type_rules "
"WHERE appeal_type = '_global' AND rule_category = $1",
category,
)
overrides = {r["rule_key"]: r for r in rows}
items = {}
for key, default_val in defaults.items():
if key in overrides:
items[key] = {
"value": overrides[key]["rule_value"],
"is_override": True,
"updated_at": overrides[key]["created_at"].isoformat() if overrides[key]["created_at"] else None,
}
else:
items[key] = {"value": default_val, "is_override": False, "updated_at": None}
return {"items": items}
class MethodologyUpdateRequest(BaseModel):
value: Any
@app.put("/api/methodology/{category}/{key}")
async def api_update_methodology(category: str, key: str, req: MethodologyUpdateRequest):
"""Upsert a methodology override. Validates value shape per category."""
if category not in _VALID_CATEGORIES:
raise HTTPException(400, f"Unknown category: {category}")
if key not in _METHODOLOGY_DEFAULTS[category]:
raise HTTPException(400, f"Unknown key '{key}' for category '{category}'")
# Validate value shape
if category == "golden_ratios":
if not isinstance(req.value, dict):
raise HTTPException(422, "golden_ratios value must be a dict of section → [min, max]")
for sec, rng in req.value.items():
if not (isinstance(rng, list) and len(rng) == 2 and all(isinstance(x, (int, float)) for x in rng)):
raise HTTPException(422, f"Section '{sec}' must be [min, max] (integers 0-100)")
elif category == "discussion_rules":
if not isinstance(req.value, list) or not all(isinstance(s, str) and s.strip() for s in req.value):
raise HTTPException(422, "discussion_rules value must be a list of non-empty strings")
elif category == "content_checklists":
if not isinstance(req.value, str) or not req.value.strip():
raise HTTPException(422, "content_checklists value must be a non-empty string")
pool = await db.get_pool()
await pool.execute(
"INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) "
"VALUES (gen_random_uuid(), '_global', $1, $2, $3::jsonb) "
"ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::jsonb",
category, key, json.dumps(req.value, ensure_ascii=False),
)
return {"key": key, "value": req.value, "is_override": True}
@app.delete("/api/methodology/{category}/{key}")
async def api_reset_methodology(category: str, key: str):
"""Delete methodology override, restoring the hardcoded default."""
if category not in _VALID_CATEGORIES:
raise HTTPException(400, f"Unknown category: {category}")
if key not in _METHODOLOGY_DEFAULTS[category]:
raise HTTPException(400, f"Unknown key '{key}' for category '{category}'")
pool = await db.get_pool()
await pool.execute(
"DELETE FROM appeal_type_rules WHERE appeal_type = '_global' AND rule_category = $1 AND rule_key = $2",
category, key,
)
return {"key": key, "value": _METHODOLOGY_DEFAULTS[category][key], "is_override": False}
# ── Skill Management API ───────────────────────────────────────────
PAPERCLIP_DB_URL = os.environ.get(
"PAPERCLIP_DB_URL", "postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip"
)
# In Docker: mounted at /paperclip-skills; locally: ~/.paperclip/instances/default/skills
_docker_skills = Path("/paperclip-skills")
# Paperclip runs locally via pm2; skills are in ~/.paperclip
_local_skills = Path.home() / ".paperclip" / "instances" / "default" / "skills"
PAPERCLIP_SKILLS_DIR = _docker_skills if _docker_skills.exists() else _local_skills
PAPERCLIP_SKILLS_DIR = _local_skills
# Default company ID for skills
SKILLS_COMPANY_ID = os.environ.get("PAPERCLIP_COMPANY_ID", "42a7acd0-30c5-4cbd-ac97-7424f65df294")
@@ -2006,15 +2548,20 @@ SKILLS_COMPANY_ID = os.environ.get("PAPERCLIP_COMPANY_ID", "42a7acd0-30c5-4cbd-a
@app.get("/api/admin/skills")
async def api_list_skills():
"""List installed Paperclip skills with DB sync status."""
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
rows = []
try:
rows = await conn.fetch(
"SELECT slug, name, length(markdown) as md_chars, file_inventory, updated_at "
"FROM company_skills WHERE company_id = $1::uuid ORDER BY slug",
SKILLS_COMPANY_ID,
)
finally:
await conn.close()
conn = await asyncpg.connect(PAPERCLIP_DB_URL, timeout=5)
try:
rows = await conn.fetch(
"SELECT slug, name, length(markdown) as md_chars, file_inventory, updated_at "
"FROM company_skills WHERE company_id = $1::uuid ORDER BY slug",
SKILLS_COMPANY_ID,
)
finally:
await conn.close()
except (OSError, asyncpg.PostgresError, asyncpg.InterfaceError, TimeoutError):
# Paperclip DB unreachable — continue with disk-only skills
pass
skills = []
for r in rows:
@@ -2317,7 +2864,7 @@ async def api_restart_paperclip():
"message": "Restart requested — the host watcher will restart Paperclip shortly.",
}
except Exception:
raise HTTPException(500, "Cannot restart Paperclip from Docker. Run manually: pm2 restart paperclip")
raise HTTPException(500, "שגיאה בהפעלת restart. הרץ ידנית: pm2 restart paperclip")
@app.post("/api/cases/{case_number}/documents/upload-tagged")
@@ -2394,25 +2941,28 @@ async def _process_tagged_document(task_id: str, dest: Path, case_number: str, c
_progress[task_id] = {"status": "processing", "filename": display_name, "step": "extracting"}
result = await processor.process_document(doc_id, case_id)
# Git commit + push
repo_dir = config.find_case_dir(case_number)
if repo_dir.exists():
env = {
"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
"PATH": "/usr/bin:/bin",
}
doc_type_hebrew = DOC_TYPE_NAMES.get(doc_type, doc_type)
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
subprocess.run(
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {display_name}"],
cwd=repo_dir, capture_output=True, env=env,
)
# Try to push to Gitea (non-blocking)
subprocess.run(["git", "push"], cwd=repo_dir, capture_output=True, env={
**env,
"GIT_TERMINAL_PROMPT": "0",
})
# Git commit + push (best-effort — don't fail upload on git errors)
try:
repo_dir = config.find_case_dir(case_number)
if repo_dir.exists():
env = {
"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
"PATH": "/usr/bin:/bin",
}
doc_type_hebrew = DOC_TYPE_NAMES.get(doc_type, doc_type)
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
subprocess.run(
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {display_name}"],
cwd=repo_dir, capture_output=True, env=env,
)
# Try to push to Gitea (non-blocking)
subprocess.run(["git", "push"], cwd=repo_dir, capture_output=True, env={
**env,
"GIT_TERMINAL_PROMPT": "0",
})
except Exception:
logger.warning("Git commit/push failed for %s (non-critical)", display_name)
_progress[task_id] = {
"status": "completed",
@@ -2650,21 +3200,24 @@ async def _process_case_document(task_id: str, source: Path, req: ClassifyReques
_progress[task_id] = {"status": "processing", "filename": req.filename, "step": "extracting"}
result = await processor.process_document(UUID(doc["id"]), case_id)
# Git commit
repo_dir = config.find_case_dir(req.case_number)
if repo_dir.exists():
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
doc_type_hebrew = {
"appeal": "כתב ערר", "response": "תשובה", "decision": "החלטה",
"reference": "מסמך עזר", "exhibit": "נספח",
}.get(req.doc_type, req.doc_type)
subprocess.run(
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {title}"],
cwd=repo_dir, capture_output=True,
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
"PATH": "/usr/bin:/bin"},
)
# Git commit (best-effort)
try:
repo_dir = config.find_case_dir(req.case_number)
if repo_dir.exists():
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
doc_type_hebrew = {
"appeal": "כתב ערר", "response": "תשובה", "decision": "החלטה",
"reference": "מסמך עזר", "exhibit": "נספח",
}.get(req.doc_type, req.doc_type)
subprocess.run(
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {title}"],
cwd=repo_dir, capture_output=True,
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
"PATH": "/usr/bin:/bin"},
)
except Exception:
logger.warning("Git commit failed for %s (non-critical)", req.filename)
# Remove from uploads
source.unlink(missing_ok=True)

View File

@@ -12,6 +12,7 @@ import os
import uuid
import asyncpg
import httpx
logger = logging.getLogger(__name__)
@@ -20,6 +21,8 @@ PAPERCLIP_DB_URL = os.environ.get(
)
PLUGIN_ID = "53461b5a-7f58-411a-9952-72f9c8d4a328" # marcusgroup.legal-ai
PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100")
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY", "")
# Company IDs from Paperclip DB
COMPANIES = {
@@ -27,19 +30,49 @@ COMPANIES = {
"betterment": "8639e837-4c9d-47fa-a76b-95788d651896", # CMPA — היטלי השבחה
}
APPEAL_TYPE_TO_COMPANY = {
"רישוי": "licensing",
"licensing": "licensing",
"היטל השבחה": "betterment",
"betterment_levy": "betterment",
"פיצויים": "betterment",
"compensation": "betterment",
# CEO agent per company — used for wakeup routing
CEO_AGENTS = {
COMPANIES["licensing"]: "752cebdd-6748-4a04-aacd-c7ab0294ef33", # CMP CEO
COMPANIES["betterment"]: "cdbfa8bc-3d61-41a4-a2e7-677ec7d34562", # CMPA CEO
}
# Default for backwards compat
CEO_AGENT_ID = CEO_AGENTS[COMPANIES["licensing"]]
# Fallback mapping — used only when DB lookup returns no results
_FALLBACK_APPEAL_TYPE_TO_COMPANY = {
"רישוי": COMPANIES["licensing"],
"היטל השבחה": COMPANIES["betterment"],
"פיצויים": COMPANIES["betterment"],
"building_permit": COMPANIES["licensing"],
"betterment_levy": COMPANIES["betterment"],
"compensation_197": COMPANIES["betterment"],
"compensation": COMPANIES["betterment"],
"licensing": COMPANIES["licensing"],
}
# Legal-AI DB URL for reading tag_company_mappings
_LEGAL_DB_URL = os.environ.get("POSTGRES_URL") or os.environ.get(
"DATABASE_URL", "postgresql://legal:legal@127.0.0.1:5432/legal_ai"
)
def _get_company_id(appeal_type: str) -> str:
key = APPEAL_TYPE_TO_COMPANY.get(appeal_type, "licensing")
return COMPANIES[key]
async def _get_company_id(appeal_type: str) -> str:
"""Resolve appeal_type tag to a Paperclip company ID via DB mappings, with fallback."""
try:
conn = await asyncpg.connect(_LEGAL_DB_URL)
try:
row = await conn.fetchrow(
"SELECT company_id FROM tag_company_mappings WHERE tag = $1 LIMIT 1",
appeal_type,
)
if row:
return row["company_id"]
finally:
await conn.close()
except Exception:
logger.debug("DB lookup for tag mapping failed, using fallback for '%s'", appeal_type)
return _FALLBACK_APPEAL_TYPE_TO_COMPANY.get(appeal_type, COMPANIES["licensing"])
async def create_project(
@@ -50,11 +83,16 @@ async def create_project(
color: str = "#6366f1",
) -> dict:
"""Create a project in the Paperclip embedded DB, or return existing one."""
company_id = _get_company_id(appeal_type)
prefix = "CMP" if _get_company_id(appeal_type) == COMPANIES["licensing"] else "CMPA"
company_id = await _get_company_id(appeal_type)
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
# Resolve prefix from company issue_prefix in Paperclip DB
comp_row = await conn.fetchrow(
"SELECT issue_prefix FROM companies WHERE id = $1::uuid", company_id,
)
prefix = comp_row["issue_prefix"] if comp_row and comp_row["issue_prefix"] else "CMP"
# Check for existing project with this case number
existing = await conn.fetchrow(
"SELECT id, name FROM projects WHERE name LIKE $1 AND company_id = $2::uuid",
@@ -216,8 +254,282 @@ async def get_project_url(case_number: str) -> str | None:
f"%{case_number}%",
)
if row:
prefix = "CMP" if row["company_id"] == uuid.UUID(COMPANIES["licensing"]) else "CMPA"
comp_row = await conn.fetchrow(
"SELECT issue_prefix FROM companies WHERE id = $1::uuid", str(row["company_id"]),
)
prefix = comp_row["issue_prefix"] if comp_row and comp_row["issue_prefix"] else "CMP"
return f"https://pc.nautilus.marcusgroup.org/{prefix}/projects/{row['id']}/issues"
return None
finally:
await conn.close()
async def create_workflow_issue(case_number: str, title: str) -> dict:
"""Create a workflow issue in the existing Paperclip project for a case.
Returns dict with issue_id, identifier, project_url.
Raises ValueError if no project found.
"""
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
# Find existing project
row = await conn.fetchrow(
"SELECT id, company_id FROM projects WHERE name LIKE $1",
f"%{case_number}%",
)
if not row:
raise ValueError(f"No Paperclip project found for case {case_number}")
project_id = str(row["id"])
company_id = str(row["company_id"])
# Get company prefix
comp_row = await conn.fetchrow(
"SELECT issue_prefix FROM companies WHERE id = $1::uuid", company_id,
)
prefix = comp_row["issue_prefix"] if comp_row and comp_row["issue_prefix"] else "CMP"
# Create the workflow issue
issue_id, identifier = await _create_issue(
conn, company_id, project_id, case_number,
f"התחל תהליך ניסוח — {title}"[:200], prefix,
)
# Link to legal-ai case via plugin state
await _link_case_to_issue(conn, issue_id, case_number)
project_url = f"https://pc.nautilus.marcusgroup.org/{prefix}/projects/{project_id}/issues"
logger.info("Created workflow issue %s for case %s", identifier, case_number)
return {
"issue_id": issue_id,
"identifier": identifier,
"company_id": company_id,
"project_url": project_url,
}
finally:
await conn.close()
async def get_case_issues(case_number: str) -> list[dict]:
"""Get all Paperclip issues linked to a legal-ai case number."""
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
rows = await conn.fetch(
"""SELECT i.id, i.title, i.status, i.identifier, i.priority,
i.assignee_agent_id, a.name AS assignee_name,
i.started_at, i.completed_at, i.created_at, i.company_id
FROM issues i
JOIN plugin_state ps ON ps.scope_id = i.id::text
LEFT JOIN agents a ON i.assignee_agent_id = a.id
WHERE ps.plugin_id = $1::uuid
AND ps.state_key = 'legal-case-number'
AND ps.value_json = $2::jsonb
ORDER BY i.created_at""",
PLUGIN_ID, json.dumps(case_number),
)
return [
{
"id": str(r["id"]),
"title": r["title"],
"status": r["status"],
"identifier": r["identifier"],
"priority": r["priority"],
"assignee_name": r["assignee_name"],
"started_at": r["started_at"].isoformat() if r["started_at"] else None,
"completed_at": r["completed_at"].isoformat() if r["completed_at"] else None,
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
"company_id": str(r["company_id"]),
}
for r in rows
]
finally:
await conn.close()
async def get_issue_comments(issue_ids: list[str]) -> list[dict]:
"""Get all comments on a list of Paperclip issues, with agent metadata."""
if not issue_ids:
return []
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
rows = await conn.fetch(
"""SELECT ic.id, ic.issue_id, ic.body, ic.created_at,
ic.author_agent_id, ic.author_user_id,
a.name AS agent_name, a.role AS agent_role, a.icon AS agent_icon
FROM issue_comments ic
LEFT JOIN agents a ON ic.author_agent_id = a.id
WHERE ic.issue_id = ANY($1::uuid[])
ORDER BY ic.created_at""",
issue_ids,
)
return [
{
"id": str(r["id"]),
"issue_id": str(r["issue_id"]),
"body": r["body"],
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
"author_agent_id": str(r["author_agent_id"]) if r["author_agent_id"] else None,
"author_user_id": r["author_user_id"],
"agent_name": r["agent_name"],
"agent_role": r["agent_role"],
"agent_icon": r["agent_icon"],
}
for r in rows
]
finally:
await conn.close()
async def get_agents_for_company(company_id: str) -> list[dict]:
"""Get all agents belonging to a Paperclip company."""
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
rows = await conn.fetch(
"""SELECT id, name, role, title, status, icon, last_heartbeat_at
FROM agents
WHERE company_id = $1::uuid
ORDER BY role, name""",
company_id,
)
return [
{
"id": str(r["id"]),
"name": r["name"],
"role": r["role"],
"title": r["title"],
"status": r["status"],
"icon": r["icon"],
"last_heartbeat_at": r["last_heartbeat_at"].isoformat() if r["last_heartbeat_at"] else None,
}
for r in rows
]
finally:
await conn.close()
async def get_agents_for_case(company_id: str, issue_ids: list[str]) -> list[dict]:
"""Get agents with per-case status (running on *this* case vs globally)."""
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
rows = await conn.fetch(
"""SELECT a.id, a.name, a.role, a.title, a.icon,
a.status AS global_status, a.last_heartbeat_at,
EXISTS(
SELECT 1 FROM heartbeat_runs hr
JOIN agent_wakeup_requests wr ON hr.wakeup_request_id = wr.id
WHERE hr.agent_id = a.id
AND hr.status = 'running'
AND wr.payload->>'issueId' = ANY($2::text[])
) AS active_on_case
FROM agents a
WHERE a.company_id = $1::uuid
ORDER BY a.role, a.name""",
company_id, issue_ids,
)
return [
{
"id": str(r["id"]),
"name": r["name"],
"role": r["role"],
"title": r["title"],
"status": "running" if r["active_on_case"] else (
"idle" if r["global_status"] == "running" else r["global_status"]
),
"icon": r["icon"],
"last_heartbeat_at": r["last_heartbeat_at"].isoformat() if r["last_heartbeat_at"] else None,
}
for r in rows
]
finally:
await conn.close()
async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
"""Post a comment on a Paperclip issue.
Tries the Board API first (triggers plugin events for CEO routing).
Falls back to direct DB insert + CEO wakeup if API fails.
"""
# Try Board API first — this triggers the event bus
if PAPERCLIP_BOARD_API_KEY:
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"{PAPERCLIP_API_URL}/api/board/issues/{issue_id}/comments",
json={"body": body},
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
)
if resp.status_code < 400:
result = resp.json()
logger.info("Posted comment via Board API on issue %s", issue_id)
return {"comment_id": result.get("id", ""), "issue_id": issue_id, "method": "api"}
except Exception:
logger.debug("Board API comment failed for issue %s, falling back to DB", issue_id)
# Fallback: direct DB insert + explicit CEO wakeup
comment_id = str(uuid.uuid4())
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
await conn.execute(
"""INSERT INTO issue_comments (id, company_id, issue_id, author_user_id, body)
VALUES ($1, $2::uuid, $3::uuid, 'chaim', $4)""",
comment_id, company_id, issue_id, body,
)
logger.info("Posted comment via DB fallback on issue %s", issue_id)
finally:
await conn.close()
# Wake the correct CEO for this company
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
try:
url = f"{PAPERCLIP_API_URL}/api/agents/{ceo_id}/wakeup"
payload = {
"source": "on_demand",
"triggerDetail": "manual",
"reason": f"user_comment_{issue_id}",
"payload": {"issueId": issue_id, "mutation": "comment"},
}
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
url, json=payload,
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
)
resp.raise_for_status()
except Exception:
logger.warning("Failed to wake CEO after DB comment on issue %s", issue_id)
return {"comment_id": comment_id, "issue_id": issue_id, "method": "db_fallback"}
async def wake_ceo_agent(issue_id: str, case_number: str, company_id: str = "") -> dict:
"""Wake the CEO agent via Paperclip's wakeup API.
MUST use API, never direct DB insert (agent won't wake from DB insert).
Routes to the correct CEO based on company_id.
"""
if not PAPERCLIP_BOARD_API_KEY:
raise RuntimeError("PAPERCLIP_BOARD_API_KEY not set — cannot wake CEO agent")
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
url = f"{PAPERCLIP_API_URL}/api/agents/{ceo_id}/wakeup"
payload = {
"source": "on_demand",
"triggerDetail": "manual",
"reason": f"start_workflow_{case_number}",
"payload": {
"issueId": issue_id,
"mutation": "workflow_start",
},
}
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
url,
json=payload,
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
)
resp.raise_for_status()
result = resp.json()
logger.info("CEO agent wakeup for case %s: %s", case_number, result)
return result

View File

@@ -1,369 +0,0 @@
/* ════════════════════════════════════════════════════════════
* Ezer Mishpati — Design System
* Editorial/Judicial aesthetic for a Hebrew RTL judicial tool.
*
* Typography: Frank Ruhl Libre (display) + Assistant (body)
* Palette: Navy #0f172a + Cream #f5f1e8 + Gold #a97d3a
* ════════════════════════════════════════════════════════════ */
@import url('https://fonts.googleapis.com/css2?family=Heebo:wght@300;400;500;600;700;800;900&display=swap');
:root {
/* ── Colors ─────────────────────────────────────────── */
--color-navy: #0f172a;
--color-navy-soft: #1e293b;
--color-navy-dim: #334155;
--color-cream: #f5f1e8;
--color-cream-deep: #ede8d8;
--color-parchment: #fbf8f0;
--color-gold: #a97d3a;
--color-gold-deep: #8b6428;
--color-gold-soft: #c89a56;
--color-gold-wash: #fdf6e8;
--color-ink: #1a1a2e;
--color-ink-soft: #3a3a52;
--color-ink-muted: #6b7280;
--color-ink-light: #9ca3af;
--color-rule: #e5dfd0; /* cream-toned hairline */
--color-rule-soft: #f0ead8;
--color-surface: #ffffff;
--color-surface-raised: #fbf8f0;
--color-bg: var(--color-cream);
/* Status colors — tuned to the palette */
--color-success: #4a7c59;
--color-success-bg: #e8efe7;
--color-warn: #b8894a;
--color-warn-bg: #faf0dc;
--color-danger: #a54242;
--color-danger-bg: #f5e6e6;
--color-info: #4e6a8c;
--color-info-bg: #e6ecf3;
/* ── Typography — Heebo (Google's primary Hebrew font) ─── */
--font-display: 'Heebo', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-body: 'Heebo', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: ui-monospace, 'Cascadia Code', 'SF Mono', Menlo, monospace;
--text-xs: 0.75rem;
--text-sm: 0.85rem;
--text-base: 0.95rem;
--text-md: 1.05rem;
--text-lg: 1.2rem;
--text-xl: 1.45rem;
--text-2xl: 1.8rem;
--text-3xl: 2.3rem;
--text-4xl: 2.9rem;
--leading-tight: 1.25;
--leading-snug: 1.4;
--leading-body: 1.65;
--leading-prose: 1.8;
--weight-light: 300;
--weight-normal: 400;
--weight-medium: 500;
--weight-semi: 600;
--weight-bold: 700;
--weight-display: 900;
/* ── Spacing scale (8px grid) ───────────────────────── */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 32px;
--space-8: 40px;
--space-9: 56px;
--space-10: 72px;
/* ── Radii ──────────────────────────────────────────── */
--radius-sm: 4px;
--radius: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-pill: 999px;
/* ── Shadows — soft, editorial ──────────────────────── */
--shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.05);
--shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
--shadow: 0 2px 6px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.08), 0 2px 4px rgba(15, 23, 42, 0.04);
--shadow-lg: 0 10px 30px rgba(15, 23, 42, 0.12), 0 2px 6px rgba(15, 23, 42, 0.05);
--shadow-gold: 0 0 0 3px var(--color-gold-wash);
/* ── Transitions ────────────────────────────────────── */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--t-fast: 120ms var(--ease-out);
--t: 180ms var(--ease-out);
--t-slow: 280ms var(--ease-out);
}
/* ── Dark theme overrides ────────────────────────────── */
body.dark {
--color-navy: #f5f1e8;
--color-navy-soft: #e8e0c8;
--color-navy-dim: #c7bc9a;
--color-cream: #0a0f1c;
--color-cream-deep: #121a2e;
--color-parchment: #161f36;
--color-gold: #d4a55a;
--color-gold-deep: #e8bc6f;
--color-gold-soft: #c89a56;
--color-gold-wash: rgba(212, 165, 90, 0.08);
--color-ink: #f5f1e8;
--color-ink-soft: #d8d2c0;
--color-ink-muted: #9a9380;
--color-ink-light: #6a6458;
--color-rule: #2a3352;
--color-rule-soft: #1e2a45;
--color-surface: #141b2f;
--color-surface-raised: #1a2238;
--color-bg: #0a0f1c;
--color-success: #5a9a6a;
--color-success-bg: rgba(90, 154, 106, 0.12);
--color-warn: #c79956;
--color-warn-bg: rgba(199, 153, 86, 0.12);
--color-danger: #c16565;
--color-danger-bg: rgba(193, 101, 101, 0.12);
--color-info: #6d8bab;
--color-info-bg: rgba(109, 139, 171, 0.12);
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.25);
--shadow: 0 2px 6px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.25);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.45), 0 2px 4px rgba(0, 0, 0, 0.25);
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.3);
}
body.dark header {
background: #060a18;
border-bottom-color: var(--color-gold);
}
/* ── Base overrides ──────────────────────────────────── */
html { font-size: 16px; }
body {
font-family: var(--font-body);
font-weight: var(--weight-normal);
font-size: var(--text-base);
line-height: var(--leading-body);
color: var(--color-ink);
background: var(--color-bg);
direction: rtl;
text-align: right;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "kern", "liga", "clig", "calt";
}
/* Display typography — serif for headings */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
font-weight: var(--weight-bold);
line-height: var(--leading-tight);
color: var(--color-navy);
letter-spacing: -0.01em;
}
h1 { font-size: var(--text-3xl); font-weight: var(--weight-display); }
h2 { font-size: var(--text-2xl); }
h3 { font-size: var(--text-xl); }
h4 { font-size: var(--text-lg); }
h5 { font-size: var(--text-md); }
h6 { font-size: var(--text-base); }
/* Prose paragraphs — justify both sides for Hebrew legal text */
p,
.prose {
text-align: justify;
text-justify: inter-word;
hyphens: auto;
line-height: var(--leading-body);
}
/* Text that should NOT justify (short labels, meta) */
.no-justify, .meta, .label, .caption,
th, td, button, input, select, label, nav {
text-align: right;
}
/* Links */
a {
color: var(--color-gold-deep);
text-decoration: none;
transition: color var(--t-fast);
}
a:hover { color: var(--color-gold); }
/* Focus rings — gold, subtle */
*:focus-visible {
outline: 2px solid var(--color-gold);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* Selection */
::selection {
background: var(--color-gold-wash);
color: var(--color-navy);
}
/* ── Utility classes ─────────────────────────────────── */
.text-display { font-family: var(--font-display); }
.text-body { font-family: var(--font-body); }
.text-mono { font-family: var(--font-mono); }
.text-xs { font-size: var(--text-xs); }
.text-sm { font-size: var(--text-sm); }
.text-base { font-size: var(--text-base); }
.text-md { font-size: var(--text-md); }
.text-lg { font-size: var(--text-lg); }
.text-xl { font-size: var(--text-xl); }
.text-2xl { font-size: var(--text-2xl); }
.text-3xl { font-size: var(--text-3xl); }
.text-muted { color: var(--color-ink-muted); }
.text-light { color: var(--color-ink-light); }
.text-gold { color: var(--color-gold-deep); }
.text-navy { color: var(--color-navy); }
.weight-light { font-weight: var(--weight-light); }
.weight-normal { font-weight: var(--weight-normal); }
.weight-medium { font-weight: var(--weight-medium); }
.weight-bold { font-weight: var(--weight-bold); }
.justify { text-align: justify; text-justify: inter-word; }
.start { text-align: right; } /* RTL start */
.end { text-align: left; } /* RTL end */
.center { text-align: center; }
.ornament {
display: block;
text-align: center;
color: var(--color-gold);
font-family: var(--font-display);
letter-spacing: 0.3em;
margin: var(--space-6) 0;
}
.ornament::before { content: "❦"; font-size: 1.3em; }
.divider {
border: 0;
height: 1px;
background: linear-gradient(
to left,
transparent 0%,
var(--color-rule) 20%,
var(--color-rule) 80%,
transparent 100%
);
margin: var(--space-6) 0;
}
.divider-gold {
border: 0;
height: 2px;
background: linear-gradient(
to left,
transparent 0%,
var(--color-gold) 50%,
transparent 100%
);
margin: var(--space-6) 0;
}
/* ── Loading skeleton ───────────────────────────────── */
.skeleton {
background: linear-gradient(
100deg,
var(--color-cream-deep) 30%,
var(--color-parchment) 50%,
var(--color-cream-deep) 70%
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s linear infinite;
border-radius: var(--radius);
color: transparent;
user-select: none;
}
@keyframes skeleton-shimmer {
from { background-position: 100% 0; }
to { background-position: -100% 0; }
}
.skeleton-line {
height: 0.9em;
margin: 4px 0;
border-radius: var(--radius-sm);
}
.skeleton-line.short { width: 40%; }
.skeleton-line.medium { width: 70%; }
/* ── Print — optimized for Dafna printing the portrait ─ */
@media print {
:root {
--color-bg: #fff;
--color-surface: #fff;
--color-navy: #000;
--color-ink: #000;
--color-ink-muted: #444;
}
body { background: #fff; color: #000; font-size: 11pt; }
header, .status-bar, .process-panel, .toast, .btn, nav,
#navDiagnostics, .home-sidebar, .home-hero-actions,
#processPanel, #trainingAnalysisCard, #trainingTasksCard {
display: none !important;
}
.main { max-width: 100% !important; padding: 0 !important; }
.page { display: none !important; }
.page.active { display: block !important; }
.portrait-card, .card {
box-shadow: none !important;
border: 1px solid #ccc !important;
page-break-inside: avoid;
margin-bottom: 16px !important;
}
.portrait-headline {
background: #fafafa !important;
border-right: 3px solid #000 !important;
color: #000 !important;
}
h1, h2, h3 { color: #000 !important; page-break-after: avoid; }
.growth-curve, .donut, .hero-timeline { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.phrase-filters, .btn, button { display: none !important; }
/* Force expand all details */
details { display: block !important; }
summary::marker, summary::-webkit-details-marker { display: none; }
}
/* ── Responsive (desktop-first, minimal mobile) ────── */
@media (max-width: 900px) {
.main { padding: var(--space-5) var(--space-4); }
header { padding: 14px 20px; flex-wrap: wrap; gap: 10px; }
header nav { gap: 2px; }
header nav a { padding: 6px 10px; font-size: 0.82em; }
.home-hero-title { font-size: 2em; }
.style-report-header h1 { font-size: 2em; }
.portrait-card { padding: var(--space-6) var(--space-5); }
.portrait-hero .hero-body { grid-template-columns: 1fr; }
.hero-donut-wrap { justify-content: center; }
.process-panel { width: 280px; }
}

File diff suppressed because it is too large Load Diff